"""The core-owned ``httk plugin`` command."""
import argparse
import json
import os
import subprocess
import sys
from collections.abc import Callable, Sequence
from pathlib import Path, PurePosixPath
from httk.core.cli import CLIContext
from httk.core.project.templates import parse_template_manifest
from . import (
build_plugin,
install_plugin,
installed_plugins,
plugin_program,
shims_home,
uninstall_plugin,
)
#: Everything a handler may raise that is an operator's problem rather than a
#: defect. Anything here is reported as ``PROGRAM: message`` and exits ``2``.
_ERRORS = (OSError, ValueError, RuntimeError)
#: The handler contract every plugin subcommand honors.
[docs]
Handler = Callable[[argparse.Namespace, CLIContext], int]
def _field(name: str, value: object) -> str:
"""Render one name/value line of a human-readable description."""
return f"{name:<22}{value}"
def _find_plugin(name: str):
for plugin in installed_plugins():
if plugin.name == name:
return plugin
raise ValueError(f"plugin {name!r} is not installed")
def _first_error(exc: Exception) -> str:
return str(exc).splitlines()[0] or type(exc).__name__
def _list_value(description: dict[str, object], name: str) -> list[object]:
value = description.get(name)
if not isinstance(value, list):
raise ValueError(f"plugin description field {name!r} must be a list")
return value
def _template_details(plugin) -> list[dict[str, object]]:
details: list[dict[str, object]] = []
for member in plugin.manifest.templates:
try:
template = parse_template_manifest(plugin.root / PurePosixPath(member))
except (OSError, ValueError) as exc:
details.append({"directory": member, "invalid": _first_error(exc)})
else:
details.append({"id": template.id, "description": template.description})
return details
def _description(plugin) -> dict[str, object]:
description = dict(plugin.metadata)
description["templates"] = _template_details(plugin)
description["workflows"] = list(plugin.manifest.workflows)
description["programs"] = [
{"name": program.name, "file": program.file, "description": program.description}
for program in plugin.manifest.programs
]
return description
def _render(description: dict[str, object]) -> str:
"""Render one plugin description as readable lines."""
lines = [
_field("name", description.get("name")),
_field("description", description.get("description") or ""),
_field("source", description.get("source")),
_field("installed_at", description.get("installed_at")),
_field("built", description.get("built")),
"templates:",
]
for template in _list_value(description, "templates"):
assert isinstance(template, dict)
if "invalid" in template:
lines.append(f" {template['directory']}: invalid ({template['invalid']})")
else:
lines.append(f" {template['id']} {template.get('description') or ''}")
lines.append("workflows:")
lines.extend(f" {workflow}" for workflow in _list_value(description, "workflows"))
lines.append("programs:")
for program in _list_value(description, "programs"):
assert isinstance(program, dict)
lines.append(f" {program['name']} {program['file']} {program.get('description') or ''}")
return "\n".join(lines)
def _handle_install(arguments: argparse.Namespace, context: CLIContext) -> int:
"""Install one plugin source."""
installed = install_plugin(arguments.source, force=arguments.force)
suffix = " (built)" if installed.metadata.get("built") is True else ""
print(f"Installed plugin {installed.name!r} from {arguments.source}{suffix}")
if str(shims_home()) not in os.environ.get("PATH", "").split(os.pathsep):
print(f"note: add {shims_home()} to PATH to run plugin programs by name")
return 0
def _handle_list(arguments: argparse.Namespace, context: CLIContext) -> int:
"""List installed plugins."""
plugins = installed_plugins()
if not plugins:
print("no plugins installed")
return 0
for plugin in plugins:
print(f"{plugin.name} {plugin.manifest.description or ''}")
return 0
def _handle_show(arguments: argparse.Namespace, context: CLIContext) -> int:
"""Describe one installed plugin."""
description = _description(_find_plugin(arguments.name))
if arguments.json:
print(json.dumps(description, indent=2, sort_keys=True))
else:
print(_render(description))
return 0
def _handle_uninstall(arguments: argparse.Namespace, context: CLIContext) -> int:
"""Uninstall one plugin."""
uninstall_plugin(arguments.name)
print(f"Uninstalled plugin {arguments.name!r}")
return 0
def _handle_build(arguments: argparse.Namespace, context: CLIContext) -> int:
"""Build one installed plugin."""
build_plugin(arguments.name)
print(f"Built plugin {arguments.name!r}")
return 0
def _handle_path(arguments: argparse.Namespace, context: CLIContext) -> int:
"""Print one installed plugin program path."""
print(plugin_program(arguments.name, arguments.program))
return 0
def _handle_run(arguments: argparse.Namespace, context: CLIContext) -> int:
"""Run one installed plugin program with the remaining arguments."""
path = plugin_program(arguments.name, arguments.program)
return subprocess.run([str(path), *arguments.args], check=False).returncode
def _build_install(parser: argparse.ArgumentParser) -> None:
parser.add_argument("source", metavar="SOURCE", help="the plugin source to install")
parser.add_argument("--force", action="store_true", help="replace an installed plugin with the same name")
def _build_name(parser: argparse.ArgumentParser) -> None:
parser.add_argument("name", metavar="NAME", help="the installed plugin name")
def _build_show(parser: argparse.ArgumentParser) -> None:
_build_name(parser)
parser.add_argument("--json", action="store_true", help="print the description as one JSON document")
def _build_path(parser: argparse.ArgumentParser) -> None:
_build_name(parser)
parser.add_argument("program", metavar="PROGRAM", help="the declared program name")
def _build_run(parser: argparse.ArgumentParser) -> None:
_build_path(parser)
parser.add_argument("args", metavar="ARGS", nargs=argparse.REMAINDER, help="arguments for the program")
def _add_leaf(
subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
name: str,
*,
summary: str,
handler: Handler,
build: Callable[[argparse.ArgumentParser], None],
) -> None:
parser = subparsers.add_parser(name, help=summary, description=summary)
parser.set_defaults(handler=handler, help_parser=parser)
build(parser)
[docs]
def build_parser(program: str) -> argparse.ArgumentParser:
"""Build the core-owned plugin command tree.
:param program: Program name used by the parser in help and errors.
:return: Configured plugin command parser.
"""
parser = argparse.ArgumentParser(prog=program, description="Install and manage httk plugins")
parser.set_defaults(handler=None, help_parser=parser)
subparsers = parser.add_subparsers(metavar="COMMAND")
_add_leaf(
subparsers,
"install",
summary="install a plugin",
handler=_handle_install,
build=_build_install,
)
_add_leaf(subparsers, "list", summary="list installed plugins", handler=_handle_list, build=lambda parser: None)
_add_leaf(subparsers, "show", summary="describe an installed plugin", handler=_handle_show, build=_build_show)
_add_leaf(
subparsers,
"uninstall",
summary="uninstall a plugin",
handler=_handle_uninstall,
build=_build_name,
)
_add_leaf(subparsers, "build", summary="build an installed plugin", handler=_handle_build, build=_build_name)
_add_leaf(subparsers, "path", summary="print a plugin program path", handler=_handle_path, build=_build_path)
_add_leaf(subparsers, "run", summary="run a plugin program", handler=_handle_run, build=_build_run)
return parser
[docs]
def command(argv: Sequence[str], context: CLIContext) -> int:
"""Handle the registered top-level plugin command.
:param argv: Arguments following the plugin command name.
:param context: Root CLI invocation context.
:return: Command exit status.
"""
parser = build_parser(f"{context.program} plugin")
try:
arguments = parser.parse_args(list(argv))
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else 1
handler: Handler | None = getattr(arguments, "handler", None)
if handler is None:
getattr(arguments, "help_parser", parser).print_help()
return 0
try:
return handler(arguments, context)
except _ERRORS as exc:
print(f"{parser.prog}: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(command(sys.argv[1:], CLIContext("httk", Path.cwd())))