Source code for foundry.cli

"""Foundry CLI entry point.

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

from __future__ import annotations

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

import typer

from foundry.config import load_config
from foundry.errors import CLIError
from foundry.output import write_files
from foundry.pipeline import generate
from foundry.target import Target, discover_targets

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


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:`~foundry.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 foundry.targets "
            "entry-point group.  Install a plugin (e.g. kiln) "
            "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 kiln + 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( 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, ) -> 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 written = write_files(files, out) typer.echo(f"Generated {written} file(s).")
[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] @targets_app.command("list") def targets_list_cmd() -> None: """List every target registered under ``foundry.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:`CLIError` to a clean exit. 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)