ReMe/reme/plugin_cli.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

358 lines
15 KiB
Python

"""Local CLI for inspecting and managing installed ReMe plugin packages."""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from importlib import metadata
from pathlib import Path
import subprocess
import sys
import tomllib
from typing import Sequence
from .entry_point import PLUGIN_ENTRY_POINT_GROUP
from .plugin_manifest import PLUGIN_MANIFEST, PluginManifest, load_package_manifest, parse_plugin_manifest
@dataclass(frozen=True)
class InstalledPlugin:
"""Package metadata for one installed ``reme.plugins`` entry point."""
name: str
target: str
distribution: str
version: str
entry: metadata.EntryPoint
@property
def format(self) -> str:
"""Return the declarative or compatibility contract used by the entry point."""
return "manifest" if ":" not in self.target else "legacy"
def _installed_plugins() -> list[InstalledPlugin]:
"""Discover installed plugins without importing their packages."""
entries = metadata.entry_points().select(group=PLUGIN_ENTRY_POINT_GROUP)
plugins: list[InstalledPlugin] = []
for entry in entries:
distribution = getattr(entry, "dist", None)
dist_name = distribution.metadata.get("Name", "") if distribution is not None else ""
version = distribution.version if distribution is not None else ""
plugins.append(
InstalledPlugin(
name=entry.name,
target=entry.value,
distribution=dist_name or "unknown",
version=version or "unknown",
entry=entry,
),
)
return sorted(plugins, key=lambda item: (item.name, item.distribution, item.target))
def _select_plugin(name: str) -> InstalledPlugin:
matches = [plugin for plugin in _installed_plugins() if plugin.name == name]
if not matches:
raise ValueError(f"Plugin '{name}' is not installed")
if len(matches) > 1:
providers = ", ".join(f"{plugin.distribution} ({plugin.target})" for plugin in matches)
raise ValueError(f"Plugin '{name}' has multiple installed providers: {providers}")
return matches[0]
def _print_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> None:
widths = [max(len(header), *(len(row[index]) for row in rows)) for index, header in enumerate(headers)]
print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def _enabled_plugins(config: str | None) -> set[str] | None:
if config is None:
return None
from .config.config_parser import resolve_app_config
value = resolve_app_config(config=config, log_config=False).get("plugins") or []
if not isinstance(value, list) or not all(isinstance(name, str) for name in value):
raise TypeError("Application config 'plugins' must be a list of strings")
return set(value)
def _list_plugins(args: argparse.Namespace) -> int:
plugins = _installed_plugins()
enabled = _enabled_plugins(args.config)
records = [
{
"name": plugin.name,
"distribution": plugin.distribution,
"version": plugin.version,
"format": plugin.format,
"target": plugin.target,
**({"enabled": plugin.name in enabled} if enabled is not None else {}),
}
for plugin in plugins
]
if args.json:
print(json.dumps(records, ensure_ascii=False, indent=2))
return 0
if not records:
print("No ReMe plugins installed.")
return 0
headers = ["PLUGIN", "DISTRIBUTION", "VERSION", "FORMAT"]
if enabled is not None:
headers.append("ENABLED")
rows = [
[
record["name"],
record["distribution"],
record["version"],
record["format"],
*(["yes" if record["enabled"] else "no"] if enabled is not None else []),
]
for record in records
]
_print_table(headers, rows)
return 0
def _installed_manifest(plugin: InstalledPlugin) -> PluginManifest:
if plugin.format != "manifest":
raise ValueError(f"Plugin '{plugin.name}' uses the legacy Python descriptor format")
distribution = getattr(plugin.entry, "dist", None)
if distribution is not None:
relative = Path(*plugin.target.split(".")).joinpath(PLUGIN_MANIFEST)
path = Path(distribution.locate_file(relative))
if path.is_file():
return parse_plugin_manifest(path.read_text(encoding="utf-8"), plugin_name=plugin.name)
# Editable installs may not expose package data through ``locate_file``.
# Importlib resources then has to import the package, whose ``__init__``
# may still contain legacy registration side effects.
from .components.component_registry import R
with R.preserve(allow_mutation=True):
return load_package_manifest(plugin.target, plugin_name=plugin.name)
def _plugin_details(plugin: InstalledPlugin) -> dict:
details = {
"name": plugin.name,
"distribution": plugin.distribution,
"version": plugin.version,
"format": plugin.format,
"target": plugin.target,
"backends": [],
"default_jobs": [],
}
if plugin.format == "manifest":
manifest = _installed_manifest(plugin)
details["backends"] = list(manifest.backends)
jobs = manifest.application_defaults.get("jobs") or {}
details["default_jobs"] = list(jobs) if isinstance(jobs, dict) else []
return details
def _show_plugin(args: argparse.Namespace) -> int:
details = _plugin_details(_select_plugin(args.plugin))
if args.json:
print(json.dumps(details, ensure_ascii=False, indent=2))
return 0
for label, key in (
("Plugin", "name"),
("Distribution", "distribution"),
("Version", "version"),
("Format", "format"),
("Entry point", "target"),
):
print(f"{label}: {details[key]}")
for label, key in (("Backends", "backends"), ("Default jobs", "default_jobs")):
values = details[key]
print(f"{label}:" if values else f"{label}: none")
for value in values:
print(f" {value}")
return 0
def _run_pip(arguments: list[str]) -> int:
command = [sys.executable, "-m", "pip", *arguments]
try:
return subprocess.run(command, check=False).returncode
except OSError as exc:
raise RuntimeError(f"Unable to run pip with {sys.executable}: {exc}") from exc
def _install_plugin(args: argparse.Namespace) -> int:
command = ["install"]
if args.editable:
command.append("--editable")
if args.upgrade:
command.append("--upgrade")
command.append(args.target)
result = _run_pip(command)
if result == 0:
print("Package installed. Run 'reme plugins list' to verify it, then enable its plugin name in app config.")
return result
def _uninstall_plugin(args: argparse.Namespace) -> int:
plugin = _select_plugin(args.plugin)
if plugin.distribution == "unknown":
raise ValueError(f"Cannot determine the distribution that provides plugin '{plugin.name}'")
siblings = [
candidate.name
for candidate in _installed_plugins()
if candidate.distribution == plugin.distribution and candidate.name != plugin.name
]
if siblings:
print(f"Distribution '{plugin.distribution}' also provides: {', '.join(siblings)}")
command = ["uninstall"]
if args.yes:
command.append("--yes")
command.append(plugin.distribution)
result = _run_pip(command)
if result == 0:
print(f"Plugin package '{plugin.distribution}' uninstalled. Remove '{plugin.name}' from application configs.")
return result
def _validate_plugins(manager) -> None:
"""Validate imports, registry ownership, and merged application schema."""
from .components.component_registry import create_application_registry
from .config.config_parser import resolve_app_config
from .schema.application_config import ApplicationConfig
registry = create_application_registry()
manager.register(registry)
ApplicationConfig(**manager.merge_config(resolve_app_config(log_config=False)))
def _validate_installed(name: str) -> list[str]:
from .plugin import PluginManager
manager = PluginManager.discover([name])
_validate_plugins(manager)
return [name]
def _local_source_roots(project_file: Path, project: dict) -> list[Path]:
"""Return declared and conventional Python source roots for a local project."""
project_root = project_file.parent
candidates: list[Path] = []
setuptools = project.get("tool", {}).get("setuptools", {})
if isinstance(setuptools, dict):
package_dir = setuptools.get("package-dir", {})
if isinstance(package_dir, dict) and isinstance(package_dir.get(""), str):
candidates.append(project_root / package_dir[""])
packages = setuptools.get("packages", {})
package_find = packages.get("find", {}) if isinstance(packages, dict) else {}
if isinstance(package_find, dict):
where = package_find.get("where", [])
if isinstance(where, str):
where = [where]
if isinstance(where, list):
candidates.extend(project_root / item for item in where if isinstance(item, str))
# ``src`` is a build-backend-independent Python project convention used by
# Hatchling, Poetry, Flit, and setuptools. Root-layout projects remain the
# final fallback.
candidates.extend((project_root / "src", project_root))
return list(dict.fromkeys(candidate.resolve() for candidate in candidates))
def _validate_local(path: Path) -> list[str]:
from .components.component_registry import R
from .plugin import PluginManager, _plugin_from_manifest
project_file = path if path.name == "pyproject.toml" else path / "pyproject.toml"
if not project_file.is_file():
raise FileNotFoundError(f"pyproject.toml not found: {project_file}")
project = tomllib.loads(project_file.read_text(encoding="utf-8"))
entry_points = project.get("project", {}).get("entry-points", {}).get(PLUGIN_ENTRY_POINT_GROUP)
if not isinstance(entry_points, dict) or not entry_points:
raise ValueError(f"No {PLUGIN_ENTRY_POINT_GROUP} entry points found in {project_file}")
source_roots = _local_source_roots(project_file, project)
plugins = []
selected_roots: list[Path] = []
manifests = []
for name, package in entry_points.items():
if not isinstance(name, str) or not isinstance(package, str) or ":" in package:
raise ValueError("Local validation requires package-only manifest entry points")
relative_manifest = Path(*package.split(".")).joinpath(PLUGIN_MANIFEST)
manifest_path = next(
(root / relative_manifest for root in source_roots if (root / relative_manifest).is_file()),
None,
)
if manifest_path is None:
searched = ", ".join(str(root / relative_manifest) for root in source_roots)
raise FileNotFoundError(f"Plugin manifest not found; searched: {searched}")
selected_roots.append(manifest_path.parents[len(package.split("."))])
manifests.append((name, manifest_path))
inserted_roots = list(dict.fromkeys(str(root) for root in selected_roots))
for source_root in reversed(inserted_roots):
sys.path.insert(0, source_root)
try:
# Match installed-plugin loading: imports may execute compatibility
# decorators, but they must not mutate the frozen built-in template.
with R.preserve(allow_mutation=True):
for name, manifest_path in manifests:
manifest = parse_plugin_manifest(manifest_path.read_text(encoding="utf-8"), plugin_name=name)
plugins.append(_plugin_from_manifest(name, manifest))
_validate_plugins(PluginManager(plugins))
finally:
for source_root in inserted_roots:
sys.path.remove(source_root)
return [plugin.name for plugin in plugins]
def _validate_plugin(args: argparse.Namespace) -> int:
path = Path(args.target).expanduser()
names = _validate_local(path.resolve()) if path.exists() else _validate_installed(args.target)
print(f"Valid ReMe plugin: {', '.join(names)}")
return 0
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="reme plugins", description="Manage ReMe plugin packages.")
commands = parser.add_subparsers(dest="command", required=True)
list_parser = commands.add_parser("list", help="List installed ReMe plugins.")
list_parser.add_argument("--config", help="Show whether plugins are enabled by this config.")
list_parser.add_argument("--json", action="store_true", help="Print JSON output.")
list_parser.set_defaults(handler=_list_plugins)
show_parser = commands.add_parser("show", help="Show one installed plugin.")
show_parser.add_argument("plugin")
show_parser.add_argument("--json", action="store_true", help="Print JSON output.")
show_parser.set_defaults(handler=_show_plugin)
install_parser = commands.add_parser("install", help="Install a plugin package with this Python interpreter.")
install_parser.add_argument("target", help="Distribution specifier, wheel, VCS URL, or local path.")
install_parser.add_argument("--editable", action="store_true", help="Install a local project in editable mode.")
install_parser.add_argument("--upgrade", action="store_true", help="Upgrade an existing installation.")
install_parser.set_defaults(handler=_install_plugin)
uninstall_parser = commands.add_parser("uninstall", help="Uninstall the distribution providing a plugin.")
uninstall_parser.add_argument("plugin", help="Plugin entry-point name, such as auto-fin.")
uninstall_parser.add_argument("--yes", action="store_true", help="Do not ask pip for confirmation.")
uninstall_parser.set_defaults(handler=_uninstall_plugin)
validate_parser = commands.add_parser("validate", help="Validate an installed plugin or local plugin project.")
validate_parser.add_argument("target", help="Installed plugin name, project directory, or pyproject.toml.")
validate_parser.set_defaults(handler=_validate_plugin)
return parser
def plugin_cli(argv: Sequence[str]) -> int:
"""Run the local-only plugin command group and return a process status."""
args = _parser().parse_args(list(argv))
try:
return args.handler(args)
except (FileNotFoundError, KeyError, ModuleNotFoundError, RuntimeError, TypeError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1