mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
315 lines
13 KiB
Python
315 lines
13 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)
|
|
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(plugins) -> 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 .plugin import PluginManager
|
|
from .schema.application_config import ApplicationConfig
|
|
|
|
manager = PluginManager(plugins)
|
|
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.plugins)
|
|
return [name]
|
|
|
|
|
|
def _validate_local(path: Path) -> list[str]:
|
|
from .plugin import Backend, Plugin, _load_backend
|
|
|
|
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}")
|
|
|
|
setuptools = project.get("tool", {}).get("setuptools", {})
|
|
package_dir = setuptools.get("package-dir", {}) if isinstance(setuptools, dict) else {}
|
|
source_root = project_file.parent / (package_dir.get("") or ".")
|
|
plugins = []
|
|
sys.path.insert(0, str(source_root.resolve()))
|
|
try:
|
|
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")
|
|
manifest_path = source_root.joinpath(*package.split(".")).joinpath(PLUGIN_MANIFEST)
|
|
if not manifest_path.is_file():
|
|
raise FileNotFoundError(f"Plugin manifest not found: {manifest_path}")
|
|
manifest = parse_plugin_manifest(manifest_path.read_text(encoding="utf-8"), plugin_name=name)
|
|
backends = tuple(
|
|
Backend(backend_name, _load_backend(target, plugin_name=name))
|
|
for backend_name, target in manifest.backends.items()
|
|
)
|
|
plugins.append(Plugin(name=name, backends=backends, config=manifest.application_defaults))
|
|
_validate_plugins(plugins)
|
|
finally:
|
|
sys.path.remove(str(source_root.resolve()))
|
|
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
|