Files
MoviePilot/app/runtime/capabilities/registry.py
2026-08-16 16:07:38 +08:00

288 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))