Source code for codegen.cli

"""Codegen CLI entry point.

The CLI is target-agnostic: every piece of framework-specific
behavior comes from a :class:`~codegen.target.Target` discovered
via the ``codegen.targets`` entry-point group.  The codegen CLI
loads the config, runs the generic pipeline against the target's
registry/assembler/env, and writes files to disk.
"""

import shutil
import sys
from pathlib import Path
from typing import Annotated

import typer
from rich.console import Console

from codegen.blank_lines import cli as lint_cli
from codegen.config import load_config
from codegen.diff import (
    compute_diffs,
    has_changes,
    navigate,
    render_names,
    render_patch,
    render_report,
    select_diffs,
)
from codegen.errors import CLIError
from codegen.output import write_files
from codegen.pipeline import generate
from codegen.target import Target, discover_targets

app = typer.Typer(
    help=(
        "Generic code-generation CLI.  Operates on any target "
        "registered under the codegen.targets entry-point group."
    ),
)
targets_app = typer.Typer(help="Inspect installed targets.")
app.add_typer(targets_app, name="targets")

# ``codegen lint`` runs codegen's own lint passes over generated
# (or any) Python code.  Today that's just the blank-line policy
# from :mod:`codegen.blank_lines`; the command exists so generated
# app justfiles -- and the workspace pre-commit hook -- can call
# ``codegen lint [PATHS] --fix`` without depending on a script path
# inside the monorepo.  Future passes plug in here too.
app.command("lint")(lint_cli)


ConfigOption = Annotated[
    Path,
    typer.Option(
        "--config",
        "-c",
        help="Path to the config file.",
    ),
]
OutOption = Annotated[
    Path,
    typer.Option(
        "--out",
        "-o",
        help="Output root directory.  Defaults to the current directory.",
    ),
]
TargetOption = Annotated[
    str | None,
    typer.Option(
        "--target",
        "-t",
        help=(
            "Which target to use.  Optional when exactly one "
            "target is installed."
        ),
    ),
]


def _resolve_target(name: str | None) -> Target:
    """Pick a :class:`~codegen.target.Target` by name or uniqueness.

    Args:
        name: Name passed via ``--target``.  ``None`` means the
            user did not specify one.

    Returns:
        The matching target.

    Raises:
        CLIError: If no targets are registered, if ``name`` is
            unknown, or if ``name`` is ``None`` and multiple
            targets are installed.

    """
    targets = discover_targets()

    if not targets:
        msg = (
            "No target is registered under the codegen.targets "
            "entry-point group.  Install a plugin (e.g. be) "
            "that provides one."
        )
        raise CLIError(msg)

    if name is None:
        if len(targets) > 1:
            names = ", ".join(t.name for t in targets)
            msg = (
                f"Multiple targets installed ({names}); pick one with --target"
            )
            raise CLIError(msg)

        return targets[0]

    for target in targets:
        if target.name == name:
            return target

    names = ", ".join(t.name for t in targets)
    msg = f"No target named {name!r} (installed: {names})"
    raise CLIError(msg)


def _stdlibs() -> dict[str, Path]:
    """Collect jsonnet stdlib prefixes from every installed target.

    Every target's stdlib is available to every config, so an
    installation with be + another target lets configs import
    from both under their respective prefixes.
    """
    return {
        t.name: t.jsonnet_stdlib_dir
        for t in discover_targets()
        if t.jsonnet_stdlib_dir is not None
    }


[docs] @app.callback() def main() -> None: """Run the generic code-generation CLI."""
[docs] @app.command("clean") def clean_cmd( config: ConfigOption, out: OutOption = Path(), target_name: TargetOption = None, ) -> None: """Delete the output directory. Removes *out* and its contents. The current working directory is never deleted. ``--config`` is parsed so the CLI surfaces config errors consistently, but its contents do not influence what is removed. """ target = _resolve_target(target_name) load_config(config, target.schema, _stdlibs()) if out == Path() or not out.exists(): typer.echo(f"Nothing to clean at {out}.") return shutil.rmtree(out) typer.echo(f"Cleaned {out}.")
[docs] @app.command("generate") def generate_cmd( # noqa: PLR0913 config: ConfigOption, out: OutOption = Path(), target_name: TargetOption = None, clean: Annotated[ # noqa: FBT002 bool, typer.Option( "--clean", help=( "Run ``clean`` before generating, removing files " "that no longer correspond to the config." ), ), ] = False, dry_run: Annotated[ # noqa: FBT002 bool, typer.Option( "--dry-run", help=( "List the files that would be generated without " "touching the filesystem. Incompatible with " "``--clean``." ), ), ] = False, force: Annotated[ # noqa: FBT002 bool, typer.Option( "--force", help=( "Treat every file the target produced as " '``if_exists="overwrite"``. Use with care: ' "this clobbers files the target marked as " '``"skip"`` (e.g. be_root\'s bootstrap ' "files), discarding any post-generation edits." ), ), ] = False, force_paths: Annotated[ list[str] | None, typer.Option( "--force-paths", help=( "Output paths to overwrite even when the target " 'marked them ``"skip"``. Repeat the flag, pass a ' "comma list (``--force-paths pyproject.toml,main.py``), " "or pass extra paths positionally -- so a shell-" "expanded glob just works: ``--force-paths *.just`` " "(your shell expands it to the matching filenames). " "A quoted glob (``'*.just'``) is matched by codegen " "instead. Ignored when ``--force`` is set." ), ), ] = None, paths: Annotated[ list[str] | None, typer.Argument( help=( "Extra paths to force-overwrite, merged with " "--force-paths. Mainly catches the tail of a shell-" "expanded glob (the first match lands on the flag, " "the rest here)." ), ), ] = None, ) -> None: """Generate files from a config via the selected target.""" if dry_run and clean: msg = "--dry-run cannot be combined with --clean" raise CLIError(msg) if clean: clean_cmd(config=config, out=out, target_name=target_name) target = _resolve_target(target_name) cfg = load_config(config, target.schema, _stdlibs()) files = generate(cfg, target) if dry_run: for f in files: typer.echo(str(out / f.path)) typer.echo(f"Would generate {len(files)} file(s).") return forced = _split_force_paths(force_paths) | _split_force_paths(paths) written = write_files(files, out, force=force, force_paths=forced) if written == len(files): typer.echo(f"Generated {written} file(s).") else: skipped = len(files) - written typer.echo( f"Generated {written} file(s); skipped {skipped} " f"(already exist; pass --force or --force-paths to " f"overwrite)." )
def _split_force_paths(values: list[str] | None) -> set[str]: """Flatten a typer-collected ``--force-paths`` list to a set. Typer hands each ``--force-paths`` invocation back as a list item. Each item may be a single path or a comma-separated string -- both forms are documented as valid -- so the helper splits-and-strips before deduping. """ if not values: return set() return { part.strip() for value in values for part in value.split(",") if part.strip() }
[docs] @app.command("validate") def validate_cmd( config: ConfigOption, target_name: TargetOption = None, ) -> None: """Validate a config file without generating anything. Parses and schema-checks ``--config`` using the selected target, then exits. Useful as a pre-commit check or for editor integrations that want fast feedback without running the full pipeline. """ target = _resolve_target(target_name) load_config(config, target.schema, _stdlibs()) typer.echo(f"{config} is valid for target {target.name!r}.")
[docs] @app.command("diff") def diff_cmd( # noqa: PLR0913 config: ConfigOption, out: OutOption = Path(), target_name: TargetOption = None, shadowed: Annotated[ # noqa: FBT002 bool, typer.Option( "--shadowed", "-s", help=( "Show only *shadowed* changes: modified ``skip`` " "files a plain ``generate`` never rewrites, so their " "drift hides until you run ``--force``. By default " "every pending change is shown." ), ), ] = False, check: Annotated[ # noqa: FBT002 bool, typer.Option( "--check", help=( "Exit non-zero if any considered file would change. " "Pairs with a CI step that fails on un-applied drift." ), ), ] = False, navigator: Annotated[ # noqa: FBT002 bool, typer.Option( "--navigate", "-n", help=( "Page through the diffs interactively (requires a " "TTY): j/n/space next, k/p prev, q quit." ), ), ] = False, patch: Annotated[ # noqa: FBT002 bool, typer.Option( "--patch", "-p", help=( "Emit one plain unified-diff patch to stdout -- pipe " "it to a pager, ``git apply``, or ``patch``." ), ), ] = False, name_only: Annotated[ # noqa: FBT002 bool, typer.Option( "--name-only", help="Print only the paths that differ, one per line.", ), ] = False, unchanged: Annotated[ # noqa: FBT002 bool, typer.Option( "--unchanged", help="Include unchanged files in the report.", ), ] = False, context: Annotated[ int, typer.Option( "--context", "-C", help="Unchanged context lines around each diff hunk.", ), ] = 3, ) -> None: """Preview what an apply (``generate``) would change on disk. Generates in memory and compares the result to the files already in *out*, writing nothing. By default it previews *every* pending change, flagging each as one a plain ``generate`` will apply or one that is *shadowed* -- a modified ``skip`` file (e.g. be_root's bootstrap scaffold) that a normal run leaves alone, so its proposed content silently drifts from disk until you run ``--force``. Pass ``--shadowed`` to see only that hidden drift. Renders as a report (default), a raw patch (``--patch``), a name list (``--name-only``), or an interactive navigator (``--navigate``). """ chosen = sum((navigator, patch, name_only)) if chosen > 1: msg = "Pick at most one of --navigate, --patch, --name-only" raise CLIError(msg) target = _resolve_target(target_name) cfg = load_config(config, target.schema, _stdlibs()) files = generate(cfg, target) selected = select_diffs( compute_diffs(files, out), shadowed_only=shadowed, include_unchanged=unchanged, ) if name_only: names = render_names(selected) if names: typer.echo(names) elif patch: typer.echo(render_patch(selected, context=context), nl=False) elif navigator: if not sys.stdout.isatty(): msg = ( "--navigate needs an interactive terminal; use " "--patch to pipe diffs instead" ) raise CLIError(msg) navigate(selected, Console()) else: render_report(selected, Console(), context=context) if check and has_changes(selected): raise typer.Exit(code=1)
[docs] @targets_app.command("list") def targets_list_cmd() -> None: """List every target registered under ``codegen.targets``. Each line is formatted as ``<name> (<language>)`` so users can see at a glance which target to pass to ``--target``. """ targets = discover_targets() if not targets: typer.echo("No targets installed.") return for target in targets: typer.echo(f"{target.name} ({target.language})")
[docs] def cli_main() -> None: """Run the CLI, converting :class:`~codegen.errors.CLIError` cleanly. Any ``CLIError`` raised inside a command is rendered as ``{prefix}: {message}`` on stderr and exits with code 1. Other exceptions propagate with a traceback, because they indicate a bug rather than bad user input. """ try: app() except CLIError as exc: typer.echo(f"{exc.prefix}: {exc}", err=True) sys.exit(1)