mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor(runtime): lazily activate host modules (#6331)
This commit is contained in:
@@ -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__))
|
||||
@@ -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 已进入关闭态,禁止启动或重新物化能力。"""
|
||||
@@ -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({})
|
||||
@@ -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
Reference in New Issue
Block a user