refactor(runtime): lazily activate host modules (#6331)

This commit is contained in:
InfinityPacer
2026-08-16 16:07:38 +08:00
committed by GitHub
parent 98276a68a8
commit 24671f8f18
67 changed files with 7364 additions and 253 deletions
+47
View File
@@ -0,0 +1,47 @@
"""MoviePilot 内部能力运行时的惰性公共导出。"""
from __future__ import annotations
from importlib import import_module
from typing import Any
_EXPORT_MODULES = {
"ActivationPolicy": "app.runtime.capabilities.model",
"AdapterExecutionMode": "app.runtime.capabilities.model",
"AsyncCapabilityAdapter": "app.runtime.capabilities.model",
"CapabilityAdapterContractError": "app.runtime.capabilities.errors",
"CapabilityAdapterModeError": "app.runtime.capabilities.errors",
"CapabilityError": "app.runtime.capabilities.errors",
"CapabilityLifecycleState": "app.runtime.capabilities.model",
"CapabilityManifestError": "app.runtime.capabilities.errors",
"CapabilityMaterializationState": "app.runtime.capabilities.model",
"CapabilityObservation": "app.runtime.capabilities.model",
"CapabilityOperationError": "app.runtime.capabilities.errors",
"CapabilityRegistry": "app.runtime.capabilities.registry",
"CapabilityRuntime": "app.runtime.capabilities.runtime",
"CapabilityRuntimeClosedError": "app.runtime.capabilities.errors",
"CapabilitySnapshot": "app.runtime.capabilities.model",
"CapabilitySpec": "app.runtime.capabilities.model",
"SelectorSchema": "app.runtime.capabilities.model",
"SelectorSpec": "app.runtime.capabilities.model",
"SyncCapabilityAdapter": "app.runtime.capabilities.model",
"UnknownCapabilityError": "app.runtime.capabilities.errors",
}
__all__ = sorted(_EXPORT_MODULES)
def __getattr__(name: str) -> Any:
"""仅在显式访问公共符号时导入对应叶模块,并缓存 canonical 对象。"""
module_name = _EXPORT_MODULES.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(import_module(module_name), name)
globals()[name] = value
return value
def __dir__() -> list[str]:
"""向交互式工具公开稳定导出名,而不触发叶模块导入。"""
return sorted(set(globals()) | set(__all__))
+32
View File
@@ -0,0 +1,32 @@
class CapabilityError(RuntimeError):
"""Capability Runtime 错误基类。"""
class CapabilityManifestError(CapabilityError, ValueError):
"""能力声明不完整、冲突或违反当前 schema。"""
class UnknownCapabilityError(CapabilityError, KeyError):
"""请求了 Registry 中不存在的能力。"""
class CapabilityAdapterModeError(CapabilityError, TypeError):
"""调用入口与适配器并发模型不匹配。"""
class CapabilityAdapterContractError(CapabilityError, TypeError):
"""适配器回调返回值不符合已声明的并发模型。"""
class CapabilityOperationError(CapabilityError):
"""能力物化或资源生命周期转换失败。"""
def __init__(self, capability_id: str, operation: str, error: BaseException):
self.capability_id = capability_id
self.operation = operation
self.error = error
super().__init__(f"能力 {capability_id} 执行 {operation} 失败:{error}")
class CapabilityRuntimeClosedError(CapabilityError):
"""Runtime 已进入关闭态,禁止启动或重新物化能力。"""
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from types import MappingProxyType
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol
class ActivationPolicy(str, Enum):
"""能力的启动触发策略。"""
BOOTSTRAP = "bootstrap"
WHEN_CONFIGURED = "when_configured"
ON_FIRST_USE = "on_first_use"
class AdapterExecutionMode(str, Enum):
"""领域适配器执行回调所使用的并发模型。"""
SYNC = "sync"
ASYNC = "async"
class CapabilityMaterializationState(str, Enum):
"""Python 实现对象的解析状态。"""
UNRESOLVED = "unresolved"
RESOLVING = "resolving"
RESOLVED = "resolved"
FAILED = "failed"
class CapabilityLifecycleState(str, Enum):
"""能力所拥有外部资源的生命周期状态。"""
DISCOVERED = "discovered"
STARTING = "starting"
RUNNING = "running"
RELOADING = "reloading"
STOPPING = "stopping"
STOPPED = "stopped"
FAILED = "failed"
@dataclass(frozen=True, slots=True)
class SelectorSchema:
"""声明一个 selector 可接受的精确参数集合。"""
required_fields: frozenset[str] = frozenset()
optional_fields: frozenset[str] = frozenset()
validator: Optional[Callable[[Mapping[str, Any]], None]] = None
@dataclass(frozen=True, slots=True)
class SelectorSpec:
"""由领域适配器解释的配置选择器。"""
kind: str
config: Mapping[str, Any]
@dataclass(frozen=True, slots=True)
class CapabilitySpec:
"""从 data-only manifest 构建的不可变能力声明。"""
schema_version: int
id: str
kind: str
entrypoint: str
activation: ActivationPolicy
metadata: Mapping[str, Any]
selector: Optional[SelectorSpec]
watch: tuple[str, ...]
depends_on: tuple[str, ...]
source: Path
@dataclass(frozen=True, slots=True)
class CapabilitySnapshot:
"""能力状态的只读快照。"""
capability_id: str
materialization: CapabilityMaterializationState
lifecycle: CapabilityLifecycleState
generation: int
visible: bool
error: Optional[str]
@dataclass(frozen=True, slots=True)
class CapabilityObservation:
"""单次状态转换的可复现观测记录。"""
capability_id: str
generation: int
operation: str
outcome: str
reason: str
materialization: CapabilityMaterializationState
lifecycle: CapabilityLifecycleState
duration_ms: float
error: Optional[str]
class SyncCapabilityAdapter(Protocol):
"""同步领域适配器合同。"""
execution_mode: AdapterExecutionMode
def materialize(self, spec: CapabilitySpec) -> Any:
"""解析 manifest entrypoint 对应的 canonical 实现对象。"""
def create(
self,
spec: CapabilitySpec,
implementation: Any,
generation: int,
previous: Any = None,
) -> Any:
"""创建尚未对外发布的候选资源。"""
def start(self, spec: CapabilitySpec, candidate: Any, generation: int) -> None:
"""启动候选资源;返回前候选不会对普通查询可见。"""
def stop(self, spec: CapabilitySpec, instance: Any, generation: int) -> None:
"""停止已经撤销运行态可见性的资源。"""
def cleanup(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> None:
"""清理由失败启动留下的候选资源。"""
class AsyncCapabilityAdapter(Protocol):
"""异步领域适配器合同。"""
execution_mode: AdapterExecutionMode
def materialize(self, spec: CapabilitySpec) -> Awaitable[Any]:
"""异步解析 manifest entrypoint 对应的 canonical 实现对象。"""
def create(
self,
spec: CapabilitySpec,
implementation: Any,
generation: int,
previous: Any = None,
) -> Awaitable[Any]:
"""异步创建尚未对外发布的候选资源。"""
def start(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
) -> Awaitable[None]:
"""异步启动候选资源。"""
def stop(
self,
spec: CapabilitySpec,
instance: Any,
generation: int,
) -> Awaitable[None]:
"""异步停止已经撤销运行态可见性的资源。"""
def cleanup(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> Awaitable[None]:
"""异步清理由失败启动留下的候选资源。"""
EMPTY_MAPPING: Mapping[str, Any] = MappingProxyType({})
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import re
import tomllib
from pathlib import Path
from types import MappingProxyType
from typing import Any, Collection, Iterable, Mapping
from app.runtime.capabilities.errors import (
CapabilityManifestError,
UnknownCapabilityError,
)
from app.runtime.capabilities.model import (
ActivationPolicy,
CapabilitySpec,
SelectorSchema,
SelectorSpec,
)
_SCHEMA_VERSION = 1
_MANIFEST_NAME = "capability.toml"
_TOP_LEVEL_FIELDS = frozenset({
"schema_version",
"id",
"kind",
"entrypoint",
"metadata",
"activation",
"depends_on",
})
_REQUIRED_FIELDS = _TOP_LEVEL_FIELDS
_ACTIVATION_FIELDS = frozenset({"policy", "watch", "selector"})
_ACTIVATION_REQUIRED_FIELDS = frozenset({"policy", "watch"})
_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
_KIND_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
_ENTRYPOINT_PATTERN = re.compile(
r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
r":[A-Za-z_][A-Za-z0-9_]*$"
)
def _freeze(value: Any, *, field: str) -> Any:
"""把 TOML 容器递归转换为不可变结构,并拒绝非配置标量。"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, list):
return tuple(_freeze(item, field=field) for item in value)
if isinstance(value, dict):
if not all(isinstance(key, str) and key for key in value):
raise CapabilityManifestError(f"{field} 包含非法键")
return MappingProxyType({
key: _freeze(item, field=f"{field}.{key}")
for key, item in value.items()
})
raise CapabilityManifestError(f"{field} 包含不支持的 TOML 值类型 {type(value).__name__}")
def _string_list(value: Any, *, field: str, path: Path) -> tuple[str, ...]:
"""校验无重复的非空字符串列表。"""
if not isinstance(value, list):
raise CapabilityManifestError(f"{path}: {field} 必须是字符串数组")
if any(not isinstance(item, str) or not item.strip() for item in value):
raise CapabilityManifestError(f"{path}: {field} 只能包含非空字符串")
normalized = tuple(item.strip() for item in value)
if len(set(normalized)) != len(normalized):
raise CapabilityManifestError(f"{path}: {field} 不能包含重复值")
return normalized
class CapabilityRegistry:
"""只读取 data-only manifest 的不可变能力注册表。"""
def __init__(
self,
specs: Mapping[str, CapabilitySpec],
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> None:
self._specs = MappingProxyType(dict(specs))
self._kinds = frozenset(kinds)
self._selector_schemas = MappingProxyType(dict(selector_schemas))
@classmethod
def discover(
cls,
roots: Iterable[Path | str],
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> "CapabilityRegistry":
"""扫描全部声明根;任何根或 manifest 非法都会阻止 Registry 构建。"""
normalized_kinds = frozenset(kinds)
if not normalized_kinds:
raise CapabilityManifestError("至少需要注册一个 capability kind")
for kind in normalized_kinds:
if not isinstance(kind, str) or not _KIND_PATTERN.fullmatch(kind):
raise CapabilityManifestError(f"非法 capability kind{kind!r}")
normalized_selectors = dict(selector_schemas)
for selector_type, schema in normalized_selectors.items():
if not isinstance(selector_type, str) or not _KIND_PATTERN.fullmatch(selector_type):
raise CapabilityManifestError(f"非法 selector type{selector_type!r}")
if not isinstance(schema, SelectorSchema):
raise CapabilityManifestError(f"selector {selector_type} 未提供 SelectorSchema")
overlap = schema.required_fields & schema.optional_fields
if overlap:
raise CapabilityManifestError(
f"selector {selector_type} 字段同时声明为 required/optional{sorted(overlap)}"
)
specs: dict[str, CapabilitySpec] = {}
normalized_roots = tuple(Path(root) for root in roots)
if not normalized_roots:
raise CapabilityManifestError("至少需要一个 capability 声明根")
for root in normalized_roots:
if not root.is_dir():
raise CapabilityManifestError(f"声明根不存在或不是目录:{root}")
manifests = sorted(root.rglob(_MANIFEST_NAME))
if not manifests:
raise CapabilityManifestError(f"声明根没有 {_MANIFEST_NAME}{root}")
for manifest_path in manifests:
spec = cls._load_manifest(
manifest_path,
kinds=normalized_kinds,
selector_schemas=normalized_selectors,
)
previous = specs.get(spec.id)
if previous:
raise CapabilityManifestError(
f"capability id 重复:{spec.id},来源 {previous.source}{spec.source}"
)
specs[spec.id] = spec
return cls(
specs,
kinds=normalized_kinds,
selector_schemas=normalized_selectors,
)
@classmethod
def _load_manifest(
cls,
path: Path,
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> CapabilitySpec:
try:
with path.open("rb") as file:
data = tomllib.load(file)
except (OSError, tomllib.TOMLDecodeError) as error:
raise CapabilityManifestError(f"无法读取 {path}{error}") from error
unknown_fields = set(data) - _TOP_LEVEL_FIELDS
if unknown_fields:
raise CapabilityManifestError(f"{path}: 未知字段 {sorted(unknown_fields)}")
missing_fields = _REQUIRED_FIELDS - set(data)
if missing_fields:
raise CapabilityManifestError(f"{path}: 缺少字段 {sorted(missing_fields)}")
schema_version = data["schema_version"]
if type(schema_version) is not int or schema_version != _SCHEMA_VERSION:
raise CapabilityManifestError(
f"{path}: 不支持 schema_version={schema_version!r}"
)
capability_id = data["id"]
if not isinstance(capability_id, str) or not _IDENTIFIER_PATTERN.fullmatch(capability_id):
raise CapabilityManifestError(f"{path}: 非法 capability id={capability_id!r}")
kind = data["kind"]
if not isinstance(kind, str) or kind not in kinds:
raise CapabilityManifestError(f"{path}: 未注册 capability kind={kind!r}")
entrypoint = data["entrypoint"]
if not isinstance(entrypoint, str) or not _ENTRYPOINT_PATTERN.fullmatch(entrypoint):
raise CapabilityManifestError(f"{path}: 非法 entrypoint={entrypoint!r}")
metadata = data["metadata"]
if not isinstance(metadata, dict):
raise CapabilityManifestError(f"{path}: metadata 必须是 table")
name = metadata.get("name")
if not isinstance(name, str) or not name.strip():
raise CapabilityManifestError(f"{path}: metadata.name 必须是非空字符串")
immutable_metadata = _freeze(metadata, field="metadata")
activation_data = data["activation"]
if not isinstance(activation_data, dict):
raise CapabilityManifestError(f"{path}: activation 必须是 table")
unknown_activation_fields = set(activation_data) - _ACTIVATION_FIELDS
missing_activation_fields = _ACTIVATION_REQUIRED_FIELDS - set(activation_data)
if unknown_activation_fields or missing_activation_fields:
raise CapabilityManifestError(
f"{path}: activation 字段非法,missing={sorted(missing_activation_fields)} "
f"unknown={sorted(unknown_activation_fields)}"
)
try:
activation = ActivationPolicy(activation_data["policy"])
except (TypeError, ValueError) as error:
raise CapabilityManifestError(
f"{path}: 非法 activation.policy={activation_data['policy']!r}"
) from error
selector = cls._parse_selector(
path,
activation=activation,
data=activation_data.get("selector"),
selector_schemas=selector_schemas,
)
watch = _string_list(activation_data["watch"], field="activation.watch", path=path)
depends_on = _string_list(data["depends_on"], field="depends_on", path=path)
if depends_on:
raise CapabilityManifestError(
f"{path}: 当前 schema 不支持非空 depends_on={list(depends_on)!r}"
)
return CapabilitySpec(
schema_version=schema_version,
id=capability_id,
kind=kind,
entrypoint=entrypoint,
activation=activation,
metadata=immutable_metadata,
selector=selector,
watch=watch,
depends_on=depends_on,
source=path,
)
@staticmethod
def _parse_selector(
path: Path,
*,
activation: ActivationPolicy,
data: Any,
selector_schemas: Mapping[str, SelectorSchema],
) -> SelectorSpec | None:
if activation is not ActivationPolicy.WHEN_CONFIGURED:
if data is not None:
raise CapabilityManifestError(
f"{path}: activation={activation.value} 时不允许 selector"
)
return None
if not isinstance(data, dict):
raise CapabilityManifestError(f"{path}: when_configured 必须提供 selector table")
selector_kind = data.get("kind")
if not isinstance(selector_kind, str) or selector_kind not in selector_schemas:
raise CapabilityManifestError(f"{path}: 未注册 selector kind={selector_kind!r}")
config = {key: value for key, value in data.items() if key != "kind"}
schema = selector_schemas[selector_kind]
missing = schema.required_fields - set(config)
unknown = set(config) - schema.required_fields - schema.optional_fields
if missing or unknown:
raise CapabilityManifestError(
f"{path}: selector {selector_kind} 字段非法,"
f"missing={sorted(missing)} unknown={sorted(unknown)}"
)
immutable_config = _freeze(config, field="selector")
if schema.validator:
try:
schema.validator(immutable_config)
except Exception as error:
raise CapabilityManifestError(
f"{path}: selector {selector_kind} 校验失败:{error}"
) from error
return SelectorSpec(kind=selector_kind, config=immutable_config)
@property
def kinds(self) -> frozenset[str]:
"""返回该 Registry 接受的 capability kind。"""
return self._kinds
def get_spec(self, capability_id: str) -> CapabilitySpec | None:
"""查询声明;不存在时返回 None。"""
return self._specs.get(capability_id)
def require_spec(self, capability_id: str) -> CapabilitySpec:
"""查询必需声明;不存在时给出稳定的领域错误。"""
spec = self.get_spec(capability_id)
if spec is None:
raise UnknownCapabilityError(f"未知 capability{capability_id}")
return spec
def list_specs(self) -> tuple[CapabilitySpec, ...]:
"""按 ID 返回稳定排序的声明快照。"""
return tuple(self._specs[key] for key in sorted(self._specs))
File diff suppressed because it is too large Load Diff