From 7f5b8b469aeeff412d573df23b4361578ecddeb1 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 30 Aug 2026 11:48:41 +0800 Subject: [PATCH] refactor(plugin): centralize market composition owners --- app/adapters/external/plugin/client.py | 3 +- app/startup/composition/plugin.py | 70 +++++++++++++++++++++++++ app/startup/initializers/plugins.py | 32 +++++------ tests/test_architecture_dependencies.py | 54 +++++++++++++++++++ 4 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 app/startup/composition/plugin.py diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py index 40c047347..aba9f53ba 100644 --- a/app/adapters/external/plugin/client.py +++ b/app/adapters/external/plugin/client.py @@ -30,6 +30,7 @@ from app.domain.plugin import ( parse_local_plugin_reference, ) from app.foundation.environment import is_free_threaded_runtime +from app.foundation.singleton import WeakSingleton from app.foundation.url import UrlUtils from app.foundation.version import compare_version from app.runtime.cache import async_fresh, cached, fresh, is_fresh @@ -208,7 +209,7 @@ def merge_plugin_market_repos( seen_repos.add(normalized_repo.lower()) return merged_repos -class PluginMarketTransport: +class PluginMarketTransport(metaclass=WeakSingleton): """负责插件市场、本地仓库和 GitHub 元数据读取。""" _base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/" diff --git a/app/startup/composition/plugin.py b/app/startup/composition/plugin.py new file mode 100644 index 000000000..54971db17 --- /dev/null +++ b/app/startup/composition/plugin.py @@ -0,0 +1,70 @@ +"""插件市场技术依赖的唯一启动组合 owner。""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from app.adapters.external.plugin.client import ( + PluginMarketClient, + PluginMarketTransport, + PluginPackageSourceClient, +) +from app.adapters.system.plugin.dependency import PluginDependencyInstaller +from app.adapters.system.plugin.health import PluginRuntimeHealth +from app.adapters.system.plugin.package import PluginPackageManager +from app.runtime.settings import get_runtime_setting + + +@dataclass(frozen=True, slots=True) +class PluginMarketComposition: + """保存插件市场相关 Transport、Client、Package 和 Dependency owner。""" + + transport: PluginMarketTransport + client: PluginMarketClient + package: PluginPackageManager + dependency: PluginDependencyInstaller + + +_market_client: Optional[PluginMarketClient] = None + + +def compose_plugin_market( + *, + installed_plugins_provider: Callable[[], list[str]], +) -> PluginMarketComposition: + """一次性构造插件市场技术依赖,供同一 lifespan 内所有用例复用。""" + global _market_client + root_path = Path(get_runtime_setting("ROOT_PATH")) + plugin_root = root_path / "app" / "plugins" + transport = PluginMarketTransport.get_existing_instance() or PluginMarketTransport() + client = PluginMarketClient(transport) + composition = PluginMarketComposition( + transport=transport, + client=client, + package=PluginPackageManager( + source=PluginPackageSourceClient(transport), + plugin_root=plugin_root, + ), + dependency=PluginDependencyInstaller( + PluginRuntimeHealth(), + installed_plugins_provider=installed_plugins_provider, + plugin_dir=plugin_root, + ), + ) + _market_client = client + return composition + + +def get_composed_plugin_market_client() -> PluginMarketClient: + """返回当前 lifespan 由组合根构造的唯一插件市场 Client。""" + if _market_client is None: + raise RuntimeError("插件市场 Client 尚未由启动组合根装配") + return _market_client + + +def reset_plugin_market_composition() -> None: + """撤销当前 lifespan 的插件市场 Client 投影。""" + global _market_client + _market_client = None diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 45bd169c7..b894df72b 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -13,15 +13,11 @@ from app.adapters.external.market import ( from app.adapters.external.plugin.client import ( VERSION_BACKWARD_COMPATIBLE_FLAGS, PluginMarketClient, - PluginMarketTransport, - PluginPackageSourceClient, split_plugin_market_repo_urls, ) from app.adapters.external.server import MoviePilotServerHelper from app.adapters.system.host import SystemUtils -from app.adapters.system.plugin.dependency import PluginDependencyInstaller from app.adapters.system.plugin.manifest import dependency_manifest_status -from app.adapters.system.plugin.package import PluginPackageManager from app.application.commands import init_commands from app.application.configuration import ( get_api_runtime_config_snapshot, @@ -123,6 +119,11 @@ from app.runtime.settings import get_runtime_setting from app.schemas.exception import PluginMutationRejectedError from app.schemas.plugin import PluginRuntimeStatus from app.schemas.types import SystemConfigKey +from app.startup.composition.plugin import ( + compose_plugin_market, + get_composed_plugin_market_client, + reset_plugin_market_composition, +) async def _async_write_plugin_config(key, value): @@ -180,12 +181,15 @@ def configure_plugin_runtime_services() -> None: def configure_plugin_services() -> None: """在模块持久化端口就绪后装配完整插件应用服务。""" - market_transport = PluginMarketTransport() - market_client = PluginMarketClient(market_transport) - package_manager = PluginPackageManager( - source=PluginPackageSourceClient(market_transport), - plugin_root=Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins", + market_composition = compose_plugin_market( + installed_plugins_provider=lambda: get_configured_system_config().get( + SystemConfigKey.UserInstalledPlugins + ) + or [] ) + market_transport = market_composition.transport + market_client = market_composition.client + package_manager = market_composition.package plugin_manager = get_plugin_manager() inventory_reader = PluginCandidateInventoryReader( market_loader=market_client.get_plugin_index_result, @@ -371,12 +375,7 @@ def configure_plugin_services() -> None: configure_plugin_system(PluginSystemServices( market=market_client, package=package_manager, - dependency=PluginDependencyInstaller( - installed_plugins_provider=lambda: get_configured_system_config().get( - SystemConfigKey.UserInstalledPlugins - ) or [], - plugin_dir=Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins", - ), + dependency=market_composition.dependency, dependency_manifest_status=dependency_manifest_status, compatible_flags=lambda flag: ( [flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) @@ -502,7 +501,7 @@ def _run_plugin_install_sync( def _build_plugin_catalog(plugin_mapper: Callable[..., Any]) -> PluginCatalogService: """在组合根连接目录用例、市场客户端、持久化读取和插件 DTO 映射。""" - client = PluginMarketClient() + client = get_composed_plugin_market_client() return PluginCatalogService( market_loader=client.get_plugins, async_market_loader=client.async_get_plugins, @@ -776,3 +775,4 @@ def stop_plugins() -> bool: reset_plugin_catalog_query() reset_plugin_release_service() reset_plugin_rating_service() + reset_plugin_market_composition() diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index f115fccc4..0fc138c62 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -2374,6 +2374,60 @@ def test_system_nettest_is_owned_by_application_service(): assert concrete_adapter_imports == set() +def test_plugin_market_construction_is_owned_by_startup_composition(): + """插件 initializer 只能消费组合根创建的市场依赖,禁止隐式构造第二套 owner。""" + initializer_path = APP_ROOT / "startup" / "initializers" / "plugins.py" + initializer_tree = ast.parse( + initializer_path.read_text(encoding="utf-8-sig"), + filename=str(initializer_path), + ) + initializer_imports = { + alias.name + for node in ast.walk(initializer_tree) + if isinstance(node, ast.ImportFrom) + for alias in node.names + } + assert "compose_plugin_market" in initializer_imports + forbidden_names = { + "PluginMarketTransport", + "PluginPackageSourceClient", + "PluginPackageManager", + "PluginDependencyInstaller", + } + assert forbidden_names.isdisjoint(initializer_imports) + assert not any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in forbidden_names + for node in ast.walk(initializer_tree) + ) + + composition_path = APP_ROOT / "startup" / "composition" / "plugin.py" + composition_tree = ast.parse( + composition_path.read_text(encoding="utf-8-sig"), + filename=str(composition_path), + ) + composition_calls = [ + node.func.id + for node in ast.walk(composition_tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in forbidden_names + ] + assert composition_calls.count("PluginMarketTransport") == 1 + assert composition_calls.count("PluginPackageSourceClient") == 1 + assert composition_calls.count("PluginPackageManager") == 1 + assert composition_calls.count("PluginDependencyInstaller") == 1 + + +def test_plugin_market_transport_is_process_shared_across_compat_and_composition(): + """兼容门面和组合根不得物化两套插件市场传输 owner。""" + from app.adapters.external.market import _plugin_market_transport + from app.adapters.external.plugin.client import PluginMarketTransport + + assert PluginMarketTransport.get_existing_instance() is _plugin_market_transport + + PROCESS_LEVEL_ROOTS = ( "app.api", "app.chain",