From 7bc3ea831fe050182c59bec03359cddc39e3e7f8 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 21 Aug 2026 19:21:27 +0800 Subject: [PATCH] refactor: scope architecture baseline operations --- .../backend-architecture-governance.md | 8 +- scripts/architecture/baseline.py | 92 ++++++-- scripts/startup/performance.py | 128 +++++++++-- tests/test_architecture_baseline_cli.py | 201 ++++++++++++++++++ tests/test_architecture_contract_baseline.py | 14 +- 5 files changed, 403 insertions(+), 40 deletions(-) create mode 100644 tests/test_architecture_baseline_cli.py diff --git a/docs/refactor/backend-architecture-governance.md b/docs/refactor/backend-architecture-governance.md index 897fd1f4c..4cd7359e6 100644 --- a/docs/refactor/backend-architecture-governance.md +++ b/docs/refactor/backend-architecture-governance.md @@ -1352,8 +1352,10 @@ done_when: [] 当前可复现命令: ```bash -./.venv/bin/python scripts/startup/performance.py --repeat 3 -./.venv/bin/python scripts/architecture/baseline.py --check --plugin-repo ../MoviePilot-Plugins +./.venv/bin/python scripts/startup/performance.py --write --repeat 3 +./.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 当前验证快照(收口批次) @@ -1361,7 +1363,7 @@ done_when: [] | 范围 | 命令 | 结果 | | --- | --- | --- | | 后端完整门禁 | `./.venv/bin/python tests/run.py` | 4,914 passed、2 failed、3 skipped(2026-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 | | 前端类型检查 | `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 | diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index 7d0129974..5b4f41ab1 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -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")) if expected == actual: return True print( - f"架构基线已变化:{path.relative_to(PROJECT_ROOT)};" - "确认变更符合边界后运行 scripts/architecture/baseline.py --write", + f"架构基线已变化:{_display_path(path)};" + f"确认变更符合边界后运行 scripts/architecture/baseline.py {write_hint}", file=sys.stderr, ) return False -def parse_args() -> argparse.Namespace: - """解析基线写入、校验和外部插件仓参数。""" +def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + """解析限定宿主或插件范围的基线操作参数。""" parser = argparse.ArgumentParser(description=__doc__) action = parser.add_mutually_exclusive_group(required=True) - action.add_argument("--write", action="store_true", help="写入当前架构基线") - action.add_argument("--check", action="store_true", help="校验当前架构基线") + action.add_argument("--check-host", 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( "--plugin-repo", 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: - """执行本仓基线以及可选官方插件基线的写入或校验。""" - args = parse_args() - baselines = [ - (DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()), - (RUNTIME_BASELINE_PATH, collect_runtime_baseline()), - ] - if args.plugin_repo: +def main(argv: Optional[list[str]] = None) -> int: + """只对显式选择的宿主或插件基线执行检查或写入。""" + args = parse_args(argv) + host_action = args.check_host or args.write_host + if host_action: + baselines = [ + (DEPENDENCY_BASELINE_PATH, collect_dependency_baseline()), + (RUNTIME_BASELINE_PATH, collect_runtime_baseline()), + ] + write_hint = "--write-host" + else: plugin_repo = args.plugin_repo.resolve() if not plugin_repo.is_dir(): raise SystemExit(f"插件仓不存在:{plugin_repo}") - baselines.append( + baselines = [ (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: write_json(path, baseline) - print(f"已写入 {path.relative_to(PROJECT_ROOT)}") + print(f"已写入 {_display_path(path)}") 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 diff --git a/scripts/startup/performance.py b/scripts/startup/performance.py index 0c8791d56..7ea39aa63 100644 --- a/scripts/startup/performance.py +++ b/scripts/startup/performance.py @@ -10,7 +10,7 @@ import subprocess import sys from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, Optional PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -28,6 +28,8 @@ IMPORT_TARGETS = ( ) RESULT_PREFIX = "MOVIEPILOT_IMPORT_BASELINE=" LIFECYCLE_RESULT_PREFIX = "MOVIEPILOT_LIFECYCLE_BASELINE=" +PERFORMANCE_FACTOR = 2.0 +PERFORMANCE_SLACK_MS = 500.0 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__) + 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("--output", type=Path, default=DEFAULT_OUTPUT) - return parser.parse_args() + parser.set_defaults(operation="print") + return parser.parse_args(argv) -def main() -> int: - """执行冷导入采样并写入 JSON 基线。""" - args = parse_args() +def _display_path(path: Path) -> Path: + try: + 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: raise SystemExit("--repeat 必须大于等于 1") + baseline = collect_baseline(args.repeat) 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.write_text( - json.dumps(collect_baseline(args.repeat), ensure_ascii=False, indent=2) - + "\n", + json.dumps(baseline, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) - try: - display_path = output.relative_to(PROJECT_ROOT) - except ValueError: - display_path = output - print(f"已写入 {display_path}") + print(f"已写入:{_display_path(output)}") return 0 diff --git a/tests/test_architecture_baseline_cli.py b/tests/test_architecture_baseline_cli.py new file mode 100644 index 000000000..fe29e0e25 --- /dev/null +++ b/tests/test_architecture_baseline_cli.py @@ -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"] diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index 9915614d3..aa3970c9f 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -13,8 +13,16 @@ BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture" 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( - [sys.executable, "scripts/architecture/baseline.py", "--check"], + [sys.executable, "scripts/architecture/baseline.py", "--check-host"], cwd=PROJECT_ROOT, capture_output=True, text=True, @@ -22,6 +30,10 @@ def test_architecture_contract_baselines_match_current_source(): ) 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():