Source code for httk.core.project.cli

"""The core-owned ``httk project`` command.

``httk project`` owns the anchor: ``init`` creates one (like ``git init``),
``show`` describes it, ``seal`` creates a signed redistribution,
``verify-seal`` checks one, and ``import-v1`` migrates a legacy project.
"""

import argparse
import json
import shutil
import sys
from collections.abc import Callable, Sequence
from pathlib import Path

from httk.core.cli import CLIContext

from .anchor import (
    PROJECT_DIRECTORY,
    PROJECT_FILE,
    import_v1_project,
    initialize_project,
    key_fingerprint,
    pinned_project_key,
    read_project,
    require_project,
    trusted_project_keys,
)
from .seal import seal_project, verify_seal
from .templates import available_templates, check_parameters, instantiate_template, resolve_template

#: 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 project subcommand honors, the same one the root
#: :command:`httk` commands use.
[docs] Handler = Callable[[argparse.Namespace, CLIContext], int]
def _key_record(value: str) -> dict[str, object]: return {"public_key": value, "fingerprint": key_fingerprint(value)} def _field(name: str, value: object) -> str: """Render one name/value line of a human-readable description.""" return f"{name:<22}{value}"
[docs] def describe_project(root: str | Path | None = None) -> dict[str, object]: """Describe one project's anchor. :param root: Project root, or None to discover the nearest project. :return: JSON-ready description of project metadata and trust keys. :raises ValueError: If no project exists or its metadata is invalid. """ project = require_project(root) metadata = read_project(project) own = pinned_project_key(metadata) trusted = trusted_project_keys(metadata) seed = project / PROJECT_DIRECTORY / "keys" / "project.seed" description: dict[str, object] = { "format": "httk-project-description", "format_version": 1, "root": str(project), "project": { "project_id": metadata.get("project_id"), "name": metadata.get("name"), "description": metadata.get("description"), "imported_from": metadata.get("imported_from"), "manifest_exclusions": metadata.get("manifest_exclusions", []), }, "keys": { "pinned": own is not None, "public_key": None if own is None else _key_record(own), "seed_present": seed.is_file(), "trusted_keys": [_key_record(key) for key in trusted], }, } return description
def _render(description: dict[str, object]) -> str: """Render one anchor description as readable lines.""" project = description.get("project", {}) keys = description.get("keys", {}) assert isinstance(project, dict) and isinstance(keys, dict) public = keys.get("public_key") or {} assert isinstance(public, dict) lines = [ _field("root", description.get("root")), _field("name", project.get("name") or "-"), _field("project_id", project.get("project_id") or "-"), _field("key_pinned", "yes" if keys.get("pinned") else "no"), _field("key_fingerprint", public.get("fingerprint") or "-"), _field("trusted_keys", len(keys.get("trusted_keys", []))), ] return "\n".join(lines) # --------------------------------------------------------------------------- # Built-in leaves # --------------------------------------------------------------------------- def _handle_init(arguments: argparse.Namespace, context: CLIContext) -> int: """Create one project anchor at PATH, refusing an existing project.""" path = Path(arguments.path or context.cwd).expanduser().resolve() if arguments.list_templates: if arguments.path or arguments.template or arguments.parameter: raise ValueError("--list-templates cannot be combined with PATH, --template, or --parameter") templates = available_templates() if not templates: print("no templates available") else: for plugin, template in templates: print(f"{plugin}:{template.id} {template.description or ''}") print("templates can also be given as a directory path") return 0 if (path / PROJECT_DIRECTORY / PROJECT_FILE).is_file(): raise ValueError(f"{path} is already an httk project") if arguments.parameter and not arguments.template: raise ValueError("--parameter requires --template") if not arguments.template: metadata = initialize_project( path, name=arguments.name or path.name, description=arguments.description, ) print(f"Initialized httk project {metadata['name']!r} in {path / PROJECT_DIRECTORY}") return 0 template = resolve_template(arguments.template) supplied: dict[str, object] = {} for value in arguments.parameter or []: if "=" not in value: raise ValueError(f"invalid template parameter {value!r}; expected NAME=VALUE") name, raw = value.split("=", 1) try: supplied[name] = json.loads(raw) except json.JSONDecodeError: supplied[name] = raw parameters = check_parameters(template, supplied) was_fresh = not path.exists() or (path.is_dir() and not any(path.iterdir())) try: initialize_project( path, name=arguments.name or path.name, description=arguments.description, ) project = describe_project(path)["project"] assert isinstance(project, dict) notes = instantiate_template( template, path, parameters, project_info={ "name": project.get("name"), "description": project.get("description"), "project_id": project.get("project_id"), }, ) except _ERRORS as exc: if was_fresh: if path.is_dir(): shutil.rmtree(path) else: raise ValueError( f"{exc}; partial state left in {path} (httk_project/ and any copied template files)" ) from exc raise print(f"Initialized httk project {project['name']!r} in {path / PROJECT_DIRECTORY}") for note in notes: print(f"note: {note}") return 0 def _handle_import_v1(arguments: argparse.Namespace, context: CLIContext) -> int: """Import a legacy v1 project into a new v2 anchor.""" path = Path(arguments.path or context.cwd).expanduser().resolve() if (path / PROJECT_DIRECTORY / PROJECT_FILE).is_file(): raise ValueError(f"{path} is already an httk project") source = Path(arguments.source or path / "ht.project").expanduser().resolve() import_v1_project(path, source=source, name=arguments.name) print(f"imported {source} -> {path / PROJECT_DIRECTORY}") return 0 def _handle_show(arguments: argparse.Namespace, context: CLIContext) -> int: """Describe the nearest project: its metadata and keys.""" description = describe_project(arguments.path or context.cwd) if arguments.json: print(json.dumps(description, indent=2, sort_keys=True)) else: print(_render(description)) return 0 def _handle_seal(arguments: argparse.Namespace, context: CLIContext) -> int: """Seal the nearest project tree into a signed redistribution ZIP.""" output = seal_project(Path(arguments.out_zip).expanduser().resolve(), context.cwd) print(f"sealed project to {output}") return 0 def _handle_verify_seal(arguments: argparse.Namespace, context: CLIContext) -> int: """Verify a signed redistribution ZIP and print its signer.""" report = verify_seal( arguments.zip_path, expect_key=arguments.expect_key, trusted_keys=arguments.trusted_keys, ) print(report["status"]) print(f"public_key: {report['public_key']}") print(f"fingerprint: {report['fingerprint']}") return 0 def _build_init(parser: argparse.ArgumentParser) -> None: parser.add_argument( "path", metavar="PATH", nargs="?", help="the directory to make a project (default: the working directory)", ) parser.add_argument("--name", metavar="NAME", help="the project name (default: the directory name)") parser.add_argument("--description", metavar="TEXT", default="", help="a one-line description") parser.add_argument("--template", metavar="SELECTOR", help="instantiate a project template") parser.add_argument( "--parameter", action="append", metavar="NAME=VALUE", help="set a template parameter (repeatable)", ) parser.add_argument("--list-templates", action="store_true", help="list available project templates") def _build_show(parser: argparse.ArgumentParser) -> None: parser.add_argument( "path", metavar="PATH", nargs="?", help="the project to describe (default: the nearest project of the working directory)", ) parser.add_argument("--json", action="store_true", help="print the description as one JSON document") def _build_import_v1(parser: argparse.ArgumentParser) -> None: parser.add_argument( "path", metavar="PATH", nargs="?", help="the v1 project to import (default: the working directory)", ) parser.add_argument("--source", metavar="DIR", help="the v1 project directory (default: PATH/ht.project)") parser.add_argument("--name", metavar="NAME", help="the imported project name") def _build_seal(parser: argparse.ArgumentParser) -> None: parser.add_argument("out_zip", metavar="OUT.ZIP", help="destination signed redistribution ZIP") def _build_verify_seal(parser: argparse.ArgumentParser) -> None: parser.add_argument("zip_path", metavar="ZIP", help="signed redistribution ZIP to verify") parser.add_argument("--expect-key", metavar="FINGERPRINT", help="require this signer fingerprint") parser.add_argument( "--trusted-key", dest="trusted_keys", metavar="FINGERPRINT", action="append", default=[], help="trust this signer fingerprint", ) # --------------------------------------------------------------------------- # Assembly and dispatch # --------------------------------------------------------------------------- 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 project command tree. :param program: Program name used by the parser in help and errors. :return: Configured project command parser. """ parser = argparse.ArgumentParser(prog=program, description="Create and inspect httk projects") parser.set_defaults(handler=None, help_parser=parser) subparsers = parser.add_subparsers(metavar="COMMAND") _add_leaf(subparsers, "init", summary="create a project anchor here", handler=_handle_init, build=_build_init) _add_leaf( subparsers, "show", summary="describe the nearest project", handler=_handle_show, build=_build_show, ) _add_leaf( subparsers, "import-v1", summary="import a legacy v1 project", handler=_handle_import_v1, build=_build_import_v1, ) _add_leaf( subparsers, "seal", summary="create a signed project redistribution", handler=_handle_seal, build=_build_seal ) _add_leaf( subparsers, "verify-seal", summary="verify a signed project redistribution", handler=_handle_verify_seal, build=_build_verify_seal, ) return parser
[docs] def command(argv: Sequence[str], context: CLIContext) -> int: """Handle the registered top-level project command. :param argv: Arguments following the project command name. :param context: Root CLI invocation context. :return: Command exit status. """ parser = build_parser(f"{context.program} project") 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())))