ReMe/reme/plugin_manifest.py
jinliyl efcc2b34d1
Some checks are pending
CI / Documentation / Test and build documentation (push) Waiting to run
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
feat: simplify plugin setup and add management CLI (#485)
* feat: simplify plugin setup and add management CLI

* fix: isolate plugin CLI import side effects

* refactor: streamline plugin validation

* fix: route plugin CLI arguments independently

* fix: support standard plugin source layouts
2026-08-23 17:58:20 +08:00

56 lines
2.4 KiB
Python

"""Parse the small, declarative contract shared by plugin runtime and CLI."""
from dataclasses import dataclass
from importlib import resources
from typing import Any
import yaml
PLUGIN_MANIFEST = "plugin.yaml"
@dataclass(frozen=True)
class PluginManifest:
"""The two contributions an installed plugin may declare."""
backends: dict[str, str]
application_defaults: dict[str, Any]
def parse_plugin_manifest(text: str, *, plugin_name: str) -> PluginManifest:
"""Parse and validate plugin.yaml without importing backend modules."""
try:
value = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise ValueError(f"Plugin '{plugin_name}' manifest is invalid YAML") from exc
if not isinstance(value, dict):
raise ValueError(f"Plugin '{plugin_name}' manifest root must be a mapping")
unknown = set(value) - {"backends", "application_defaults"}
if unknown:
raise ValueError(f"Plugin '{plugin_name}' manifest has unknown keys: {', '.join(sorted(unknown))}")
backends = value.get("backends")
application_defaults = value.get("application_defaults")
backends = {} if backends is None else backends
application_defaults = {} if application_defaults is None else application_defaults
if not isinstance(backends, dict):
raise TypeError(f"Plugin '{plugin_name}' manifest 'backends' must be a mapping")
if not isinstance(application_defaults, dict):
raise TypeError(f"Plugin '{plugin_name}' manifest 'application_defaults' must be a mapping")
for name, target in backends.items():
if not isinstance(name, str) or not name:
raise TypeError(f"Plugin '{plugin_name}' backend names must be non-empty strings")
if not isinstance(target, str) or not target:
raise TypeError(f"Plugin '{plugin_name}' backend target for '{name}' must be a non-empty string")
return PluginManifest(backends=dict(backends), application_defaults=dict(application_defaults))
def load_package_manifest(package: str, *, plugin_name: str) -> PluginManifest:
"""Read plugin.yaml from an importable package."""
try:
path = resources.files(package).joinpath(PLUGIN_MANIFEST)
text = path.read_text(encoding="utf-8")
except (ModuleNotFoundError, FileNotFoundError, TypeError) as exc:
raise ValueError(f"Plugin '{plugin_name}' does not provide {package}/{PLUGIN_MANIFEST}") from exc
return parse_plugin_manifest(text, plugin_name=plugin_name)