From b658f7c2c0dcb771f3e6b1124a8835e18b2f62cb Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 29 Aug 2026 18:04:18 +0800 Subject: [PATCH] refactor(startup): isolate chain composition --- app/startup/composition/chain.py | 141 ++++++++++++++++++ app/startup/initializers/modules.py | 116 ++------------ docs/architecture-optimization-checklist.md | 15 +- docs/architecture-overview.md | 8 +- docs/rules/05-architecture.md | 10 +- scripts/architecture/service_locator.py | 3 +- .../architecture/dependency-baseline.json | 68 ++++++--- .../fixtures/architecture/mypy-baseline.json | 2 +- .../fixtures/architecture/ruff-baseline.json | 6 - tests/test_architecture_dependencies.py | 82 +++++++++- tests/test_chain_runtime_context.py | 85 +++++++++++ tests/test_service_locator_gate.py | 5 +- 12 files changed, 393 insertions(+), 148 deletions(-) create mode 100644 app/startup/composition/chain.py diff --git a/app/startup/composition/chain.py b/app/startup/composition/chain.py new file mode 100644 index 000000000..3d587a1bc --- /dev/null +++ b/app/startup/composition/chain.py @@ -0,0 +1,141 @@ +"""Chain 无参兼容入口与跨层依赖的宿主组合根。""" + +from functools import partial +from typing import Any, Callable + +from app.application.chain.context import ( + ChainRuntimeContext, + configure_chain_runtime_context_provider, +) +from app.application.configuration import ChainRuntimeConfig +from app.application.image import configure_wallpaper_providers +from app.application.messaging.message import MessageHelper, MessageQueueManager +from app.application.transfer.execution import TransferExecutionRepository +from app.chain.mediaserver import MediaServerChain +from app.chain.tmdb import TmdbChain +from app.db.adapters.chain import TransactionalChainDurableEventWriter +from app.db.adapters.download import TransactionalDownloadFailureRepository +from app.db.adapters.history.download import TransactionalDownloadHistoryRepository +from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository +from app.db.adapters.mediaserver import TransactionalMediaServerRepository +from app.db.adapters.site import TransactionalSiteRepository +from app.db.adapters.subscription import TransactionalSubscriptionRepository +from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository +from app.db.oper.message import MessageOper +from app.db.oper.systemconfig import SystemConfigOper +from app.db.session import SessionFactory +from app.runtime.cache import AsyncFileCache, FileCache +from app.runtime.events import EventManager +from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher +from app.runtime.extensions.module_manager import ModuleManager +from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.stop import runtime_stop_state +from app.startup.composition.database import build_transactional_user_repository +from app.startup.composition.subscription import ( + delete_subscribe_scope, + rule_group_mutation_scope, + site_reference_mutation_scope, + subscription_completion_scope, + subscription_mutation_scope, + sync_delete_subscribe_scope, + sync_subscription_mutation_scope, +) + +_event_manager_factory: Callable[[], EventManager] = EventManager + + +def execute_legacy_transfer_command(**kwargs: Any) -> Any: + """把旧 Chain ABI 延迟转入唯一 TransferChain durable command。""" + from app.chain.transfer.facade import TransferChain + + return TransferChain().execute_legacy_transfer_command(**kwargs) + + +def build_chain_runtime_context( + *, + message_helper: MessageHelper, + message_queue: MessageQueueManager, + system_config: SystemConfigOper, + site: TransactionalSiteRepository, + subscription: TransactionalSubscriptionRepository, + download_history: TransactionalDownloadHistoryRepository, + transfer_history: TransactionalTransferHistoryRepository, + transfer_execution: TransferExecutionRepository, + configuration: Callable[[], ChainRuntimeConfig], +) -> ChainRuntimeContext: + """创建 Chain 无参兼容入口共享的运行时对象与数据端口。""" + return ChainRuntimeContext( + module_manager=ModuleManager(), + plugin_manager=PluginManager(), + event_manager=_event_manager_factory(), + message_oper=MessageOper(), + message_helper=message_helper, + file_cache=FileCache(), + async_file_cache=AsyncFileCache(), + message_queue=message_queue, + module_dispatcher_factory=ModuleInvocationDispatcher, + site_repository=site, + subscription_repository=subscription, + subscription_mutation_scope=subscription_mutation_scope, + sync_subscription_mutation_scope=sync_subscription_mutation_scope, + subscription_delete_scope=delete_subscribe_scope, + sync_subscription_delete_scope=sync_delete_subscribe_scope, + subscription_completion_scope=subscription_completion_scope, + rule_group_mutation_scope=partial( + rule_group_mutation_scope, + system_config.publish_many, + ), + site_reference_mutation_scope=partial( + site_reference_mutation_scope, + system_config.publish_many, + ), + download_history_repository=download_history, + transfer_history_repository=transfer_history, + transfer_admission_repository=TransactionalTransferAdmissionRepository(SessionFactory), + transfer_execution_repository=transfer_execution, + media_server_repository=TransactionalMediaServerRepository(SessionFactory), + download_failure_repository=TransactionalDownloadFailureRepository(SessionFactory), + user_repository=build_transactional_user_repository(), + legacy_transfer_command=execute_legacy_transfer_command, + configuration=configuration(), + durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory), + stop_state=runtime_stop_state, + ) + + +def configure_wallpaper_services() -> None: + """把需要 Chain 编排的壁纸来源注入图片服务。""" + configure_wallpaper_providers( + tmdb_wallpaper=lambda: TmdbChain().get_random_wallpager(), + tmdb_wallpapers=lambda count: TmdbChain().get_trending_wallpapers(count), + mediaserver_wallpaper=lambda: MediaServerChain().get_latest_wallpaper(), + mediaserver_wallpapers=lambda count: MediaServerChain().get_latest_wallpapers(count=count), + ) + + +def configure_chain_runtime_context( + *, + message_helper: MessageHelper, + message_queue: MessageQueueManager, + system_config: SystemConfigOper, + site: TransactionalSiteRepository, + subscription: TransactionalSubscriptionRepository, + download_history: TransactionalDownloadHistoryRepository, + transfer_history: TransactionalTransferHistoryRepository, + transfer_execution: TransferExecutionRepository, + configuration: Callable[[], ChainRuntimeConfig], +) -> None: + """登记按需构造的 Chain 上下文,保持无参 Chain 的插件兼容合同。""" + configure_chain_runtime_context_provider( + lambda: build_chain_runtime_context( + message_helper=message_helper, + message_queue=message_queue, + system_config=system_config, + site=site, + subscription=subscription, + download_history=download_history, + transfer_history=transfer_history, + transfer_execution=transfer_execution, + configuration=configuration, + ) + ) diff --git a/app/startup/initializers/modules.py b/app/startup/initializers/modules.py index 381d8e349..c62dd91e6 100644 --- a/app/startup/initializers/modules.py +++ b/app/startup/initializers/modules.py @@ -3,11 +3,9 @@ import inspect import sys from collections.abc import Awaitable from functools import partial -from typing import Any, Callable, cast +from typing import Callable, cast from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper -from app.chain.mediaserver import MediaServerChain -from app.chain.tmdb import TmdbChain # SitesHelper涉及资源包拉取,提前引入并容错提示 try: @@ -25,17 +23,12 @@ from app.adapters.system.resource import ( ResourceHelper, configure_resource_version_provider, ) -from app.application.chain.context import ( - ChainRuntimeContext, - configure_chain_runtime_context_provider, -) from app.application.configuration import ( TransferRetryConfig, configure_transfer_retry_config, get_configured_system_config, ) from app.application.history import configure_transfer_history_repository -from app.application.image import configure_wallpaper_providers from app.application.messaging.agent import ( dispatch_web_agent_message_event, shutdown_web_agent_background_tasks, @@ -57,8 +50,6 @@ from app.application.site.health import SiteHealthService, configure_site_health from app.application.site.query import SiteQueryService, configure_site_query_service from app.application.workflow import configure_workflow_execution from app.command import CommandChain -from app.db.adapters.chain import TransactionalChainDurableEventWriter -from app.db.adapters.download import TransactionalDownloadFailureRepository from app.db.adapters.history.download import ( SessionDownloadHistoryRepository, TransactionalDownloadHistoryRepository, @@ -67,7 +58,6 @@ from app.db.adapters.history.transfer import ( SessionTransferHistoryRepository, TransactionalTransferHistoryRepository, ) -from app.db.adapters.mediaserver import TransactionalMediaServerRepository from app.db.adapters.outbox import ( SqlAlchemyAsyncOutboxDispatchStore, SqlAlchemyAsyncOutboxStager, @@ -79,7 +69,6 @@ from app.db.adapters.subscription import ( TransactionalSubscriptionHistoryRepository, TransactionalSubscriptionRepository, ) -from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository from app.db.adapters.transfer.execution import ( TransactionalTransferExecutionRepository, ) @@ -88,7 +77,6 @@ from app.db.adapters.workflow import ( ) from app.db.oper.mediaserver import MediaServerOper from app.db.oper.message import MessageOper -from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.workflow import WorkflowOper from app.db.session import ( SessionFactory, @@ -101,13 +89,10 @@ from app.db.uow import ( SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork, ) -from app.runtime.cache import AsyncFileCache, FileCache from app.runtime.config import settings as legacy_settings from app.runtime.events import EventHandlerBinding, EventManager from app.runtime.execution import run_in_threadpool_to_completion -from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher from app.runtime.extensions.module_manager import ModuleManager -from app.runtime.extensions.plugin_manager import PluginManager from app.runtime.extensions.service_config import ( ServiceConfigHelper, configure_service_config_reader, @@ -117,7 +102,6 @@ from app.runtime.settings import ( get_runtime_setting, ) from app.runtime.state import SystemHelper -from app.runtime.stop import runtime_stop_state from app.runtime.tasks import get_task_registry from app.runtime.thread import ThreadHelper from app.schemas.message import Message, MessageType @@ -126,8 +110,11 @@ from app.startup.composition.agent import ( compose_agent, publish_agent_services, ) +from app.startup.composition.chain import ( + configure_chain_runtime_context, + configure_wallpaper_services, +) from app.startup.composition.configuration import ( - build_chain_runtime_config, compose_configuration, publish_configuration, reset_configuration, @@ -142,7 +129,6 @@ from app.startup.composition.context import ( WorkflowRuntime, ) from app.startup.composition.database import ( - build_transactional_user_repository, compose_database_services, configure_database, database_runtime_active, @@ -161,13 +147,8 @@ from app.startup.composition.server import configure_server_services from app.startup.composition.subscription import ( async_rule_group_mutation_scope, build_subscription_batch_writer, - delete_subscribe_scope, rule_group_mutation_scope, site_reference_mutation_scope, - subscription_completion_scope, - subscription_mutation_scope, - sync_delete_subscribe_scope, - sync_subscription_mutation_scope, ) from app.startup.initializers.agent import configure_agent_data_context, init_agent from app.startup.initializers.resources import ( @@ -177,63 +158,6 @@ from app.startup.initializers.resources import ( from app.startup.initializers.scheduler import configure_scheduler_agent_tasks -def _execute_legacy_transfer_command(**kwargs: Any) -> Any: - """把旧 Chain ABI 延迟转入唯一 TransferChain durable command。""" - from app.chain.transfer.facade import TransferChain - - return TransferChain().execute_legacy_transfer_command(**kwargs) - - -def _build_chain_runtime_context( - *, - message_helper: MessageHelper, - message_queue: MessageQueueManager, - system_config: SystemConfigOper, - site: TransactionalSiteRepository, - subscription: TransactionalSubscriptionRepository, - download_history: TransactionalDownloadHistoryRepository, - transfer_history: TransactionalTransferHistoryRepository, -) -> ChainRuntimeContext: - """在启动组合根创建 Chain 所需的运行时对象和数据端口。""" - return ChainRuntimeContext( - module_manager=ModuleManager(), - plugin_manager=PluginManager(), - event_manager=EventManager(), - message_oper=MessageOper(), - message_helper=message_helper, - file_cache=FileCache(), - async_file_cache=AsyncFileCache(), - message_queue=message_queue, - module_dispatcher_factory=ModuleInvocationDispatcher, - site_repository=site, - subscription_repository=subscription, - subscription_mutation_scope=subscription_mutation_scope, - sync_subscription_mutation_scope=sync_subscription_mutation_scope, - subscription_delete_scope=delete_subscribe_scope, - sync_subscription_delete_scope=sync_delete_subscribe_scope, - subscription_completion_scope=subscription_completion_scope, - rule_group_mutation_scope=partial( - rule_group_mutation_scope, - system_config.publish_many, - ), - site_reference_mutation_scope=partial( - site_reference_mutation_scope, - system_config.publish_many, - ), - download_history_repository=download_history, - transfer_history_repository=transfer_history, - transfer_admission_repository=TransactionalTransferAdmissionRepository(SessionFactory), - transfer_execution_repository=TransactionalTransferExecutionRepository(SessionFactory), - media_server_repository=TransactionalMediaServerRepository(SessionFactory), - download_failure_repository=TransactionalDownloadFailureRepository(SessionFactory), - user_repository=build_transactional_user_repository(), - legacy_transfer_command=_execute_legacy_transfer_command, - configuration=build_chain_runtime_config(legacy_settings), - durable_event_writer=TransactionalChainDurableEventWriter(SessionFactory), - stop_state=runtime_stop_state, - ) - - def configure_runtime_data_providers() -> None: """在启动组合层装配模块和配置目录的运行时读取能力。""" configure_service_config_reader(lambda key: get_configured_system_config().get(key)) @@ -244,16 +168,6 @@ def configure_runtime_data_providers() -> None: ) -def configure_wallpaper_services() -> None: - """把需要 Chain 编排的壁纸来源注入图片服务。""" - configure_wallpaper_providers( - tmdb_wallpaper=lambda: TmdbChain().get_random_wallpager(), - tmdb_wallpapers=lambda count: TmdbChain().get_trending_wallpapers(count), - mediaserver_wallpaper=lambda: MediaServerChain().get_latest_wallpaper(), - mediaserver_wallpapers=lambda count: MediaServerChain().get_latest_wallpapers(count=count), - ) - - def notify_event_error(title: str, message: str) -> None: """将事件总线错误转发到系统消息通道。""" MessageHelper().put( @@ -660,16 +574,16 @@ async def _initialize_modules() -> HostRuntime: # 应用服务不反向依赖 Chain,由启动组合层注入壁纸来源。 configure_wallpaper_services() # Chain 无参兼容入口由组合根明确提供依赖上下文;测试和新代码可直接注入替代上下文。 - configure_chain_runtime_context_provider( - lambda: _build_chain_runtime_context( - message_helper=message_helper, - message_queue=message_queue, - system_config=system_config, - site=site_repository, - subscription=subscription_repository, - download_history=download_history_repository, - transfer_history=transfer_history_repository, - ) + configure_chain_runtime_context( + message_helper=message_helper, + message_queue=message_queue, + system_config=system_config, + site=site_repository, + subscription=subscription_repository, + download_history=download_history_repository, + transfer_history=transfer_history_repository, + transfer_execution=transfer_execution_repository, + configuration=configuration.runtime.chain, ) # 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。 configure_security_access() diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 668f9da43..d012555cd 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -62,7 +62,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | `chain` | 多入口复用的用例编排与 Module 动态分发已稳定,包根 SCC 已清零 | God object、私有长方法、无参构造和资源物化过重 | | `db/oper` | Model/Oper 已不自建 Session、不自行提交 | 无 Session Facade 仍被宿主组合根注入,业务操作可能拆成多个事务 | | `db/adapters` | 已有显式 Session/UoW 的参考切片 | Port 返回类型不够稳定;Outbox 的 stage 与自提交 dispatcher store 混在一个类型 | -| `startup` | 已是 HostRuntime 和生命周期组合根 | `initializers/modules.py` 高扇出,仍有导入期 provider 注册和具体对象目录装配 | +| `startup` | 已是 HostRuntime 和生命周期组合根;Chain 兼容上下文由 `composition/chain.py` 独立装配 | `initializers/modules.py` 仍有其他领域高扇出和具体对象目录装配 | | API/Command/Scheduler | 多数入口已转向 Chain/Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 | | `sdk`/`compat` | 精确映射、稳定插件 ABI 和宿主 canonical 路径已落地 | SDK 仍暴露部分可变全局对象/具体 Manager,只能渐进收窄,不能直接删除 | @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 970 / 8,279 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 971 / 8,306 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 | | 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 10,335 / 593 文件 | strict frontier 当前覆盖 41 个文件,Domain projection 与既有 owner 拆分的受益调用方低水位已固化 | +| 全量 mypy 历史债务 | 10,335 / 593 文件 | 新 Chain composition owner 零类型债,`modules.py` 低水位下降 1;strict frontier 当前覆盖 41 个文件 | | Ruff 历史诊断 | 648 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | @@ -672,10 +672,11 @@ Pylint 10/10、Ruff、mypy/复杂度 ratchet、宿主与最新官方插件基线 插件持久化迁入既有 `startup/composition/{configuration,database}.py`;initializer 只调用组合 API。 数据库 runtime 拒绝重入,Worker 成功后才发布事务入口;成功关闭与启动失败会对称撤销本批 provider、 释放数据库引擎,并在模块 owner 收敛后清除 `app.state.host_runtime`。 -- [x] L4.2 已将 network、security 与 agent 装配迁入单词 owner;Agent composition 在 Worker 启动后 - 读取容量,并让 `HostRuntime`、兼容 provider、工具管理器与 Scheduler 共享同一数据和任务对象。 -- [ ] 继续将 Chain、server、outbox 与 HostRuntime 构造按领域移到 - `startup/composition/*`,最终让 initializer 只负责顺序、注册和是否重启的决策。 +- [x] L4.2 已将 network、security、agent、server、outbox 与 Chain 装配迁入单词型 + `startup/composition/*` owner;Agent composition 在 Worker 启动后读取容量并共享同一数据和任务对象, + Chain 通过 provider 保持插件无参构造兼容,旧 Transfer command 只在真实调用时延迟导入。 +- [ ] 继续将 HostRuntime 剩余构造移到 `startup/composition/runtime.py`,最终让 initializer + 只负责顺序、注册和是否重启的决策。 - [ ] 保留生命周期 manifest 和顺序快照;高扇出在组合根是允许的,但业务实现不能继续沉积其中。 ### ARCH-204 收敛 sync/async 重复与测试存量 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index d64471c3e..16b2463a6 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -285,7 +285,9 @@ sequenceDiagram `HostRuntime.settings` 的窄服务读写可变部署设置,业务域不接触 Settings 实例;生产与测试组合根统一 复用 `startup/composition/configuration.py` 的映射、快照加载与发布。`startup/composition/database.py` 持有数据库 worker、兼容事务 runner、查询/插件持久化和旧 `ApiDataPorts` 的具体装配;initializer - 只保留顺序调用。`startup/composition/security.py` 统一装配认证、用户查询、PassKey 与 Web 访问 + 只保留顺序调用。`startup/composition/chain.py` 集中构造无参 Chain 兼容上下文及其持久化、事件、 + Outbox、消息和模块分发依赖,并延迟绑定旧 Transfer command 与壁纸 Chain,避免组合根导入环。 + `startup/composition/security.py` 统一装配认证、用户查询、PassKey 与 Web 访问 provider,并将同一 `AuthenticationRuntime` 交给 HostRuntime;`startup/composition/network.py` 统一 装配网络应用端口;`startup/composition/agent.py` 构造并发布唯一的 Agent 数据、会话和自主任务对象, `HostRuntime`、工具管理器与 Scheduler 复用同一对象身份。`ApiDataPorts` 仅保留旧导入 ABI, @@ -747,8 +749,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 970 | -| 内部导入边 | 8,279 | +| Python 模块 | 971 | +| 内部导入边 | 8,306 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index a225ea103..206e82992 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -141,6 +141,10 @@ runtime/settings construction and publication. `startup/composition/database.py` owns the process database worker, compatibility transaction runner, query services, plugin persistence and legacy API data-port wiring. Initializers call these owners in startup order and must not recreate their concrete construction. +`startup/composition/chain.py` owns the no-argument Chain compatibility context, +typed persistence/dispatch object graph, lazy legacy Transfer command binding and +wallpaper-provider publication. `initializers/modules.py` only passes the already +constructed repositories in startup order and must not recreate those dependencies. `startup/composition/security.py` owns authentication, user lookup, PassKey and Web access provider assembly; `initializers/modules.py` only invokes that owner and places its returned `AuthenticationRuntime` into the host runtime. @@ -151,7 +155,7 @@ startup order and must not recreate their concrete construction. `startup/composition/outbox.py` owns durable handler validation, lazy event/notification replay binding and the short-transaction dispatcher factory. Initializers call this owner in -startup order and must not recreate their concrete construction. Database runtime +startup order and must not recreate its concrete construction. `startup/composition/server.py` owns MoviePilot Server report/share service construction and registration, including lazy local-data readers and external transport callbacks; `initializers/modules.py` retains only its startup-order call @@ -160,8 +164,8 @@ and the later explicit network warmup. Database runtime chat persistence and autonomous-task repository/execution service set. It may accept the Agent initializer's data-context registrar as a callback, but must not import `startup.initializers`; tool-manager, scheduler and Agent activation order remains in -the initializer. Database runtime -ownership rejects overlapping lifespans; after a successful worker shutdown, the +the initializer. Database runtime ownership rejects overlapping lifespans; after a +successful worker shutdown, the configuration/database composition owners must revoke every provider they published before releasing database connections. Other domain providers remain owned by their matching composition batch and must satisfy the same symmetric lifecycle contract diff --git a/scripts/architecture/service_locator.py b/scripts/architecture/service_locator.py index c8b3611df..bf68327a9 100644 --- a/scripts/architecture/service_locator.py +++ b/scripts/architecture/service_locator.py @@ -47,6 +47,7 @@ RUNTIME_FACADE_POLICIES = ( exact_consumers=frozenset( { "app.sdk.plugins", + "app.startup.composition.chain", "app.startup.initializers.modules", } ), @@ -57,7 +58,7 @@ RUNTIME_FACADE_POLICIES = ( exact_consumers=frozenset( { "app.sdk.plugins", - "app.startup.initializers.modules", + "app.startup.composition.chain", "app.startup.initializers.plugins", } ), diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 210795fea..35d117e8c 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1089,8 +1089,8 @@ "runtime_only": true } }, - "edge_count": 8279, - "edge_sha256": "fe7bb7d7e064c8774e3c50b2db083386ff0d5bcb9476de2de3346de39478dd57", + "edge_count": 8306, + "edge_sha256": "2224fccf31c611f1ba2cf9e8afbdd33b9277ac2846101c85d57e0801b2a7b542", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -8651,6 +8651,49 @@ "app.startup.composition.agent -> app.startup.composition.context", "app.startup.composition.agent -> app.startup.composition.database", "app.startup.composition.agent -> app.startup.composition.subscription", + "app.startup.composition.chain -> app.application", + "app.startup.composition.chain -> app.application.chain", + "app.startup.composition.chain -> app.application.chain.context", + "app.startup.composition.chain -> app.application.configuration", + "app.startup.composition.chain -> app.application.image", + "app.startup.composition.chain -> app.application.messaging", + "app.startup.composition.chain -> app.application.messaging.message", + "app.startup.composition.chain -> app.application.transfer", + "app.startup.composition.chain -> app.application.transfer.execution", + "app.startup.composition.chain -> app.chain", + "app.startup.composition.chain -> app.chain.mediaserver", + "app.startup.composition.chain -> app.chain.tmdb", + "app.startup.composition.chain -> app.chain.transfer", + "app.startup.composition.chain -> app.chain.transfer.facade", + "app.startup.composition.chain -> app.db", + "app.startup.composition.chain -> app.db.adapters", + "app.startup.composition.chain -> app.db.adapters.chain", + "app.startup.composition.chain -> app.db.adapters.download", + "app.startup.composition.chain -> app.db.adapters.history", + "app.startup.composition.chain -> app.db.adapters.history.download", + "app.startup.composition.chain -> app.db.adapters.history.transfer", + "app.startup.composition.chain -> app.db.adapters.mediaserver", + "app.startup.composition.chain -> app.db.adapters.site", + "app.startup.composition.chain -> app.db.adapters.subscription", + "app.startup.composition.chain -> app.db.adapters.transfer", + "app.startup.composition.chain -> app.db.adapters.transfer.admission", + "app.startup.composition.chain -> app.db.oper", + "app.startup.composition.chain -> app.db.oper.message", + "app.startup.composition.chain -> app.db.oper.systemconfig", + "app.startup.composition.chain -> app.db.session", + "app.startup.composition.chain -> app.runtime", + "app.startup.composition.chain -> app.runtime.cache", + "app.startup.composition.chain -> app.runtime.events", + "app.startup.composition.chain -> app.runtime.extensions", + "app.startup.composition.chain -> app.runtime.extensions.module", + "app.startup.composition.chain -> app.runtime.extensions.module.dispatcher", + "app.startup.composition.chain -> app.runtime.extensions.module_manager", + "app.startup.composition.chain -> app.runtime.extensions.plugin_manager", + "app.startup.composition.chain -> app.runtime.stop", + "app.startup.composition.chain -> app.startup", + "app.startup.composition.chain -> app.startup.composition", + "app.startup.composition.chain -> app.startup.composition.database", + "app.startup.composition.chain -> app.startup.composition.subscription", "app.startup.composition.configuration -> app.application", "app.startup.composition.configuration -> app.application.configuration", "app.startup.composition.configuration -> app.application.database", @@ -8921,11 +8964,8 @@ "app.startup.initializers.modules -> app.agent.tools", "app.startup.initializers.modules -> app.agent.tools.manager", "app.startup.initializers.modules -> app.application", - "app.startup.initializers.modules -> app.application.chain", - "app.startup.initializers.modules -> app.application.chain.context", "app.startup.initializers.modules -> app.application.configuration", "app.startup.initializers.modules -> app.application.history", - "app.startup.initializers.modules -> app.application.image", "app.startup.initializers.modules -> app.application.messaging", "app.startup.initializers.modules -> app.application.messaging.agent", "app.startup.initializers.modules -> app.application.messaging.chat", @@ -8941,54 +8981,40 @@ "app.startup.initializers.modules -> app.application.workflow", "app.startup.initializers.modules -> app.chain", "app.startup.initializers.modules -> app.chain.download", - "app.startup.initializers.modules -> app.chain.mediaserver", "app.startup.initializers.modules -> app.chain.scraping", "app.startup.initializers.modules -> app.chain.search", "app.startup.initializers.modules -> app.chain.site", "app.startup.initializers.modules -> app.chain.subscribe", "app.startup.initializers.modules -> app.chain.subscribe.facade", - "app.startup.initializers.modules -> app.chain.tmdb", - "app.startup.initializers.modules -> app.chain.transfer", - "app.startup.initializers.modules -> app.chain.transfer.facade", "app.startup.initializers.modules -> app.chain.workflow", "app.startup.initializers.modules -> app.command", "app.startup.initializers.modules -> app.db", "app.startup.initializers.modules -> app.db.adapters", - "app.startup.initializers.modules -> app.db.adapters.chain", - "app.startup.initializers.modules -> app.db.adapters.download", "app.startup.initializers.modules -> app.db.adapters.history", "app.startup.initializers.modules -> app.db.adapters.history.download", "app.startup.initializers.modules -> app.db.adapters.history.transfer", - "app.startup.initializers.modules -> app.db.adapters.mediaserver", "app.startup.initializers.modules -> app.db.adapters.outbox", "app.startup.initializers.modules -> app.db.adapters.site", "app.startup.initializers.modules -> app.db.adapters.subscription", "app.startup.initializers.modules -> app.db.adapters.transfer", - "app.startup.initializers.modules -> app.db.adapters.transfer.admission", "app.startup.initializers.modules -> app.db.adapters.transfer.execution", "app.startup.initializers.modules -> app.db.adapters.workflow", "app.startup.initializers.modules -> app.db.oper", "app.startup.initializers.modules -> app.db.oper.mediaserver", "app.startup.initializers.modules -> app.db.oper.message", - "app.startup.initializers.modules -> app.db.oper.systemconfig", "app.startup.initializers.modules -> app.db.oper.workflow", "app.startup.initializers.modules -> app.db.session", "app.startup.initializers.modules -> app.db.uow", "app.startup.initializers.modules -> app.runtime", - "app.startup.initializers.modules -> app.runtime.cache", "app.startup.initializers.modules -> app.runtime.config", "app.startup.initializers.modules -> app.runtime.events", "app.startup.initializers.modules -> app.runtime.execution", "app.startup.initializers.modules -> app.runtime.extensions", - "app.startup.initializers.modules -> app.runtime.extensions.module", - "app.startup.initializers.modules -> app.runtime.extensions.module.dispatcher", "app.startup.initializers.modules -> app.runtime.extensions.module_manager", - "app.startup.initializers.modules -> app.runtime.extensions.plugin_manager", "app.startup.initializers.modules -> app.runtime.extensions.service_config", "app.startup.initializers.modules -> app.runtime.log", "app.startup.initializers.modules -> app.runtime.settings", "app.startup.initializers.modules -> app.runtime.state", - "app.startup.initializers.modules -> app.runtime.stop", "app.startup.initializers.modules -> app.runtime.tasks", "app.startup.initializers.modules -> app.runtime.thread", "app.startup.initializers.modules -> app.scheduler", @@ -8999,6 +9025,7 @@ "app.startup.initializers.modules -> app.startup", "app.startup.initializers.modules -> app.startup.composition", "app.startup.initializers.modules -> app.startup.composition.agent", + "app.startup.initializers.modules -> app.startup.composition.chain", "app.startup.initializers.modules -> app.startup.composition.configuration", "app.startup.initializers.modules -> app.startup.composition.context", "app.startup.initializers.modules -> app.startup.composition.database", @@ -9372,7 +9399,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 970, + "module_count": 971, "modules": [ "app", "app.adapters", @@ -10296,6 +10323,7 @@ "app.startup", "app.startup.composition", "app.startup.composition.agent", + "app.startup.composition.chain", "app.startup.composition.configuration", "app.startup.composition.context", "app.startup.composition.database", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 21fe85f51..58fb4c024 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -3286,7 +3286,7 @@ "attr-defined": 2, "misc": 1, "no-any-return": 2, - "no-untyped-call": 16, + "no-untyped-call": 15, "no-untyped-def": 6, "return-value": 2 }, diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 8fde30c46..c9ffc02aa 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -1,7 +1,4 @@ { - "app/adapters/external/server.py": { - "I001": 1 - }, "app/adapters/external/wechat_crypt.py": { "I001": 1 }, @@ -708,9 +705,6 @@ "app/runtime/extensions/plugin/storage.py": { "I001": 1 }, - "app/runtime/extensions/service_config.py": { - "I001": 1 - }, "app/runtime/gc.py": { "I001": 1 }, diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index f01b49395..2d9ef0f1f 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -566,7 +566,7 @@ def test_chain_runtime_context_owns_typed_repository_instances(): ) mediaserver_source = (APP_ROOT / "chain" / "mediaserver.py").read_text(encoding="utf-8") application_source = (APP_ROOT / "application" / "mediaserver.py").read_text(encoding="utf-8") - startup_source = (APP_ROOT / "startup" / "initializers" / "modules.py").read_text(encoding="utf-8") + startup_source = (APP_ROOT / "startup" / "composition" / "chain.py").read_text(encoding="utf-8") assert "DownloadFailure = Any" not in download_source assert "dboper" not in mediaserver_source assert "async def async_get_item_id(" in application_source @@ -578,7 +578,7 @@ def test_startup_composes_typed_chain_and_agent_data_contexts(): context_specs = { "ChainRuntimeContext": ( APP_ROOT / "application" / "chain" / "context.py", - APP_ROOT / "startup" / "initializers" / "modules.py", + APP_ROOT / "startup" / "composition" / "chain.py", { "site_repository", "subscription_repository", @@ -919,6 +919,74 @@ def test_modules_initializer_delegates_agent_composition(): assert "startup.initializers" not in agent_source +def test_modules_initializer_delegates_chain_composition(): + """Chain 上下文、壁纸与旧整理命令只由单词型 composition owner 装配。""" + initializer_source = ( + APP_ROOT / "startup" / "initializers" / "modules.py" + ).read_text(encoding="utf-8") + composition_path = APP_ROOT / "startup" / "composition" / "chain.py" + composition_source = composition_path.read_text(encoding="utf-8") + composition_tree = ast.parse(composition_source, filename=str(composition_path)) + package_tree = ast.parse( + (APP_ROOT / "startup" / "composition" / "__init__.py").read_text( + encoding="utf-8" + ) + ) + architecture_source = ( + PROJECT_ROOT / "docs" / "rules" / "05-architecture.md" + ).read_text(encoding="utf-8") + + assert composition_path.is_file() + assert not list(composition_path.parent.glob("chain_*.py")) + assert "from app.startup.composition.chain import (" in initializer_source + assert initializer_source.index("configure_wallpaper_services()") < ( + initializer_source.index("configure_chain_runtime_context(") + ) + for retired_name in ( + "_execute_legacy_transfer_command", + "_build_chain_runtime_context", + ): + assert retired_name not in initializer_source + for detail in ( + "ChainRuntimeContext(", + "TransactionalTransferAdmissionRepository(SessionFactory)", + "TransactionalChainDurableEventWriter(SessionFactory)", + "def execute_legacy_transfer_command(", + "def build_chain_runtime_context(", + "def configure_wallpaper_services(", + "def configure_chain_runtime_context(", + ): + assert detail not in initializer_source + assert detail in composition_source + assert initializer_source.count( + "TransactionalTransferExecutionRepository(" + ) == 1 + assert "transfer_execution=transfer_execution_repository" in initializer_source + assert "transfer_execution_repository=transfer_execution" in composition_source + assert "TransactionalTransferExecutionRepository" not in composition_source + transfer_function = next( + node + for node in composition_tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "execute_legacy_transfer_command" + ) + assert any( + isinstance(node, ast.ImportFrom) + and node.module == "app.chain.transfer.facade" + for node in ast.walk(transfer_function) + ) + assert not any( + isinstance(node, ast.ImportFrom) + and node.module == "app.chain.transfer.facade" + for node in composition_tree.body + ) + assert "`startup/composition/chain.py` owns" in architecture_source + assert not any( + isinstance(node, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.ClassDef)) + for node in package_tree.body + ) + + def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports(): """工作流旧写入口只能存在于 SDK Legacy facade。""" oper_path = APP_ROOT / "db" / "oper" / "workflow.py" @@ -1119,11 +1187,15 @@ def test_startup_injects_user_adapter_instead_of_raw_oper(): database_source = ( APP_ROOT / "startup" / "composition" / "database.py" ).read_text(encoding="utf-8-sig") - source = initializer_source + database_source + chain_source = ( + APP_ROOT / "startup" / "composition" / "chain.py" + ).read_text(encoding="utf-8-sig") + source = initializer_source + database_source + chain_source assert "TransactionalUserRepository" in source assert "SqlAlchemyUserRepository" in source - assert "build_transactional_user_repository" in initializer_source + assert "build_transactional_user_repository" not in initializer_source + assert "build_transactional_user_repository" in chain_source assert "from app.db.oper.user import UserOper" not in source assert "user=lambda: UserOper()" not in source @@ -1160,7 +1232,7 @@ def test_transfer_pending_oper_import_is_confined_to_database_boundary(): def test_startup_injects_transactional_transfer_admission_repository(): """启动组合根必须向 Chain 注入事务型整理准入仓储。""" - path = APP_ROOT / "startup" / "initializers" / "modules.py" + path = APP_ROOT / "startup" / "composition" / "chain.py" tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) imports_repository = any( isinstance(node, ast.ImportFrom) diff --git a/tests/test_chain_runtime_context.py b/tests/test_chain_runtime_context.py index e62b52be6..1e7b2637a 100644 --- a/tests/test_chain_runtime_context.py +++ b/tests/test_chain_runtime_context.py @@ -1,5 +1,7 @@ """Chain 运行上下文注入和无参兼容 provider 测试。""" +import sys +from types import ModuleType from unittest.mock import Mock, call import pytest @@ -114,3 +116,86 @@ def test_chain_keeps_explicit_typed_repositories() -> None: assert chain.subscription_completion_scope is context.subscription_completion_scope assert chain.transfer_execution_repository is context.transfer_execution_repository assert chain.user_repository is context.user_repository + + +def test_chain_composition_registers_lazy_compatibility_provider(monkeypatch) -> None: + """组合 API 只登记 provider,首次无参 Chain 请求时才构造完整上下文。""" + from app.startup.composition import chain as chain_composition + + context = _context() + builder = Mock(return_value=context) + register = Mock() + dependencies = { + "message_helper": Mock(), + "message_queue": Mock(), + "system_config": Mock(), + "site": Mock(), + "subscription": Mock(), + "download_history": Mock(), + "transfer_history": Mock(), + "transfer_execution": Mock(), + "configuration": Mock(), + } + monkeypatch.setattr(chain_composition, "build_chain_runtime_context", builder) + monkeypatch.setattr( + chain_composition, + "configure_chain_runtime_context_provider", + register, + ) + + chain_composition.configure_chain_runtime_context(**dependencies) + + builder.assert_not_called() + provider = register.call_args.args[0] + assert provider() is context + builder.assert_called_once_with(**dependencies) + + +def test_chain_composition_keeps_legacy_transfer_import_lazy(monkeypatch) -> None: + """旧整理命令仅在真实调用时加载 TransferChain,并原样转交参数。""" + from app.startup.composition import chain as chain_composition + + execute = Mock(return_value="transferred") + transfer_chain = Mock(return_value=Mock(execute_legacy_transfer_command=execute)) + facade = ModuleType("app.chain.transfer.facade") + facade.TransferChain = transfer_chain + monkeypatch.setitem(sys.modules, "app.chain.transfer.facade", facade) + + result = chain_composition.execute_legacy_transfer_command( + source="downloads", + target="library", + ) + + assert result == "transferred" + transfer_chain.assert_called_once_with() + execute.assert_called_once_with(source="downloads", target="library") + + +def test_chain_composition_registers_lazy_wallpaper_providers(monkeypatch) -> None: + """壁纸装配只登记 callable,不在启动阶段提前物化业务 Chain。""" + from app.startup.composition import chain as chain_composition + + register = Mock() + tmdb_instance = Mock() + tmdb_instance.get_random_wallpager.return_value = "tmdb-one" + tmdb_instance.get_trending_wallpapers.return_value = ["tmdb-many"] + media_instance = Mock() + media_instance.get_latest_wallpaper.return_value = "media-one" + media_instance.get_latest_wallpapers.return_value = ["media-many"] + tmdb_chain = Mock(return_value=tmdb_instance) + media_chain = Mock(return_value=media_instance) + monkeypatch.setattr(chain_composition, "configure_wallpaper_providers", register) + monkeypatch.setattr(chain_composition, "TmdbChain", tmdb_chain) + monkeypatch.setattr(chain_composition, "MediaServerChain", media_chain) + + chain_composition.configure_wallpaper_services() + + tmdb_chain.assert_not_called() + media_chain.assert_not_called() + providers = register.call_args.kwargs + assert providers["tmdb_wallpaper"]() == "tmdb-one" + assert providers["tmdb_wallpapers"](3) == ["tmdb-many"] + assert providers["mediaserver_wallpaper"]() == "media-one" + assert providers["mediaserver_wallpapers"](4) == ["media-many"] + tmdb_instance.get_trending_wallpapers.assert_called_once_with(3) + media_instance.get_latest_wallpapers.assert_called_once_with(count=4) diff --git a/tests/test_service_locator_gate.py b/tests/test_service_locator_gate.py index de590d36a..45ef0d08c 100644 --- a/tests/test_service_locator_gate.py +++ b/tests/test_service_locator_gate.py @@ -29,9 +29,12 @@ def test_service_locator_gate_detects_each_concrete_runtime_family() -> None: "app.startup.initializers.modules": { "app.command", "app.runtime.extensions.module_manager", - "app.runtime.extensions.plugin_manager", "app.scheduler.facade", }, + "app.startup.composition.chain": { + "app.runtime.extensions.module_manager", + "app.runtime.extensions.plugin_manager", + }, "app.startup.composition.outbox": {"app.command"}, "app.workflow.manager": {"app.workflow"}, }