mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 06:56:43 +08:00
refactor: restore architecture governance gates
This commit is contained in:
@@ -59,6 +59,11 @@ jobs:
|
||||
- name: Check async blocking ratchet
|
||||
run: uv run --locked --no-sync python scripts/architecture/async_blocking.py
|
||||
|
||||
- name: Check startup performance contract
|
||||
run: >-
|
||||
uv run --locked --no-sync python
|
||||
scripts/startup/performance.py --check --repeat 3
|
||||
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit Tests (${{ matrix.shard }})
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
from app.application.backup import BackupArtifact, BackupVerification
|
||||
from app.application.database import get_database_governance as _get_database_governance
|
||||
|
||||
__all__ = [
|
||||
"BackupArtifact",
|
||||
"BackupVerification",
|
||||
"create_backup",
|
||||
"list_backups",
|
||||
"verify_backup",
|
||||
]
|
||||
|
||||
|
||||
def create_backup() -> BackupArtifact:
|
||||
"""在宿主管理目录中创建当前数据库的一致备份。"""
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
3. `PluginManager` 的加载、生命周期、注册表、投影、存储、目录、路径、同步、依赖、克隆和文件监控分别由 `app/runtime/extensions/plugin/` 下的单职责组件承担;旧管理器只保留 V3 ABI 门面和兼容调用顺序。
|
||||
4. 动态插件 API 使用专用 raw 路由;主程序统一响应信封不进入插件 `get_api()`。前端 `pluginApi` 对非 `Response` envelope 的 payload 原样交付调用方。
|
||||
5. 旧插件导入仅由 `app/runtime/compat/manifest.py` 精确映射;canonical 模块不复制旧 Manager/Helper/Oper 导出。`app/plugins/` 仍是运行时副本,继续排除在宿主架构扫描之外。
|
||||
6. 当前机器基线为 746 个宿主 Python 模块、6,024 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
|
||||
6. 2026-08-23 当前机器基线为 800 个宿主 Python 模块、6,479 条内部导入边;数据库边界、Adapter→DB、Runtime→DB、Application→DB 及新增 API/Agent/Chain 目标边均为 0。架构门禁、插件兼容快照和基线脚本均已重新生成。
|
||||
7. 订阅写入统一归入 `app/application/subscription/write.py`;插件动态路由和文件夹操作统一归入 `app/application/plugin/routes.py`、`folders.py`。重构期间新增且未形成插件 ABI 的 `app/application/subscribe.py`、`app/application/plugins.py` 已直接删除,不进入 compat manifest。
|
||||
|
||||
## 2. 范围与明确排除项
|
||||
@@ -45,7 +45,7 @@
|
||||
### 2.2 排除项
|
||||
|
||||
- **不审计、不迁移 `app/plugins/` 中的代码。**该目录是已安装插件副本,不是后端架构源代码,也不能作为插件兼容性的唯一事实来源。
|
||||
- 插件兼容基线应读取同工作区独立仓库 `../MoviePilot-Plugins` 的 `plugins.v2/`、`plugins.v3/`,再配合宿主的 SDK、兼容清单和插件管理器契约判断。
|
||||
- 插件兼容基线应读取同工作区独立仓库 `../MoviePilot-Plugins` 的 `plugins.v3/`、`plugins.v2/` 和 V3 实际会从默认索引回退加载的 `plugins/` 实现,再配合宿主的 SDK、兼容清单和插件管理器契约判断。
|
||||
- 不把 `app/modules/themoviedb/` 内部第三方或移植代码的局部循环,直接等同于 MoviePilot 自有架构失败。它需要被隔离,但不应优先重写上游库。
|
||||
- 本轮不主张数据库表结构变更。纯架构批次不得夹带 Alembic 迁移、字段重命名或数据回填。
|
||||
- 本轮不主张删除 V3 兼容映射。任何删除都应作为显式破坏性变更另行决策。
|
||||
@@ -99,7 +99,7 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
|
||||
|
||||
### 4.3 模块规模
|
||||
|
||||
排除 `app/plugins/` 后,当前静态扫描得到 746 个 Python 模块、6,024 条内部导入边。主要一级目录规模如下(代码行数包含注释和空行,用于趋势比较而非质量评分):
|
||||
排除 `app/plugins/` 后,2026-08-23 当前静态扫描得到 800 个 Python 模块、6,479 条内部导入边。下表保留 2026-08-18 收口时的一级目录规模快照(代码行数包含注释和空行,用于趋势比较而非质量评分):
|
||||
|
||||
| 一级目录 | 约代码行数 | Python 文件数 | 判断 |
|
||||
| --- | ---: | ---: | --- |
|
||||
@@ -147,8 +147,8 @@ MoviePilot V3 已经完成一轮重要基础工作:原 `app/core`、`app/helpe
|
||||
|
||||
| 指标 | 初始审计 | 当前基线 | 说明 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Python 模块数 | 约 654 | 746 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
|
||||
| 内部导入边 | 约 5,623 | 6,024 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
|
||||
| Python 模块数 | 约 654 | 800 | 增量来自单一职责的 Application、Runtime、Adapter、插件组件和维护用例模块 |
|
||||
| 内部导入边 | 约 5,623 | 6,479 | 显式端口增加模块数但移除了反向边;边数不作为单独质量目标 |
|
||||
| SCC 数 | 14 | 1 | 自有代码 SCC 已归零,仅保留 TMDB 移植包内部隔离例外 |
|
||||
| `adapters -> db` | 存在 | 0 | `PluginHelper`、`MoviePilotServerHelper` 的本地数据读取已移到组合根/Application |
|
||||
| `runtime -> db` | 存在 | 0 | 插件存储、服务配置均改为启动注入 |
|
||||
@@ -159,7 +159,7 @@ Doctor/Monitor 改为惰性公开门面;QQBot、Telegram、TriMedia、UGreen
|
||||
|
||||
- `tests/fixtures/architecture/dependency-baseline.json`:模块、边、SCC 和目标边。
|
||||
- `tests/fixtures/architecture/runtime-contract-baseline.json`:SDK、兼容清单、事件和 `run_module` 合同。
|
||||
- `tests/fixtures/architecture/official-plugin-baseline.json`:独立官方插件仓 V2/V3 导入及钩子快照。
|
||||
- `tests/fixtures/architecture/official-plugin-baseline.json`:独立官方插件仓中 V3 实际可加载的 V3/V2/default 实现导入及钩子快照。
|
||||
- `app/schemas/exports.py`:Schema 根入口的生成式兼容导出清单。
|
||||
|
||||
## 5. 目标架构与依赖方向
|
||||
@@ -788,9 +788,9 @@ app/agent/execution/ # 执行、流事件、用量、恢复
|
||||
|
||||
`app/runtime/compat/manifest.py` 当前约包含:
|
||||
|
||||
- 112 个模块别名。
|
||||
- 113 个模块别名。
|
||||
- 1 个包别名。
|
||||
- 8 个模块、约 41 个符号别名。
|
||||
- 10 个模块、55 个符号别名。
|
||||
- 3 个虚拟包。
|
||||
|
||||
独立插件仓中仍高频使用:
|
||||
@@ -828,8 +828,8 @@ app/agent/execution/ # 执行、流事件、用量、恢复
|
||||
|
||||
- SDK 公开面有机器可读清单和变更审查。
|
||||
- 每次迁移明确列出旧路径、新路径、身份要求和保留期限。
|
||||
- 独立插件仓 v2/v3 静态导入扫描通过。
|
||||
- V3 治理批次不删除现有 112/41 兼容项。
|
||||
- 独立插件仓中 V3 实际可加载实现的静态导入扫描通过。
|
||||
- V3 治理批次不删除现有 113 个模块别名、1 个包别名和 55 个符号别名。
|
||||
|
||||
### 6.15 配置、缓存和错误策略分散
|
||||
|
||||
@@ -1071,7 +1071,7 @@ startup 注入具体依赖
|
||||
|
||||
| 阶段 | 已落地入口 | 已锁定的关键语义 |
|
||||
| --- | --- | --- |
|
||||
| 0 | `scripts/architecture/baseline.py`、`scripts/schema/exports.py`、三份 architecture fixture | 模块/边/SCC、SDK/compat、事件、`run_module`、官方插件 V2/V3 导入和钩子快照 |
|
||||
| 0 | `scripts/architecture/baseline.py`、`scripts/schema/exports.py`、architecture fixtures | 模块/边/SCC、显式 `__all__` SDK/compat、事件、`run_module`、官方插件 V3/V2/default 有效实现的导入和钩子快照 |
|
||||
| 0 | `app/adapters/web/plugin/routes.py`、`app/application/plugin/routes.py` | 主程序继续统一 envelope;动态插件 API 默认 raw,自定义状态码、原生 Response、文件/流响应不被改写 |
|
||||
| 0 | `MoviePilot-Frontend/src/api/client.ts` | 联邦插件公共客户端遇到非 `Response` payload 时原样返回;合法 envelope 仍保留统一错误反馈 |
|
||||
| 1 | `app/schemas/exports.py`、`app/schemas/__init__.py` | Schema 根入口惰性兼容导出,宿主内部使用精确子模块,公开符号由生成清单锁定 |
|
||||
|
||||
@@ -6,20 +6,30 @@
|
||||
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
||||
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
||||
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
|
||||
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配、Outbox 外围扩展和 Model 查询兼容面仍按风险切片推进。
|
||||
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配、Outbox 外围扩展和 Model 查询兼容面仍按风险切片推进。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线。
|
||||
|
||||
## 当前复核结论(2026-08-23)
|
||||
|
||||
本节是本轮全面复核后的当前事实源。本文后续的阶段实施记录保留历史审计证据,
|
||||
其中的数量和判断以当时审计提交为准,不能直接当作当前未完成项。
|
||||
|
||||
### 长期整改阶段 0:治理门禁恢复(2026-08-23)
|
||||
|
||||
- 宿主依赖基线已审查 TaskRegistry 接入后的语义差异:当前为 `800` 个模块、`6479` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
|
||||
- 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistry;normal/safe 组件数分别为 `16`/`8`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。
|
||||
- 官方插件快照覆盖 `plugins.v3`、`plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。
|
||||
- SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing`、`__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。
|
||||
- async 阻塞实际债务已由 fixture 中的 10 项下降到 1 项并固化低水位;剩余项是 Scheduler Agent task 查询,后续阶段迁入异步查询边界后归零。
|
||||
|
||||
本阶段只修复治理信号和事实源,不把基线刷新当作业务重构完成。后台任务所有权、Module Contract V2、typed runtime、durable 副作用和质量规模化仍按下列 P1/P2 顺序推进。
|
||||
|
||||
### 总体判断
|
||||
|
||||
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
|
||||
|
||||
- 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。
|
||||
- `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。
|
||||
- 依赖图当前约 `796` 个 Python 模块、`6432` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
|
||||
- 依赖图当前为 `800` 个 Python 模块、`6479` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
|
||||
- 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。
|
||||
|
||||
综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。
|
||||
@@ -33,7 +43,7 @@
|
||||
|
||||
### P2:中长期可演进性债务
|
||||
|
||||
- **大型职责域仍偏重。** 代表性热点包括 `app/chain/subscribe.py`(约 `4141` 行)、`app/chain/transfer.py`(约 `2944` 行)、`app/agent/orchestrator.py`(约 `3535` 行)、`app/agent/llm/provider.py`(约 `3529` 行)、`app/adapters/external/market.py`(约 `2805` 行)和 `app/api/endpoints/agent.py`(约 `2326` 行)。复杂度 ratchet 只保证不超过当前基线,不代表这些文件已经易维护。只有在行为快照、调用命中和事务边界明确后,才值得按用例拆分。
|
||||
- **大型职责域仍偏重。** 代表性热点包括 `app/chain/subscribe.py`(约 `4141` 行)、`app/chain/transfer.py`(约 `2944` 行)、`app/agent/orchestrator.py`(约 `3540` 行)、`app/agent/llm/provider.py`(约 `3529` 行)、`app/adapters/external/market.py`(约 `3139` 行)和 `app/api/endpoints/agent.py`(约 `2489` 行)。复杂度 ratchet 只保证不超过当前基线,不代表这些文件已经易维护。只有在行为快照、调用命中和事务边界明确后,才值得按用例拆分。
|
||||
- **类型门禁覆盖面不足。** `mypy.ini` strict 文件清单目前约 `37` 个文件,Agent、Chain、Module、Adapter 大量代码仍依赖动态类型。应从模块契约、生命周期、Repository/Port 和关键 Chain 返回值开始扩展,而不是直接开启全仓 strict。
|
||||
- **Pylint 仍是增量硬门禁。** `.github/workflows/pylint.yml` 对改动 Python 文件执行硬检查,但全仓报告使用 `|| true` 仅作 advisory。该策略适合存量迁移,却没有形成全仓质量趋势约束;应增加按目录和新增问题数的 ratchet。
|
||||
- **测试风格存在历史混用。** 当前约 `499` 个测试文件,仍有约 `70` 个 `unittest.TestCase` 文件。它不是生产架构缺陷,但会增加 fixture、状态隔离和异步测试迁移成本,应在触碰相关模块时渐进迁移。
|
||||
@@ -45,7 +55,7 @@
|
||||
- 全功能多 worker 的误导性配置已由 `app/runtime/topology.py` 和 `app/main.py` 拒绝;V3 默认单 worker 的部署事实已经明确。
|
||||
- 写事务已由组合根/UoW/Outbox 方向收口,`db_update/async_db_update` 为零;不要重新引入 Model 自动提交。
|
||||
- 已具备 correlation ID、`/health/live`、`/health/ready`、模块/事件/调度观测端口和兼容 Facade 命中指标;历史文档中“完全缺少观测能力”的描述已过时。
|
||||
- 旧导入路径、SDK 导出、插件 manifest 和 V1/V2/V3 索引均有白名单或版本约束;兼容层应继续保持“薄、可观测、只增不删”,不应为了清理目录直接删除。
|
||||
- 旧导入路径、显式 `__all__` SDK 合同、插件 manifest 和 V3 实际可加载的三层索引实现均有白名单或版本约束;兼容层应继续保持“薄、可观测、只增不删”,不应为了清理目录直接删除。
|
||||
- TMDB 移植包内部 SCC 属于第三方隔离代码,按现状豁免是合理的技术决策。
|
||||
|
||||
### 刻意保留的兼容成本
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
{
|
||||
"app/agent/tools/impl/_plugin_tool_utils.py:uninstall_plugin_runtime:shutil.rmtree": 1,
|
||||
"app/agent/tools/impl/scrape_metadata.py:ScrapeMetadataTool.run:Path.exists": 1,
|
||||
"app/agent/tools/impl/scrape_metadata.py:ScrapeMetadataTool.run:Path.is_dir": 1,
|
||||
"app/chain/media.py:MediaChain._async_music_album_dir_fallback:Path.exists": 1,
|
||||
"app/chain/media.py:MediaChain._async_music_album_dir_fallback:Path.is_file": 1,
|
||||
"app/chain/media.py:MediaChain.async_recognize_music_album_directory:Path.is_dir": 1,
|
||||
"app/modules/acoustid/__init__.py:AcoustIdModule.async_identify_music_by_fingerprint:Path.is_file": 1,
|
||||
"app/modules/discord/discord.py:Discord._send_file:Path.exists": 1,
|
||||
"app/modules/discord/discord.py:Discord._send_file:Path.is_file": 1,
|
||||
"app/scheduler.py:Scheduler.execute_agent_task:AgentTaskOper.get": 1
|
||||
}
|
||||
|
||||
+560
-79
File diff suppressed because it is too large
Load Diff
+10
-275
@@ -7443,16 +7443,6 @@
|
||||
"sdk_exports": {
|
||||
"app.sdk": [],
|
||||
"app.sdk.browser": [
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "Any",
|
||||
"target": "typing.Any"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "annotations",
|
||||
"target": "__future__.annotations"
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "launch_browser_context",
|
||||
@@ -8040,275 +8030,10 @@
|
||||
}
|
||||
],
|
||||
"app.sdk.string": [
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "Callable",
|
||||
"target": "typing.Callable"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "DomUtils",
|
||||
"target": "app.foundation.dom.DomUtils"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "HashUtils",
|
||||
"target": "app.foundation.crypto.HashUtils"
|
||||
},
|
||||
{
|
||||
"kind": "ClassDef",
|
||||
"name": "StringUtils",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "base_url",
|
||||
"target": "app.foundation.url.base_url"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "common_prefix",
|
||||
"target": "app.foundation.text.common_prefix"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "compact_numbers",
|
||||
"target": "app.domain.episode.compact_numbers"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "compare_version",
|
||||
"target": "app.foundation.version.compare_version"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "contains_chinese",
|
||||
"target": "app.foundation.text.contains_chinese"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "contains_japanese",
|
||||
"target": "app.foundation.text.contains_japanese"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "contains_korean",
|
||||
"target": "app.foundation.text.contains_korean"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "cookiejar_to_string",
|
||||
"target": "app.foundation.text.cookiejar_to_string"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "count_words",
|
||||
"target": "app.foundation.text.count_words"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "escape_markdown",
|
||||
"target": "app.foundation.text.escape_markdown"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "extract_domain",
|
||||
"target": "app.domain.site.extract_domain"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "extract_named_ids",
|
||||
"target": "app.foundation.text.extract_named_ids"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_amount",
|
||||
"target": "app.foundation.text.format_amount"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_approx_duration",
|
||||
"target": "app.foundation.temporal.format_approx_duration"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_compact_size",
|
||||
"target": "app.foundation.size.format_compact_size"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_duration",
|
||||
"target": "app.foundation.temporal.format_duration"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_minutes",
|
||||
"target": "app.foundation.temporal.format_minutes"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_ranges",
|
||||
"target": "app.domain.episode.format_ranges"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_remaining",
|
||||
"target": "app.foundation.temporal.format_remaining"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_size",
|
||||
"target": "app.foundation.size.format_size"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "format_timestamp",
|
||||
"target": "app.foundation.temporal.format_timestamp"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "host_label",
|
||||
"target": "app.foundation.url.host_label"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "is_all_chinese",
|
||||
"target": "app.foundation.text.is_all_chinese"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "is_english_word",
|
||||
"target": "app.foundation.text.is_english_word"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "is_link",
|
||||
"target": "app.foundation.url.is_link"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "is_magnet_link",
|
||||
"target": "app.domain.torrent.is_magnet_link"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "is_media_title_like",
|
||||
"target": "app.domain.title.is_media_title_like"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "is_number",
|
||||
"target": "app.foundation.text.is_number"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "natural_sort_key",
|
||||
"target": "app.foundation.text.natural_sort_key"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "normalize_datetime",
|
||||
"target": "app.foundation.temporal.normalize_datetime"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "normalize_upper",
|
||||
"target": "app.foundation.text.normalize_upper"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_address",
|
||||
"target": "app.foundation.url.parse_address"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_bool",
|
||||
"target": "app.foundation.text.parse_bool"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_datetime",
|
||||
"target": "app.foundation.temporal.parse_datetime"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_float",
|
||||
"target": "app.foundation.text.parse_float"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_int",
|
||||
"target": "app.foundation.text.parse_int"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_search_keyword",
|
||||
"target": "app.domain.title.parse_search_keyword"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_size",
|
||||
"target": "app.foundation.size.parse_size"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "parse_timestamp",
|
||||
"target": "app.foundation.temporal.parse_timestamp"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "random_string",
|
||||
"target": "app.foundation.text.random_string"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "remove_punctuation",
|
||||
"target": "app.foundation.text.remove_punctuation"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "sanitize_filename",
|
||||
"target": "app.foundation.text.sanitize_filename"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "second_level_label",
|
||||
"target": "app.foundation.url.second_level_label"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "signature",
|
||||
"target": "inspect.signature"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "split_by_bytes",
|
||||
"target": "app.foundation.text.split_by_bytes"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "split_netloc",
|
||||
"target": "app.foundation.url.split_netloc"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "strip_optional",
|
||||
"target": "app.foundation.text.strip_optional"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "title_case",
|
||||
"target": "app.foundation.text.title_case"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "urls_match",
|
||||
"target": "app.domain.site.urls_match"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "wraps",
|
||||
"target": "functools.wraps"
|
||||
}
|
||||
],
|
||||
"app.sdk.utilities": [
|
||||
@@ -8362,6 +8087,16 @@
|
||||
"name": "cut",
|
||||
"target": "app.foundation.text.cut"
|
||||
},
|
||||
{
|
||||
"kind": "alias",
|
||||
"name": "decrypt",
|
||||
"target": "app.foundation.crypto.CryptoJsUtils.decrypt"
|
||||
},
|
||||
{
|
||||
"kind": "alias",
|
||||
"name": "encrypt",
|
||||
"target": "app.foundation.crypto.CryptoJsUtils.encrypt"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "log_execution_time",
|
||||
|
||||
+164
-130
@@ -1,41 +1,41 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-08-21T12:02:30.246931+00:00",
|
||||
"platform": "macOS-26.5.2-arm64-arm-64bit",
|
||||
"python": "3.12.6",
|
||||
"schema_version": 2,
|
||||
"generated_at": "2026-08-23T08:11:40.406030+00:00",
|
||||
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
|
||||
"python": "3.14.3",
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_module_count": 1810,
|
||||
"max_ms": 987.312,
|
||||
"median_ms": 984.741,
|
||||
"min_ms": 983.917,
|
||||
"loaded_app_module_count": 362,
|
||||
"max_ms": 944.347,
|
||||
"median_ms": 935.035,
|
||||
"min_ms": 933.625,
|
||||
"samples_ms": [
|
||||
987.312,
|
||||
984.741,
|
||||
983.917
|
||||
944.347,
|
||||
935.035,
|
||||
933.625
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_module_count": 1820,
|
||||
"max_ms": 1015.426,
|
||||
"median_ms": 999.369,
|
||||
"min_ms": 996.521,
|
||||
"loaded_app_module_count": 374,
|
||||
"max_ms": 950.655,
|
||||
"median_ms": 950.429,
|
||||
"min_ms": 947.964,
|
||||
"samples_ms": [
|
||||
996.521,
|
||||
999.369,
|
||||
1015.426
|
||||
950.655,
|
||||
947.964,
|
||||
950.429
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_module_count": 1844,
|
||||
"max_ms": 1057.647,
|
||||
"median_ms": 1037.08,
|
||||
"min_ms": 1034.011,
|
||||
"loaded_app_module_count": 376,
|
||||
"max_ms": 1150.269,
|
||||
"median_ms": 1103.976,
|
||||
"min_ms": 1097.978,
|
||||
"samples_ms": [
|
||||
1057.647,
|
||||
1034.011,
|
||||
1037.08
|
||||
1103.976,
|
||||
1097.978,
|
||||
1150.269
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -46,55 +46,26 @@
|
||||
"samples": [
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 15,
|
||||
"startup_ms": 0.569,
|
||||
"full_lifespan_ms": 0.669,
|
||||
"enabled_component_count": 16,
|
||||
"startup_ms": 0.721,
|
||||
"full_lifespan_ms": 0.907,
|
||||
"stage_ms": {
|
||||
"数据库准备": 0.08,
|
||||
"HTTP 基础能力": 0.035,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.029,
|
||||
"数据库连接预算": 0.025,
|
||||
"路由": 0.024,
|
||||
"后台任务登记器": 0.084,
|
||||
"数据库准备": 0.038,
|
||||
"HTTP 基础能力": 0.028,
|
||||
"领域依赖装配": 0.027,
|
||||
"数据库引擎预热": 0.024,
|
||||
"数据库连接预算": 0.024,
|
||||
"路由": 0.023,
|
||||
"模块服务": 0.024,
|
||||
"插件备份恢复": 0.023,
|
||||
"插件": 0.021,
|
||||
"定时器": 0.019,
|
||||
"监控器": 0.022,
|
||||
"待处理整理回放": 0.021,
|
||||
"命令服务": 0.019,
|
||||
"工作流": 0.021,
|
||||
"插件同步与启动收尾": 0.032
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 15,
|
||||
"startup_ms": 0.548,
|
||||
"full_lifespan_ms": 0.641,
|
||||
"stage_ms": {
|
||||
"数据库准备": 0.071,
|
||||
"HTTP 基础能力": 0.033,
|
||||
"领域依赖装配": 0.032,
|
||||
"数据库引擎预热": 0.028,
|
||||
"数据库连接预算": 0.025,
|
||||
"路由": 0.022,
|
||||
"模块服务": 0.023,
|
||||
"插件备份恢复": 0.019,
|
||||
"插件": 0.02,
|
||||
"定时器": 0.022,
|
||||
"插件": 0.029,
|
||||
"定时器": 0.023,
|
||||
"监控器": 0.024,
|
||||
"待处理整理回放": 0.019,
|
||||
"命令服务": 0.022,
|
||||
"工作流": 0.022,
|
||||
"插件同步与启动收尾": 0.028
|
||||
"待处理整理回放": 0.021,
|
||||
"命令服务": 0.025,
|
||||
"工作流": 0.02,
|
||||
"插件同步与启动收尾": 0.031
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -106,25 +77,57 @@
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 15,
|
||||
"startup_ms": 0.687,
|
||||
"full_lifespan_ms": 0.795,
|
||||
"enabled_component_count": 16,
|
||||
"startup_ms": 0.614,
|
||||
"full_lifespan_ms": 0.788,
|
||||
"stage_ms": {
|
||||
"数据库准备": 0.083,
|
||||
"HTTP 基础能力": 0.035,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.033,
|
||||
"数据库连接预算": 0.027,
|
||||
"路由": 0.021,
|
||||
"模块服务": 0.019,
|
||||
"插件备份恢复": 0.064,
|
||||
"插件": 0.032,
|
||||
"后台任务登记器": 0.075,
|
||||
"数据库准备": 0.037,
|
||||
"HTTP 基础能力": 0.029,
|
||||
"领域依赖装配": 0.027,
|
||||
"数据库引擎预热": 0.023,
|
||||
"数据库连接预算": 0.024,
|
||||
"路由": 0.023,
|
||||
"模块服务": 0.023,
|
||||
"插件备份恢复": 0.023,
|
||||
"插件": 0.024,
|
||||
"定时器": 0.025,
|
||||
"监控器": 0.028,
|
||||
"待处理整理回放": 0.025,
|
||||
"监控器": 0.023,
|
||||
"待处理整理回放": 0.022,
|
||||
"命令服务": 0.021,
|
||||
"工作流": 0.022,
|
||||
"插件同步与启动收尾": 0.03
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 16,
|
||||
"startup_ms": 0.611,
|
||||
"full_lifespan_ms": 0.785,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.072,
|
||||
"数据库准备": 0.033,
|
||||
"HTTP 基础能力": 0.026,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.023,
|
||||
"数据库连接预算": 0.022,
|
||||
"路由": 0.024,
|
||||
"模块服务": 0.02,
|
||||
"插件备份恢复": 0.023,
|
||||
"插件": 0.024,
|
||||
"定时器": 0.026,
|
||||
"监控器": 0.024,
|
||||
"待处理整理回放": 0.023,
|
||||
"命令服务": 0.024,
|
||||
"工作流": 0.024,
|
||||
"插件同步与启动收尾": 0.037
|
||||
"工作流": 0.022,
|
||||
"插件同步与启动收尾": 0.031
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -135,20 +138,63 @@
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.569,
|
||||
"median_full_lifespan_ms": 0.669,
|
||||
"enabled_component_count": 15
|
||||
"median_startup_ms": 0.614,
|
||||
"median_full_lifespan_ms": 0.788,
|
||||
"enabled_component_count": 16,
|
||||
"enabled_components": [
|
||||
"后台任务登记器",
|
||||
"数据库准备",
|
||||
"HTTP 基础能力",
|
||||
"领域依赖装配",
|
||||
"数据库引擎预热",
|
||||
"数据库连接预算",
|
||||
"路由",
|
||||
"模块服务",
|
||||
"插件备份恢复",
|
||||
"插件",
|
||||
"定时器",
|
||||
"监控器",
|
||||
"待处理整理回放",
|
||||
"命令服务",
|
||||
"工作流",
|
||||
"插件备份"
|
||||
]
|
||||
},
|
||||
"safe": {
|
||||
"samples": [
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 7,
|
||||
"startup_ms": 0.408,
|
||||
"full_lifespan_ms": 0.531,
|
||||
"enabled_component_count": 8,
|
||||
"startup_ms": 0.463,
|
||||
"full_lifespan_ms": 0.667,
|
||||
"stage_ms": {
|
||||
"数据库准备": 0.077,
|
||||
"HTTP 基础能力": 0.035,
|
||||
"后台任务登记器": 0.075,
|
||||
"数据库准备": 0.035,
|
||||
"HTTP 基础能力": 0.03,
|
||||
"领域依赖装配": 0.032,
|
||||
"数据库引擎预热": 0.024,
|
||||
"数据库连接预算": 0.022,
|
||||
"路由": 0.025,
|
||||
"模块服务": 0.022,
|
||||
"插件同步与启动收尾": 0.058
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 8,
|
||||
"startup_ms": 0.474,
|
||||
"full_lifespan_ms": 0.68,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.076,
|
||||
"数据库准备": 0.043,
|
||||
"HTTP 基础能力": 0.029,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.026,
|
||||
"数据库连接预算": 0.024,
|
||||
@@ -166,41 +212,19 @@
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 7,
|
||||
"startup_ms": 0.416,
|
||||
"full_lifespan_ms": 0.534,
|
||||
"enabled_component_count": 8,
|
||||
"startup_ms": 0.454,
|
||||
"full_lifespan_ms": 0.657,
|
||||
"stage_ms": {
|
||||
"数据库准备": 0.079,
|
||||
"HTTP 基础能力": 0.036,
|
||||
"领域依赖装配": 0.029,
|
||||
"数据库引擎预热": 0.027,
|
||||
"数据库连接预算": 0.026,
|
||||
"路由": 0.026,
|
||||
"模块服务": 0.021,
|
||||
"插件同步与启动收尾": 0.054
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 7,
|
||||
"startup_ms": 0.397,
|
||||
"full_lifespan_ms": 0.52,
|
||||
"stage_ms": {
|
||||
"数据库准备": 0.07,
|
||||
"HTTP 基础能力": 0.033,
|
||||
"领域依赖装配": 0.026,
|
||||
"数据库引擎预热": 0.027,
|
||||
"数据库连接预算": 0.029,
|
||||
"后台任务登记器": 0.075,
|
||||
"数据库准备": 0.036,
|
||||
"HTTP 基础能力": 0.028,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.025,
|
||||
"数据库连接预算": 0.024,
|
||||
"路由": 0.024,
|
||||
"模块服务": 0.022,
|
||||
"插件同步与启动收尾": 0.051
|
||||
"模块服务": 0.024,
|
||||
"插件同步与启动收尾": 0.06
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -211,9 +235,19 @@
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.408,
|
||||
"median_full_lifespan_ms": 0.531,
|
||||
"enabled_component_count": 7
|
||||
"median_startup_ms": 0.463,
|
||||
"median_full_lifespan_ms": 0.667,
|
||||
"enabled_component_count": 8,
|
||||
"enabled_components": [
|
||||
"后台任务登记器",
|
||||
"数据库准备",
|
||||
"HTTP 基础能力",
|
||||
"领域依赖装配",
|
||||
"数据库引擎预热",
|
||||
"数据库连接预算",
|
||||
"路由",
|
||||
"模块服务"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,20 @@ 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:
|
||||
def _performance_sample(
|
||||
*,
|
||||
loaded_app_module_count: int = 5,
|
||||
) -> dict:
|
||||
"""构造区分环境诊断值与稳定宿主导入契约的性能样本。"""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"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,
|
||||
"loaded_app_module_count": loaded_app_module_count,
|
||||
"max_ms": 100.0,
|
||||
"median_ms": 90.0,
|
||||
"min_ms": 80.0,
|
||||
@@ -30,6 +34,7 @@ def _performance_sample(*, loaded_module_count: int = 10) -> dict:
|
||||
"modes": {
|
||||
"normal": {
|
||||
"enabled_component_count": 2,
|
||||
"enabled_components": ["后台任务登记器", "路由"],
|
||||
"samples": [
|
||||
{
|
||||
"threads_before": 1,
|
||||
@@ -215,6 +220,111 @@ def test_plugin_provenance_does_not_participate_in_semantic_gate(tmp_path: Path)
|
||||
) == architecture_baseline.semantic_baseline(baseline_path, second)
|
||||
|
||||
|
||||
def test_collect_sdk_exports_uses_explicit_all_and_resolves_aliases(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""SDK 快照只记录 __all__,并把顶层赋值恢复为真实导出目标。"""
|
||||
sdk_root = tmp_path / "app" / "sdk"
|
||||
sdk_root.mkdir(parents=True)
|
||||
(sdk_root / "demo.py").write_text(
|
||||
"from __future__ import annotations\n"
|
||||
"from typing import Any\n"
|
||||
"from app.foundation.crypto import CryptoJsUtils\n\n"
|
||||
"encrypt = CryptoJsUtils.encrypt\n\n"
|
||||
"def launch():\n"
|
||||
" return None\n\n"
|
||||
"def accidental():\n"
|
||||
" return None\n\n"
|
||||
"__all__ = ['CryptoJsUtils', 'encrypt', 'launch']\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(architecture_baseline, "APP_ROOT", tmp_path / "app")
|
||||
|
||||
exports = architecture_baseline.collect_sdk_exports()["app.sdk.demo"]
|
||||
|
||||
assert exports == [
|
||||
{
|
||||
"name": "CryptoJsUtils",
|
||||
"kind": "import",
|
||||
"target": "app.foundation.crypto.CryptoJsUtils",
|
||||
},
|
||||
{
|
||||
"name": "encrypt",
|
||||
"kind": "alias",
|
||||
"target": "app.foundation.crypto.CryptoJsUtils.encrypt",
|
||||
},
|
||||
{"name": "launch", "kind": "FunctionDef", "target": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_collect_sdk_exports_treats_missing_all_as_no_public_contract(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""未显式声明 __all__ 的 SDK 模块不得泄漏实现期导入。"""
|
||||
sdk_root = tmp_path / "app" / "sdk"
|
||||
sdk_root.mkdir(parents=True)
|
||||
(sdk_root / "internal.py").write_text(
|
||||
"from typing import Any\n\n"
|
||||
"def helper():\n"
|
||||
" return Any\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(architecture_baseline, "APP_ROOT", tmp_path / "app")
|
||||
|
||||
assert architecture_baseline.collect_sdk_exports() == {"app.sdk.internal": []}
|
||||
|
||||
|
||||
def test_official_plugin_baseline_includes_effective_v3_default_fallback(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""V3 插件快照应补入专用索引未接管的 package.json 兼容实现。"""
|
||||
plugin_repo = tmp_path / "MoviePilot-Plugins"
|
||||
sources = {
|
||||
"plugins.v3/current/__init__.py": "from app.sdk.events import Event\n",
|
||||
"plugins.v2/legacy/__init__.py": "from app.core.event import Event\n",
|
||||
"plugins/shared/__init__.py": "from app.log import logger\n",
|
||||
"plugins/current/__init__.py": "from app.unused.current import Value\n",
|
||||
"plugins/legacy/__init__.py": "from app.unused.legacy import Value\n",
|
||||
"plugins/rejected/__init__.py": "from app.unused.rejected import Value\n",
|
||||
}
|
||||
for relative, source in sources.items():
|
||||
path = plugin_repo / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
(plugin_repo / "package.v3.json").write_text(
|
||||
json.dumps({"Current": {"version": "3.0.0"}}), encoding="utf-8"
|
||||
)
|
||||
(plugin_repo / "package.v2.json").write_text(
|
||||
json.dumps({"Legacy": {"version": "2.0.0"}}), encoding="utf-8"
|
||||
)
|
||||
(plugin_repo / "package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"Current": {"v2": True},
|
||||
"Legacy": {"v2": True},
|
||||
"Shared": {"v2": True},
|
||||
"Rejected": {"v2": True, "v3": False},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
baseline = architecture_baseline.collect_official_plugin_baseline(plugin_repo)
|
||||
|
||||
assert baseline["scope"] == {
|
||||
"repository": "MoviePilot-Plugins",
|
||||
"roots": ["plugins.v2", "plugins.v3", "plugins"],
|
||||
"default_plugins": ["Shared"],
|
||||
}
|
||||
assert baseline["provenance"]["python_file_count"] == 3
|
||||
assert baseline["imports"]["app.log"]["files"] == [
|
||||
"plugins/shared/__init__.py"
|
||||
]
|
||||
assert not any(module.startswith("app.unused") for module in baseline["imports"])
|
||||
|
||||
|
||||
def test_plugin_v2_fixture_migrates_before_semantic_comparison(tmp_path: Path):
|
||||
"""旧插件 fixture 应在内存中迁移,不能因 schema 排列变化误报 ABI。"""
|
||||
baseline_path = tmp_path / "official-plugin-baseline.json"
|
||||
@@ -506,11 +616,47 @@ def test_performance_write_requires_explicit_action(tmp_path: Path, monkeypatch)
|
||||
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)
|
||||
def test_performance_check_reports_loaded_app_module_drift():
|
||||
"""稳定的宿主冷导入模块数量变化必须产生可诊断失败。"""
|
||||
expected = _performance_sample(loaded_app_module_count=5)
|
||||
actual = _performance_sample(loaded_app_module_count=6)
|
||||
|
||||
errors = startup_performance.check_baseline(expected, actual)
|
||||
|
||||
assert errors == ["app.factory 加载模块数变化:10 -> 11"]
|
||||
assert errors == ["app.factory 加载宿主模块数变化:5 -> 6"]
|
||||
|
||||
|
||||
def test_performance_check_ignores_environment_provenance_drift():
|
||||
"""Python 与平台 provenance 差异不应改变宿主冷导入语义契约。"""
|
||||
expected = _performance_sample()
|
||||
actual = _performance_sample()
|
||||
actual["python"] = "3.14"
|
||||
actual["platform"] = "another-platform"
|
||||
|
||||
assert startup_performance.check_baseline(expected, actual) == []
|
||||
|
||||
|
||||
def test_performance_check_reports_lifecycle_component_replacement():
|
||||
"""组件数量未变时,生命周期成员或顺序变化仍必须失败。"""
|
||||
expected = _performance_sample()
|
||||
actual = _performance_sample()
|
||||
actual["lifecycle"]["modes"]["normal"]["enabled_components"] = [
|
||||
"后台任务登记器",
|
||||
"数据库准备",
|
||||
]
|
||||
|
||||
errors = startup_performance.check_baseline(expected, actual)
|
||||
|
||||
assert errors == ["normal 模式生命周期组件集合或顺序已变化"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("safe_mode", (False, True))
|
||||
def test_performance_lifecycle_probe_preserves_task_registry(safe_mode: bool):
|
||||
"""隔离探针必须真实装配并释放 lifespan 依赖的 TaskRegistry。"""
|
||||
sample = startup_performance.measure_lifecycle(safe_mode)
|
||||
|
||||
assert "后台任务登记器" in sample["enabled_components"]
|
||||
assert sample["enabled_component_count"] == len(sample["enabled_components"])
|
||||
assert sample["threads_after"] == sample["threads_before"]
|
||||
assert sample["tasks_after"] == sample["tasks_before"]
|
||||
assert sample["database_connections_started"] == 0
|
||||
|
||||
@@ -33,6 +33,7 @@ def test_unit_test_workflow_has_independent_host_architecture_gate():
|
||||
assert "tests/test_architecture_dependencies.py" in commands
|
||||
assert "tests/test_architecture_contract_baseline.py" in commands
|
||||
assert "scripts/architecture/baseline.py --check-host" in commands
|
||||
assert "scripts/startup/performance.py --check --repeat 3" in commands
|
||||
|
||||
|
||||
def test_official_plugin_observation_is_scheduled_and_never_writes_fixture():
|
||||
|
||||
@@ -46,7 +46,8 @@ def test_official_plugin_baseline_records_external_source():
|
||||
|
||||
assert baseline["schema_version"] == 3
|
||||
assert baseline["scope"]["repository"] == "MoviePilot-Plugins"
|
||||
assert baseline["scope"]["roots"] == ["plugins.v2", "plugins.v3"]
|
||||
assert baseline["scope"]["roots"] == ["plugins.v2", "plugins.v3", "plugins"]
|
||||
assert baseline["scope"]["default_plugins"]
|
||||
assert len(baseline["provenance"]["head"]) == 40
|
||||
assert all(
|
||||
not path.startswith("app/plugins/")
|
||||
@@ -99,6 +100,7 @@ def test_startup_performance_baseline_records_all_cold_import_targets():
|
||||
baseline_path = BASELINE_ROOT / "startup-performance-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert baseline["schema_version"] == 2
|
||||
assert baseline["repeat"] >= 3
|
||||
assert set(baseline["targets"]) == {
|
||||
"app.startup.lifecycle",
|
||||
@@ -108,7 +110,7 @@ def test_startup_performance_baseline_records_all_cold_import_targets():
|
||||
for contract in baseline["targets"].values():
|
||||
assert len(contract["samples_ms"]) == baseline["repeat"]
|
||||
assert contract["min_ms"] <= contract["median_ms"] <= contract["max_ms"]
|
||||
assert contract["loaded_module_count"] > 0
|
||||
assert contract["loaded_app_module_count"] > 0
|
||||
|
||||
|
||||
def test_runtime_contract_baseline_excludes_diagnostic_line_numbers():
|
||||
@@ -202,6 +204,8 @@ def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resource
|
||||
safe = lifecycle["modes"]["safe"]
|
||||
assert normal["enabled_component_count"] > safe["enabled_component_count"]
|
||||
for mode in (normal, safe):
|
||||
assert "后台任务登记器" in mode["enabled_components"]
|
||||
assert mode["enabled_component_count"] == len(mode["enabled_components"])
|
||||
assert len(mode["samples"]) == baseline["repeat"]
|
||||
for sample in mode["samples"]:
|
||||
assert sample["threads_after"] == sample["threads_before"]
|
||||
|
||||
Reference in New Issue
Block a user