"""CLI application for the project.
The ``pun`` command is the single Typer root. Every enabled
component other than the primary (CLI > GUI > TUI > web > MCP > worker) is hung
off it as a lazily-imported subcommand — ``pun interactive``
(TUI), ``pun web``, ``pun mcp``, ... — rather
than a separate ``pun-<name>`` console script (see https://hasansezertasan.github.io/copier-pyproject/context/components/#secondary-components-are-subcommands-of-one-console-root-not-separate-scripts).
"""
# mypy: disable-error-code="misc"
from __future__ import annotations
import contextlib
import importlib
from typing import TYPE_CHECKING
import typer
from pun.__metadata__ import PROJECT_NAME
from pun.core import app as service
from pun.core.logging_setup import get_logger
if TYPE_CHECKING:
from collections.abc import Generator
__all__ = ["app", "info", "interactive", "show_version"]
logger = get_logger()
# Every component's runtime dependency is a core ``dependency`` of this package
# (there are no per-component extras), so a missing one never means "install the
# extra" -- it means this environment is out of sync with the installed metadata.
# That happens after a ``copier update`` enables a component, or in a stale venv.
_MISSING_DEPENDENCY = "Error: The {component} component requires '{missing}', which is not available in this environment. {hint}" # noqa: E501
_SYNC_HINT = f"Component dependencies ship with '{PROJECT_NAME}', so this usually means your environment is out of sync -- run `uv sync` (or reinstall the package) and try again." # noqa: E501
_TUI_DEPENDENCIES = ("textual", "typing_extensions")
def _preflight(module: str) -> None:
"""Import a component dependency eagerly, so the guard below can classify it.
Two kinds of dependency never reach the guard in a usable form. ``uvicorn``
and Tkinter are imported at *call* time -- after the guarded block has closed
-- and ``faststream.<broker>`` is a guarded re-export that raises a plain,
*nameless* ``ImportError`` when the broker extra is absent, which exact-name
matching cannot classify. Importing here, and supplying the module name when
the interpreter does not, puts both back in reach of the guard.
Args:
module: Import module name to import eagerly.
Raises:
ModuleNotFoundError: Naming ``module`` when it cannot be imported.
"""
try:
_ = importlib.import_module(module)
except ModuleNotFoundError as exc:
# A nested failure belongs to the dependency being preflighted: a
# partially installed uvicorn or web framework may name a sub-dependency
# like ``click`` or ``starlette``, but the launcher's allowlist
# intentionally knows only its direct dependencies.
raise ModuleNotFoundError(str(exc), name=module) from None
except ImportError as exc:
# Not just "missing": a module whose own import raises is unusable here
# too, and ``uv sync`` is the same repair. This is also the only shape
# faststream's nameless broker-extra ``ImportError`` arrives in.
raise ModuleNotFoundError(str(exc), name=module) from None
@contextlib.contextmanager
def _component_dependencies(
component: str, *dependencies: str, hint: str = _SYNC_HINT
) -> Generator[None, None, None]:
"""Translate a missing component dependency into an actionable CLI error.
Wraps a launcher command's lazy import so a ``ModuleNotFoundError`` for a
known dependency becomes a message naming the component, missing module, and
fix. Dependencies the component imports later than this block are imported
eagerly by ``_preflight`` above instead.
Args:
component: Human name of the component being launched (e.g. ``"web"``).
*dependencies: Import module names owned by the component, matched
exactly.
hint: Remediation sentence appended to the error. Defaults to the
out-of-sync-environment ``uv sync`` hint, which is right for every
component whose dependency is a real distribution.
Yields:
``None``; the caller performs the import inside the block.
Raises:
ModuleNotFoundError: Propagating a missing module that is not exactly one
of the component's known dependencies.
typer.Exit: With code 1 if a dependency of ``component`` is missing.
"""
try:
yield
except ModuleNotFoundError as exc:
missing = exc.name
# Exact matches only. A failure *below* an installed dependency's
# namespace -- a typo'd ``from <dep>.user_plugin import X`` in a
# customized component -- is an application defect, not a stale
# environment, and suppressing its traceback to recommend ``uv sync``
# would send the reader down the wrong path. A genuine missing dependency
# reaching *this* block always names a root, because the component
# imports it as ``from <dep> import X`` at module scope; the awkward
# cases (deferred and guarded imports) go through ``_preflight``.
if missing is None or missing not in dependencies:
raise
# An out-of-sync environment is an expected, user-facing error, so log
# without the traceback that logging.exception would add.
logger.error( # noqa: TRY400
"Missing dependency %r for the %s component", missing, component
)
typer.echo(
_MISSING_DEPENDENCY.format(component=component, missing=missing, hint=hint),
err=True,
)
raise typer.Exit(code=1) from None
app = typer.Typer(name="pun", no_args_is_help=True)
[docs]
@app.command(name="version")
def show_version() -> None:
"""Show the current version number of pun.
Show the version number:
pun version
Example output:
0.1.0
Raises:
typer.Exit: If the package metadata cannot be found.
"""
try:
resolved = service.version()
except service.MetadataUnavailableError:
# An uninstalled or partial package is an expected, user-facing error, so
# log without the traceback that logging.exception would add.
logger.error("Package metadata not found for %s", PROJECT_NAME) # noqa: TRY400
typer.echo(
f"Error: Package '{PROJECT_NAME}' metadata not found. Is the package installed correctly?", # noqa: E501
err=True,
)
raise typer.Exit(code=1) from None
logger.info("Command `version` called.")
typer.echo(resolved)
logger.info("Version displayed successfully.")
[docs]
@app.command()
def info() -> None:
"""Display information about the pun application.
Show application information:
pun info
Example output:
Application Version: 0.1.0
Python Version: 3.12.0 (CPython)
Platform: Darwin
Raises:
typer.Exit: If the package metadata cannot be found.
"""
try:
payload = service.info()
except service.MetadataUnavailableError:
# An uninstalled or partial package is an expected, user-facing error, so
# log without the traceback that logging.exception would add.
logger.error("Package metadata not found for %s", PROJECT_NAME) # noqa: TRY400
typer.echo(
f"Error: Package '{PROJECT_NAME}' metadata not found. Is the package installed correctly?", # noqa: E501
err=True,
)
raise typer.Exit(code=1) from None
logger.info("Command `info` called.")
python = f"{payload['python_version']} ({payload['python_implementation']})"
typer.echo(f"Application Version: {payload['application_version']}")
typer.echo(f"Python Version: {python}")
typer.echo(f"Platform: {payload['platform']}")
logger.info("Application information displayed successfully.")
[docs]
@app.command()
def interactive() -> None:
"""Start interactive mode (TUI) for pun.
Launch the terminal user interface:
pun interactive
Raises:
typer.Exit: Propagating the TUI's exit code, or code 1 if a dependency of
the TUI is missing from this environment.
"""
with _component_dependencies("TUI", *_TUI_DEPENDENCIES):
_preflight("textual")
from pun.tui.app import main # noqa: PLC0415
raise typer.Exit(code=main())