mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
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 * fix: isolate plugin CLI import side effects * refactor: streamline plugin validation * fix: route plugin CLI arguments independently * fix: support standard plugin source layouts
358 lines
15 KiB
Python
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
|