refactor: scope architecture baseline operations

This commit is contained in:
jxxghp
2026-08-21 19:21:27 +08:00
parent 300ae75bcf
commit 7bc3ea831f
5 changed files with 403 additions and 40 deletions
@@ -1352,8 +1352,10 @@ done_when: []
当前可复现命令: 当前可复现命令:
```bash ```bash
./.venv/bin/python scripts/startup/performance.py --repeat 3 ./.venv/bin/python scripts/startup/performance.py --write --repeat 3
./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins ./.venv/bin/python scripts/architecture/baseline.py --check-host
./.venv/bin/python scripts/architecture/baseline.py \
--check-plugins --plugin-repo ../MoviePilot-Plugins
``` ```
### 11.4 2026-08-18 当前验证快照(收口批次) ### 11.4 2026-08-18 当前验证快照(收口批次)
@@ -1361,7 +1363,7 @@ done_when: []
| 范围 | 命令 | 结果 | | 范围 | 命令 | 结果 |
| --- | --- | --- | | --- | --- | --- |
| 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped2026-08-18);失败为未修改的 Agent 图片能力测试,架构专项不受影响 | | 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped2026-08-18);失败为未修改的 Agent 图片能力测试,架构专项不受影响 |
| 架构与插件快照 | `./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins` | 已通过,基线已更新为 746 模块 / 6,024 边 | | 架构与插件快照 | 分别运行 `--check-host``--check-plugins --plugin-repo ../MoviePilot-Plugins` | 已通过,基线已更新为 746 模块 / 6,024 边 |
| 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed | | 前端联邦 API 客户端 | `yarn test:run src/api/__tests__/client.spec.ts src/api/__tests__/index.spec.ts` | 36 passed |
| 前端类型检查 | `yarn typecheck` | 通过 | | 前端类型检查 | `yarn typecheck` | 通过 |
| V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed | | V3 插件契约与版本门禁 | `../MoviePilot/.venv/bin/python -m pytest tests/ci/test_v3_contract.py tests/ci/test_plugin_release_gate.py -q` | 16 passed |
+70 -22
View File
@@ -668,53 +668,101 @@ def write_json(path: Path, value: dict[str, Any]) -> None:
) )
def check_json(path: Path, actual: dict[str, Any]) -> bool: def _display_path(path: Path) -> Path:
"""比较当前扫描结果和已提交基线并输出可执行提示。""" try:
return path.relative_to(PROJECT_ROOT)
except ValueError:
return path
def check_json(
path: Path,
actual: dict[str, Any],
*,
write_hint: str,
) -> bool:
"""比较当前扫描结果和已提交基线并输出限定范围的更新提示。"""
expected = json.loads(path.read_text(encoding="utf-8")) expected = json.loads(path.read_text(encoding="utf-8"))
if expected == actual: if expected == actual:
return True return True
print( print(
f"架构基线已变化:{path.relative_to(PROJECT_ROOT)}" f"架构基线已变化:{_display_path(path)}"
"确认变更符合边界后运行 scripts/architecture/baseline.py --write", f"确认变更符合边界后运行 scripts/architecture/baseline.py {write_hint}",
file=sys.stderr, file=sys.stderr,
) )
return False return False
def parse_args() -> argparse.Namespace: def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
"""解析基线写入、校验和外部插件仓参数。""" """解析限定宿主或插件范围的基线操作参数。"""
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
action = parser.add_mutually_exclusive_group(required=True) action = parser.add_mutually_exclusive_group(required=True)
action.add_argument("--write", action="store_true", help="写入当前架构基线") action.add_argument("--check-host", action="store_true", help="校验宿主架构基线")
action.add_argument("--check", action="store_true", help="校验当前架构基线") action.add_argument("--check-plugins", action="store_true", help="校验官方插件基线")
action.add_argument("--write-host", action="store_true", help="写入宿主架构基线")
action.add_argument("--write-plugins", action="store_true", help="写入官方插件基线")
action.add_argument("--check", action="store_true", help=argparse.SUPPRESS)
action.add_argument("--write", action="store_true", help=argparse.SUPPRESS)
parser.add_argument(
"--scope",
choices=("host", "plugins"),
help="旧 --check/--write 的必填兼容范围",
)
parser.add_argument( parser.add_argument(
"--plugin-repo", "--plugin-repo",
type=Path, type=Path,
help="可选的独立 MoviePilot-Plugins 仓路径", help="官方插件操作所需的独立 MoviePilot-Plugins 仓路径",
) )
return parser.parse_args() args = parser.parse_args(argv)
if args.check or args.write:
if not args.scope:
parser.error("旧 --check/--write 已弃用,必须同时指定 --scope host|plugins")
replacement = f"--{'check' if args.check else 'write'}-{args.scope}"
print(
f"警告:--{'check' if args.check else 'write'} --scope {args.scope} "
f"已弃用,请改用 {replacement}",
file=sys.stderr,
)
setattr(args, f"{'check' if args.check else 'write'}_{args.scope}", True)
plugin_action = args.check_plugins or args.write_plugins
if plugin_action and not args.plugin_repo:
parser.error("插件基线操作必须指定 --plugin-repo")
if not plugin_action and args.plugin_repo:
parser.error("--plugin-repo 只能用于插件基线操作")
return args
def main() -> int: def main(argv: Optional[list[str]] = None) -> int:
"""执行本仓基线以及可选官方插件基线的写入或校验""" """只对显式选择的宿主或插件基线执行检查或写入"""
args = parse_args() args = parse_args(argv)
baselines = [ host_action = args.check_host or args.write_host
(DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()), if host_action:
(RUNTIME_BASELINE_PATH, collect_runtime_baseline()), baselines = [
] (DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()),
if args.plugin_repo: (RUNTIME_BASELINE_PATH, collect_runtime_baseline()),
]
write_hint = "--write-host"
else:
plugin_repo = args.plugin_repo.resolve() plugin_repo = args.plugin_repo.resolve()
if not plugin_repo.is_dir(): if not plugin_repo.is_dir():
raise SystemExit(f"插件仓不存在:{plugin_repo}") raise SystemExit(f"插件仓不存在:{plugin_repo}")
baselines.append( baselines = [
(PLUGIN_BASELINE_PATH, collect_official_plugin_baseline(plugin_repo)) (PLUGIN_BASELINE_PATH, collect_official_plugin_baseline(plugin_repo))
]
write_hint = f"--write-plugins --plugin-repo {plugin_repo}"
if args.write_host or args.write_plugins:
display_paths = ", ".join(
str(_display_path(path)) for path, _baseline in baselines
) )
if args.write: print(f"即将写入:{display_paths}")
for path, baseline in baselines: for path, baseline in baselines:
write_json(path, baseline) write_json(path, baseline)
print(f"已写入 {path.relative_to(PROJECT_ROOT)}") print(f"已写入 {_display_path(path)}")
return 0 return 0
checks = [check_json(path, baseline) for path, baseline in baselines] checks = [
check_json(path, baseline, write_hint=write_hint)
for path, baseline in baselines
]
return 0 if all(checks) else 1 return 0 if all(checks) else 1
+114 -14
View File
@@ -10,7 +10,7 @@ import subprocess
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Optional
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -28,6 +28,8 @@ IMPORT_TARGETS = (
) )
RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE=" RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE="
LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE=" LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE="
PERFORMANCE_FACTOR = 2.0
PERFORMANCE_SLACK_MS = 500.0
def measure_import(target: str) -> dict[str, Any]: def measure_import(target: str) -> dict[str, Any]:
@@ -231,31 +233,129 @@ def collect_baseline(repeat: int) -> dict[str, Any]:
} }
def parse_args() -> argparse.Namespace: def check_baseline(
"""解析输出路径和采样次数。""" expected: dict[str, Any],
actual: dict[str, Any],
) -> list[str]:
"""比较稳定资源契约与宽松耗时预算,返回所有不符合项。"""
errors: list[str] = []
expected_targets = expected.get("targets", {})
actual_targets = actual.get("targets", {})
if set(expected_targets) != set(actual_targets):
errors.append("冷导入目标集合已变化")
for target in sorted(set(expected_targets) & set(actual_targets)):
expected_target = expected_targets[target]
actual_target = actual_targets[target]
if (
actual_target["loaded_module_count"]
!= expected_target["loaded_module_count"]
):
errors.append(
f"{target} 加载模块数变化:"
f"{expected_target['loaded_module_count']} -> "
f"{actual_target['loaded_module_count']}"
)
budget_ms = max(
expected_target["max_ms"] * PERFORMANCE_FACTOR,
expected_target["max_ms"] + PERFORMANCE_SLACK_MS,
)
if actual_target["median_ms"] > budget_ms:
errors.append(
f"{target} 冷导入中位数 {actual_target['median_ms']}ms "
f"超过预算 {round(budget_ms, 3)}ms"
)
expected_modes = expected.get("lifecycle", {}).get("modes", {})
actual_modes = actual.get("lifecycle", {}).get("modes", {})
if set(expected_modes) != set(actual_modes):
errors.append("生命周期模式集合已变化")
for mode_name in sorted(set(expected_modes) & set(actual_modes)):
expected_mode = expected_modes[mode_name]
actual_mode = actual_modes[mode_name]
if (
actual_mode["enabled_component_count"]
!= expected_mode["enabled_component_count"]
):
errors.append(
f"{mode_name} 模式组件数变化:"
f"{expected_mode['enabled_component_count']} -> "
f"{actual_mode['enabled_component_count']}"
)
for sample in actual_mode.get("samples", []):
if sample["threads_after"] != sample["threads_before"]:
errors.append(f"{mode_name} 模式存在未释放线程")
if sample["tasks_after"] != sample["tasks_before"]:
errors.append(f"{mode_name} 模式存在未释放异步任务")
if sample["database_connections_started"] != 0:
errors.append(f"{mode_name} 模式隔离采样建立了数据库连接")
return errors
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
"""解析只读打印、检查或显式写入操作。"""
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
action = parser.add_mutually_exclusive_group()
action.add_argument(
"--print",
dest="operation",
action="store_const",
const="print",
help="打印本次采样且不写文件(默认)",
)
action.add_argument(
"--check",
dest="operation",
action="store_const",
const="check",
help="按已提交基线检查资源契约和宽松性能预算",
)
action.add_argument(
"--write",
dest="operation",
action="store_const",
const="write",
help="显式写入采样基线",
)
parser.add_argument("--repeat", type=int, default=3) parser.add_argument("--repeat", type=int, default=3)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser.parse_args() parser.set_defaults(operation="print")
return parser.parse_args(argv)
def main() -> int: def _display_path(path: Path) -> Path:
"""执行冷导入采样并写入 JSON 基线。""" try:
args = parse_args() return path.relative_to(PROJECT_ROOT)
except ValueError:
return path
def main(argv: Optional[list[str]] = None) -> int:
"""采样关键入口,并按显式操作打印、检查或写入结果。"""
args = parse_args(argv)
if args.repeat < 1: if args.repeat < 1:
raise SystemExit("--repeat 必须大于等于 1") raise SystemExit("--repeat 必须大于等于 1")
baseline = collect_baseline(args.repeat)
output = args.output.resolve() output = args.output.resolve()
if args.operation == "print":
print(json.dumps(baseline, ensure_ascii=False, indent=2))
return 0
if args.operation == "check":
if not output.is_file():
raise SystemExit(f"性能基线不存在:{output}")
expected = json.loads(output.read_text(encoding="utf-8"))
errors = check_baseline(expected, baseline)
if errors:
for error in errors:
print(f"性能基线检查失败:{error}", file=sys.stderr)
return 1
print(f"性能基线检查通过:{_display_path(output)}")
return 0
print(f"即将写入:{_display_path(output)}")
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
output.write_text( output.write_text(
json.dumps(collect_baseline(args.repeat), ensure_ascii=False, indent=2) json.dumps(baseline, ensure_ascii=False, indent=2) + "\n",
+ "\n",
encoding="utf-8", encoding="utf-8",
) )
try: print(f"已写入:{_display_path(output)}")
display_path = output.relative_to(PROJECT_ROOT)
except ValueError:
display_path = output
print(f"已写入 {display_path}")
return 0 return 0
+201
View File
@@ -0,0 +1,201 @@
"""架构与启动性能基线脚本的 CLI 行为测试。"""
import json
from pathlib import Path
import pytest
from scripts.architecture import baseline as architecture_baseline
from scripts.startup import performance as startup_performance
def _performance_sample(*, loaded_module_count: int = 10) -> dict:
return {
"schema_version": 1,
"generated_at": "2026-08-21T00:00:00+00:00",
"platform": "test",
"python": "3.12",
"repeat": 1,
"targets": {
"app.factory": {
"loaded_module_count": loaded_module_count,
"max_ms": 100.0,
"median_ms": 90.0,
"min_ms": 80.0,
"samples_ms": [90.0],
}
},
"lifecycle": {
"scope": "isolated no-op",
"modes": {
"normal": {
"enabled_component_count": 2,
"samples": [
{
"threads_before": 1,
"threads_after": 1,
"tasks_before": 1,
"tasks_after": 1,
"database_connections_started": 0,
}
],
}
},
},
}
def test_architecture_legacy_action_requires_scope(capsys):
"""旧操作未明确宿主或插件范围时必须拒绝执行。"""
with pytest.raises(SystemExit) as error:
architecture_baseline.parse_args(["--check"])
assert error.value.code == 2
assert "必须同时指定 --scope" in capsys.readouterr().err
def test_architecture_legacy_action_maps_to_scoped_operation(capsys):
"""兼容期旧参数应提示弃用并映射到唯一范围。"""
args = architecture_baseline.parse_args(["--check", "--scope", "host"])
assert args.check_host is True
assert "请改用 --check-host" in capsys.readouterr().err
def test_architecture_plugin_action_requires_repository(capsys):
"""插件基线操作缺少独立插件仓时必须在扫描前失败。"""
with pytest.raises(SystemExit) as error:
architecture_baseline.parse_args(["--check-plugins"])
assert error.value.code == 2
assert "必须指定 --plugin-repo" in capsys.readouterr().err
def test_architecture_write_host_only_updates_host_files(
tmp_path: Path,
monkeypatch,
capsys,
):
"""宿主写操作不得连带覆盖官方插件 fixture。"""
dependency_path = tmp_path / "dependency.json"
runtime_path = tmp_path / "runtime.json"
plugin_path = tmp_path / "plugin.json"
monkeypatch.setattr(
architecture_baseline,
"DEPENDENCY_BASELINE_PATH",
dependency_path,
)
monkeypatch.setattr(
architecture_baseline,
"RUNTIME_BASELINE_PATH",
runtime_path,
)
monkeypatch.setattr(architecture_baseline, "PLUGIN_BASELINE_PATH", plugin_path)
monkeypatch.setattr(
architecture_baseline,
"collect_dependency_baseline",
lambda: {"scope": "host-dependency"},
)
monkeypatch.setattr(
architecture_baseline,
"collect_runtime_baseline",
lambda: {"scope": "host-runtime"},
)
assert architecture_baseline.main(["--write-host"]) == 0
assert json.loads(dependency_path.read_text()) == {"scope": "host-dependency"}
assert json.loads(runtime_path.read_text()) == {"scope": "host-runtime"}
assert not plugin_path.exists()
output = capsys.readouterr().out
assert "即将写入" in output
assert "dependency.json" in output
assert "runtime.json" in output
def test_architecture_write_plugins_only_updates_plugin_file(
tmp_path: Path,
monkeypatch,
):
"""插件写操作不得修改宿主依赖和运行契约 fixture。"""
plugin_repo = tmp_path / "MoviePilot-Plugins"
(plugin_repo / "plugins.v2").mkdir(parents=True)
(plugin_repo / "plugins.v3").mkdir()
dependency_path = tmp_path / "dependency.json"
runtime_path = tmp_path / "runtime.json"
plugin_path = tmp_path / "plugin.json"
monkeypatch.setattr(
architecture_baseline,
"DEPENDENCY_BASELINE_PATH",
dependency_path,
)
monkeypatch.setattr(
architecture_baseline,
"RUNTIME_BASELINE_PATH",
runtime_path,
)
monkeypatch.setattr(architecture_baseline, "PLUGIN_BASELINE_PATH", plugin_path)
assert architecture_baseline.main(
["--write-plugins", "--plugin-repo", str(plugin_repo)]
) == 0
assert plugin_path.is_file()
assert not dependency_path.exists()
assert not runtime_path.exists()
def test_performance_default_print_does_not_write_fixture(
tmp_path: Path,
monkeypatch,
capsys,
):
"""性能脚本无操作参数时只打印采样,不得创建输出文件。"""
output = tmp_path / "performance.json"
sample = _performance_sample()
monkeypatch.setattr(startup_performance, "DEFAULT_OUTPUT", output)
monkeypatch.setattr(startup_performance, "collect_baseline", lambda _repeat: sample)
assert startup_performance.main(["--repeat", "1"]) == 0
assert not output.exists()
assert json.loads(capsys.readouterr().out) == sample
def test_performance_check_is_read_only(tmp_path: Path, monkeypatch, capsys):
"""性能检查应使用现有 fixture 且保持文件内容不变。"""
output = tmp_path / "performance.json"
sample = _performance_sample()
output.write_text(json.dumps(sample), encoding="utf-8")
content_before = output.read_bytes()
monkeypatch.setattr(startup_performance, "collect_baseline", lambda _repeat: sample)
assert startup_performance.main(
["--check", "--repeat", "1", "--output", str(output)]
) == 0
assert output.read_bytes() == content_before
assert "检查通过" in capsys.readouterr().out
def test_performance_write_requires_explicit_action(tmp_path: Path, monkeypatch):
"""只有显式 write 操作才允许写入性能 fixture。"""
output = tmp_path / "performance.json"
sample = _performance_sample()
monkeypatch.setattr(startup_performance, "collect_baseline", lambda _repeat: sample)
assert startup_performance.main(
["--write", "--repeat", "1", "--output", str(output)]
) == 0
assert json.loads(output.read_text(encoding="utf-8")) == sample
def test_performance_check_reports_loaded_module_drift():
"""稳定的冷导入模块数量变化必须产生可诊断失败。"""
expected = _performance_sample(loaded_module_count=10)
actual = _performance_sample(loaded_module_count=11)
errors = startup_performance.check_baseline(expected, actual)
assert errors == ["app.factory 加载模块数变化:10 -> 11"]
+13 -1
View File
@@ -13,8 +13,16 @@ BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture"
def test_architecture_contract_baselines_match_current_source(): def test_architecture_contract_baselines_match_current_source():
"""宿主依赖图和公开运行契约变化必须显式刷新基线。""" """宿主依赖图和公开运行契约变化必须显式刷新基线。"""
baseline_paths = (
BASELINE_ROOT / "dependency-baseline.json",
BASELINE_ROOT / "runtime-contract-baseline.json",
)
contents_before = {
path: path.read_bytes()
for path in baseline_paths
}
result = subprocess.run( result = subprocess.run(
[sys.executable, "scripts/architecture/baseline.py", "--check"], [sys.executable, "scripts/architecture/baseline.py", "--check-host"],
cwd=PROJECT_ROOT, cwd=PROJECT_ROOT,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -22,6 +30,10 @@ def test_architecture_contract_baselines_match_current_source():
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert {
path: path.read_bytes()
for path in baseline_paths
} == contents_before
def test_official_plugin_baseline_records_external_source(): def test_official_plugin_baseline_records_external_source():