mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor: restore architecture governance gates
This commit is contained in:
@@ -753,34 +753,121 @@ def collect_event_diagnostics() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _sdk_all_names(tree: ast.Module, path: Path) -> tuple[str, ...]:
|
||||
"""读取 SDK 模块显式声明的 ``__all__``,未声明时不推断公开合同。"""
|
||||
value_node: Optional[ast.expr] = None
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id == "__all__"
|
||||
for target in node.targets
|
||||
):
|
||||
value_node = node.value
|
||||
elif (
|
||||
isinstance(node, ast.AnnAssign)
|
||||
and isinstance(node.target, ast.Name)
|
||||
and node.target.id == "__all__"
|
||||
):
|
||||
value_node = node.value
|
||||
if value_node is None:
|
||||
return ()
|
||||
try:
|
||||
names = ast.literal_eval(value_node)
|
||||
except (ValueError, TypeError) as err:
|
||||
raise ValueError(f"SDK 模块 {path} 的 __all__ 必须是字符串列表") from err
|
||||
if not isinstance(names, (list, tuple)) or not all(
|
||||
isinstance(name, str) for name in names
|
||||
):
|
||||
raise ValueError(f"SDK 模块 {path} 的 __all__ 必须是字符串列表")
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError(f"SDK 模块 {path} 的 __all__ 存在重复名称")
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def _sdk_alias_target(
|
||||
value: ast.expr,
|
||||
imported_targets: dict[str, str],
|
||||
) -> str:
|
||||
"""把顶层别名赋值解析为稳定目标,保留其真实 canonical 来源。"""
|
||||
if isinstance(value, ast.Name):
|
||||
return imported_targets.get(value.id, value.id)
|
||||
if isinstance(value, ast.Attribute):
|
||||
return f"{_sdk_alias_target(value.value, imported_targets)}.{value.attr}"
|
||||
return ast.unparse(value)
|
||||
|
||||
|
||||
def _collect_sdk_module_exports(path: Path) -> list[dict[str, str]]:
|
||||
"""按单个 SDK 模块的显式 ``__all__`` 生成可比较的符号合同。"""
|
||||
tree = parse_source(path)
|
||||
export_names = _sdk_all_names(tree, path)
|
||||
imported_targets: dict[str, str] = {}
|
||||
bindings: dict[str, dict[str, str]] = {}
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
module_name = f"{'.' * node.level}{node.module}"
|
||||
for alias in node.names:
|
||||
if alias.name == "*":
|
||||
continue
|
||||
public_name = alias.asname or alias.name
|
||||
target = f"{module_name}.{alias.name}"
|
||||
imported_targets[public_name] = target
|
||||
bindings[public_name] = {
|
||||
"name": public_name,
|
||||
"kind": "import",
|
||||
"target": target,
|
||||
}
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
public_name = alias.asname or alias.name.split(".", maxsplit=1)[0]
|
||||
imported_targets[public_name] = alias.name
|
||||
bindings[public_name] = {
|
||||
"name": public_name,
|
||||
"kind": "import",
|
||||
"target": alias.name,
|
||||
}
|
||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
bindings[node.name] = {
|
||||
"name": node.name,
|
||||
"kind": type(node).__name__,
|
||||
"target": "",
|
||||
}
|
||||
elif isinstance(node, ast.Assign):
|
||||
for target_node in node.targets:
|
||||
if not isinstance(target_node, ast.Name) or target_node.id == "__all__":
|
||||
continue
|
||||
bindings[target_node.id] = {
|
||||
"name": target_node.id,
|
||||
"kind": "alias",
|
||||
"target": _sdk_alias_target(node.value, imported_targets),
|
||||
}
|
||||
elif (
|
||||
isinstance(node, ast.AnnAssign)
|
||||
and isinstance(node.target, ast.Name)
|
||||
and node.target.id != "__all__"
|
||||
and node.value is not None
|
||||
):
|
||||
bindings[node.target.id] = {
|
||||
"name": node.target.id,
|
||||
"kind": "alias",
|
||||
"target": _sdk_alias_target(node.value, imported_targets),
|
||||
}
|
||||
|
||||
unresolved = sorted(set(export_names) - set(bindings))
|
||||
if unresolved:
|
||||
raise ValueError(
|
||||
f"SDK 模块 {path} 的 __all__ 含无法解析的顶层名称:{', '.join(unresolved)}"
|
||||
)
|
||||
return sorted(
|
||||
(bindings[name] for name in export_names),
|
||||
key=lambda item: (item["name"], item["kind"], item["target"]),
|
||||
)
|
||||
|
||||
|
||||
def collect_sdk_exports() -> dict[str, list[dict[str, str]]]:
|
||||
"""通过 AST 收集顶层 SDK 公开符号,避免导入时物化运行资源。"""
|
||||
"""按显式 ``__all__`` 收集 SDK 合同,避免导入时物化运行资源。"""
|
||||
result: dict[str, list[dict[str, str]]] = {}
|
||||
for path in sorted((APP_ROOT / "sdk").glob("*.py")):
|
||||
module_name = f"app.sdk.{path.stem}" if path.stem != "__init__" else "app.sdk"
|
||||
exports: list[dict[str, str]] = []
|
||||
for node in parse_source(path).body:
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
for alias in node.names:
|
||||
public_name = alias.asname or alias.name
|
||||
if public_name.startswith("_") or alias.name == "*":
|
||||
continue
|
||||
exports.append(
|
||||
{
|
||||
"name": public_name,
|
||||
"kind": "import",
|
||||
"target": f"{node.module}.{alias.name}",
|
||||
}
|
||||
)
|
||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
if not node.name.startswith("_"):
|
||||
exports.append(
|
||||
{"name": node.name, "kind": type(node).__name__, "target": ""}
|
||||
)
|
||||
result[module_name] = sorted(
|
||||
exports,
|
||||
key=lambda item: (item["name"], item["kind"], item["target"]),
|
||||
)
|
||||
result[module_name] = _collect_sdk_module_exports(path)
|
||||
return result
|
||||
|
||||
|
||||
@@ -982,14 +1069,50 @@ def collect_plugin_api_contracts(path: Path) -> list[dict[str, Any]]:
|
||||
return sorted(routes, key=lambda item: (item["path"], item["endpoint"]))
|
||||
|
||||
|
||||
def _read_plugin_index(path: Path) -> dict[str, Any]:
|
||||
"""读取插件索引;缺失索引按该代没有候选处理。"""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"插件索引必须是对象:{path}")
|
||||
return value
|
||||
|
||||
|
||||
def _v3_default_plugin_roots(plugin_repo: Path) -> dict[str, Path]:
|
||||
"""返回 V3/V2 专用索引均未接管时可回退的默认插件源码目录。"""
|
||||
default_index = _read_plugin_index(plugin_repo / "package.json")
|
||||
v2_index = _read_plugin_index(plugin_repo / "package.v2.json")
|
||||
v3_index = _read_plugin_index(plugin_repo / "package.v3.json")
|
||||
roots: dict[str, Path] = {}
|
||||
for plugin_id, plugin_info in default_index.items():
|
||||
if not isinstance(plugin_info, dict):
|
||||
continue
|
||||
if plugin_info.get("v2") is not True or plugin_info.get("v3") is False:
|
||||
continue
|
||||
if plugin_id in v3_index:
|
||||
continue
|
||||
v2_info = v2_index.get(plugin_id)
|
||||
if isinstance(v2_info, dict) and v2_info.get("v3") is not False:
|
||||
continue
|
||||
plugin_root = plugin_repo / "plugins" / plugin_id.lower()
|
||||
if not plugin_root.is_dir():
|
||||
raise FileNotFoundError(f"V3 默认兼容插件缺少源码目录:{plugin_root}")
|
||||
roots[plugin_id] = plugin_root
|
||||
return roots
|
||||
|
||||
|
||||
def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]:
|
||||
"""扫描独立官方插件仓的导入面、Hook 和动态 API 契约。"""
|
||||
roots = [plugin_repo / "plugins.v2", plugin_repo / "plugins.v3"]
|
||||
"""扫描独立官方插件仓中 V3 可见实现的导入面、Hook 和动态 API 契约。"""
|
||||
versioned_roots = [plugin_repo / "plugins.v2", plugin_repo / "plugins.v3"]
|
||||
default_roots = _v3_default_plugin_roots(plugin_repo)
|
||||
paths = sorted(
|
||||
path
|
||||
for root in roots
|
||||
if root.exists()
|
||||
for path in root.rglob("*.py")
|
||||
{
|
||||
path
|
||||
for root in [*versioned_roots, *default_roots.values()]
|
||||
if root.exists()
|
||||
for path in root.rglob("*.py")
|
||||
}
|
||||
)
|
||||
import_files: dict[str, set[str]] = defaultdict(set)
|
||||
hook_files: dict[str, set[str]] = defaultdict(set)
|
||||
@@ -1019,7 +1142,8 @@ def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]:
|
||||
"schema_version": 3,
|
||||
"scope": {
|
||||
"repository": "MoviePilot-Plugins",
|
||||
"roots": [root.name for root in roots],
|
||||
"roots": [*[root.name for root in versioned_roots], "plugins"],
|
||||
"default_plugins": sorted(default_roots),
|
||||
},
|
||||
"provenance": {
|
||||
"head": git_head(plugin_repo),
|
||||
|
||||
@@ -46,7 +46,10 @@ importlib.import_module({target!r})
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
print({RESULT_PREFIX!r} + json.dumps({{
|
||||
'elapsed_ms': elapsed_ms,
|
||||
'loaded_module_count': len(set(sys.modules) - before),
|
||||
'loaded_app_module_count': len([
|
||||
name for name in set(sys.modules) - before
|
||||
if name == 'app' or name.startswith('app.')
|
||||
]),
|
||||
}}))
|
||||
"""
|
||||
environment = os.environ.copy()
|
||||
@@ -98,17 +101,22 @@ from app.startup import lifecycle
|
||||
|
||||
|
||||
def _noop():
|
||||
'''替代无需执行的真实同步组件回调。'''
|
||||
return None
|
||||
|
||||
|
||||
async def _async_noop():
|
||||
'''替代无需执行的真实异步组件回调。'''
|
||||
return None
|
||||
|
||||
|
||||
def _isolated_start(component, probe_app):
|
||||
'''保留探针所需基础状态,其余组件启动替换为空操作。'''
|
||||
# 保留 readiness 所需状态转换,其余真实启动回调替换为空操作。
|
||||
if component.start is None:
|
||||
return None
|
||||
if component.name == '后台任务登记器':
|
||||
return component.start
|
||||
if component.name == '数据库准备':
|
||||
return lambda: lifecycle.get_application_health(
|
||||
probe_app
|
||||
@@ -116,7 +124,16 @@ def _isolated_start(component, probe_app):
|
||||
return _noop
|
||||
|
||||
|
||||
def _isolated_stop(component):
|
||||
'''真实释放探针基础设施,其余组件关闭替换为空操作。'''
|
||||
# TaskRegistry 是后续生命周期代码的基础设施,探针必须验证其真实释放路径。
|
||||
if component.name == '后台任务登记器':
|
||||
return component.stop
|
||||
return _noop if component.stop is not None else None
|
||||
|
||||
|
||||
async def _probe():
|
||||
'''执行一次隔离生命周期并输出资源与耗时样本。'''
|
||||
lifecycle.settings.MOVIEPILOT_SAFE_MODE = {safe_mode!r}
|
||||
lifecycle.init_extra = _async_noop
|
||||
lifecycle.global_vars.set_loop = lambda loop: None
|
||||
@@ -128,7 +145,7 @@ async def _probe():
|
||||
dataclasses.replace(
|
||||
component,
|
||||
start=_isolated_start(component, probe_app),
|
||||
stop=_noop if component.stop is not None else None,
|
||||
stop=_isolated_stop(component),
|
||||
)
|
||||
for component in original_components
|
||||
)
|
||||
@@ -137,6 +154,7 @@ async def _probe():
|
||||
original_step = lifecycle.run_startup_step
|
||||
|
||||
async def timed_step(name, callback, timeout_seconds=None):
|
||||
'''执行隔离启动步骤并记录耗时。'''
|
||||
started = time.perf_counter()
|
||||
result = await original_step(name, callback, timeout_seconds)
|
||||
stage_ms[name] = round((time.perf_counter() - started) * 1000, 3)
|
||||
@@ -151,12 +169,14 @@ async def _probe():
|
||||
started_threads = threading.active_count()
|
||||
started_tasks = len(asyncio.all_tasks())
|
||||
finished_ms = (time.perf_counter() - started) * 1000
|
||||
enabled_components = [
|
||||
component.name for component in isolated_components
|
||||
if component.enabled({safe_mode!r})
|
||||
]
|
||||
print({LIFECYCLE_RESULT_PREFIX!r} + json.dumps({{
|
||||
'mode': 'safe' if {safe_mode!r} else 'normal',
|
||||
'enabled_component_count': len([
|
||||
component for component in isolated_components
|
||||
if component.enabled({safe_mode!r})
|
||||
]),
|
||||
'enabled_components': enabled_components,
|
||||
'enabled_component_count': len(enabled_components),
|
||||
'startup_ms': round(startup_ms, 3),
|
||||
'full_lifespan_ms': round(finished_ms, 3),
|
||||
'stage_ms': stage_ms,
|
||||
@@ -206,9 +226,9 @@ def collect_baseline(repeat: int) -> dict[str, Any]:
|
||||
samples = [measure_import(target) for _ in range(repeat)]
|
||||
elapsed = [sample["elapsed_ms"] for sample in samples]
|
||||
targets[target] = {
|
||||
"loaded_module_count": int(
|
||||
"loaded_app_module_count": int(
|
||||
statistics.median(
|
||||
sample["loaded_module_count"] for sample in samples
|
||||
sample["loaded_app_module_count"] for sample in samples
|
||||
)
|
||||
),
|
||||
"max_ms": round(max(elapsed), 3),
|
||||
@@ -219,8 +239,21 @@ def collect_baseline(repeat: int) -> dict[str, Any]:
|
||||
lifecycle_modes: dict[str, Any] = {}
|
||||
for safe_mode, mode_name in ((False, "normal"), (True, "safe")):
|
||||
samples = [measure_lifecycle(safe_mode) for _ in range(repeat)]
|
||||
enabled_components = samples[0]["enabled_components"]
|
||||
if any(
|
||||
sample["enabled_components"] != enabled_components
|
||||
for sample in samples[1:]
|
||||
):
|
||||
raise RuntimeError(f"{mode_name} 模式生命周期组件在重复采样间发生变化")
|
||||
lifecycle_modes[mode_name] = {
|
||||
"samples": samples,
|
||||
"samples": [
|
||||
{
|
||||
key: value
|
||||
for key, value in sample.items()
|
||||
if key != "enabled_components"
|
||||
}
|
||||
for sample in samples
|
||||
],
|
||||
"median_startup_ms": round(
|
||||
statistics.median(sample["startup_ms"] for sample in samples),
|
||||
3,
|
||||
@@ -230,9 +263,10 @@ def collect_baseline(repeat: int) -> dict[str, Any]:
|
||||
3,
|
||||
),
|
||||
"enabled_component_count": samples[0]["enabled_component_count"],
|
||||
"enabled_components": enabled_components,
|
||||
}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"platform": platform.platform(),
|
||||
"python": platform.python_version(),
|
||||
@@ -251,6 +285,11 @@ def check_baseline(
|
||||
) -> list[str]:
|
||||
"""比较稳定资源契约与宽松耗时预算,返回所有不符合项。"""
|
||||
errors: list[str] = []
|
||||
if expected.get("schema_version") != actual.get("schema_version"):
|
||||
errors.append(
|
||||
"启动性能基线 schema 版本变化:"
|
||||
f"{expected.get('schema_version')} -> {actual.get('schema_version')}"
|
||||
)
|
||||
expected_targets = expected.get("targets", {})
|
||||
actual_targets = actual.get("targets", {})
|
||||
if set(expected_targets) != set(actual_targets):
|
||||
@@ -258,14 +297,13 @@ def check_baseline(
|
||||
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"]
|
||||
if actual_target.get("loaded_app_module_count") != expected_target.get(
|
||||
"loaded_app_module_count"
|
||||
):
|
||||
errors.append(
|
||||
f"{target} 加载模块数变化:"
|
||||
f"{expected_target['loaded_module_count']} -> "
|
||||
f"{actual_target['loaded_module_count']}"
|
||||
f"{target} 加载宿主模块数变化:"
|
||||
f"{expected_target.get('loaded_app_module_count')} -> "
|
||||
f"{actual_target.get('loaded_app_module_count')}"
|
||||
)
|
||||
budget_ms = max(
|
||||
expected_target["max_ms"] * PERFORMANCE_FACTOR,
|
||||
@@ -283,6 +321,10 @@ def check_baseline(
|
||||
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.get("enabled_components") != expected_mode.get(
|
||||
"enabled_components"
|
||||
):
|
||||
errors.append(f"{mode_name} 模式生命周期组件集合或顺序已变化")
|
||||
if (
|
||||
actual_mode["enabled_component_count"]
|
||||
!= expected_mode["enabled_component_count"]
|
||||
|
||||
Reference in New Issue
Block a user