"""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)