refactor(plugin): centralize market composition owners

This commit is contained in:
jxxghp
2026-08-30 11:48:41 +08:00
parent f7ca7e5172
commit 7f5b8b469a
4 changed files with 142 additions and 17 deletions

View File

@@ -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/"

View File

@@ -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

View File

@@ -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()

View File

@@ -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",