Source code for xclif

import inspect
import sys
import types
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, NoReturn, Self

if TYPE_CHECKING:
    from rich.console import Console

from xclif.command import Command, command
from xclif.context import Context, get_context
from xclif.definition import _DefinitionOption
from xclif.importer import get_modules
from xclif.logging import (
    configure_logging,
    f,
    get_logger,
    level_from_verbosity,
    log,
)

__version__ = "0.6.1"

__all__ = [
    "Arg",
    "Cascade",
    "Cli",
    "Context",
    "Option",
    "Path",
    "WithConfig",
    "__version__",
    "command",
    "configure_logging",
    "console",
    "f",
    "get_context",
    "get_logger",
    "level_from_verbosity",
    "log",
]

# A shared Rich console for CLI output. ``soft_wrap=True`` stops Rich from
# inserting its own hard line breaks into long strings (e.g. file paths) — it
# emits each logical line in one piece and lets the terminal wrap at the edge,
# so paths stay selectable and copyable. Use ``console.print(...)`` instead of
# ``rich.print(...)`` for command output.
#
# Built lazily via module ``__getattr__`` so that merely importing ``xclif``
# does not import Rich on the hot path (matching ``logging._make_console``).
_console: "Console | None" = None


def __getattr__(name: str) -> object:
    if name == "console":
        global _console
        if _console is None:
            from rich.console import Console

            _console = Console(soft_wrap=True)
        return _console
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


[docs] @dataclass(frozen=True) class WithConfig: """Marker for parameters that can be read from a config file or env var. ``name: WithConfig[str]`` is sugar for ``Annotated[str, WithConfig()]``. Priority order: CLI flag > env var > config file > default. Env var: ``<PREFIX>_<PARAM_NAME_UPPERCASED>`` Config key: the parameter name as-is. """ def __class_getitem__(cls, item: type) -> type: from typing import Annotated return Annotated[item, cls()]
[docs] @dataclass(frozen=True) class Arg: """Annotation metadata for positional arguments. Use inside ``Annotated`` to attach a description or display name:: def copy(src: Annotated[str, Arg(description="Source file", name="SRC")]) -> None: ... """ description: str | None = None name: str | None = None # display name in help (e.g. "FILE")
[docs] @dataclass(frozen=True) class Cascade: """Annotation metadata to cascade an option to subcommands. Use as a type wrapper to make an option's value available to all subcommands via ``get_context()``:: def root( base_url: Cascade[WithConfig[str]] = DEFAULT_BASE_URL, ) -> None: ... Subcommands can then use ``get_context()["base_url"]``. """ def __class_getitem__(cls, item: type) -> type: from typing import Annotated, get_args, get_origin # If item is already Annotated (e.g. WithConfig[str]), add Cascade() if get_origin(item) is Annotated: args = get_args(item) return Annotated[args[0], *args[1:], cls()] # Plain type — just wrap with Cascade return Annotated[item, cls()]
[docs] @dataclass(frozen=True) class Option: """Annotation metadata for CLI options. Use inside ``Annotated`` to attach a description or override the flag name:: def build(dry_run: Annotated[bool, Option(description="Skip execution", name="dry-run")] = False) -> None: ... ``name`` overrides the CLI flag (``--dry-run``). The Python kwarg name is unchanged. """ description: str | None = None name: str | None = None # CLI flag name override (e.g. "dry-run" → --dry-run)
def _deep_merge(base: dict, override: dict) -> dict: """Recursively merge *override* into *base*, returning a new dict. Values in *override* take priority. Nested dicts are merged recursively; non-dict values in *override* replace values in *base*. """ merged = base.copy() for key, value in override.items(): if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): merged[key] = _deep_merge(merged[key], value) else: merged[key] = value return merged def _load_local_config(filename: str) -> dict: """Load a single config file from the current working directory by name.""" import json path = Path.cwd() / filename if not path.is_file(): return {} suffix = path.suffix.lower() text = path.read_text(encoding="utf-8") if suffix == ".toml": import tomlkit return dict(tomlkit.loads(text)) elif suffix == ".json": return json.loads(text) return {} def _detect_version(package_name: str) -> str | None: """Try to auto-detect the version from installed package metadata.""" import importlib.metadata try: return importlib.metadata.version(package_name) except importlib.metadata.PackageNotFoundError: return None
[docs] @dataclass class Cli: """Entry point for an Xclif-powered CLI application. Typically constructed via :meth:`from_routes` (file-based routing) or :meth:`from_manifest` (pre-compiled manifest for fast startup). Direct construction is only needed when using the flat decorator API. Parameters ---------- root_command: The root :class:`~xclif.command.Command` of the CLI tree. version: Version string shown by ``--version``. Auto-detected from package metadata when *None*. env_prefix: Prefix for environment-variable overrides (e.g. ``"MYAPP"`` → ``MYAPP_FOO``). Defaults to the uppercased root command name. config_name: App name used to locate the config directory via ``platformdirs``. Defaults to the root command name. local_config: Filename to look for in the current working directory as a local config file (e.g. ``".myapp.toml"``). When set, the file is loaded and its values take priority over the user-level config but are still overridden by env vars and CLI flags. Supports ``.toml`` and ``.json`` extensions. *None* (the default) disables local config. config_command: Whether to auto-inject the ``config`` subcommand when any parameter uses :class:`~xclif.config.WithConfig`. *True* (the default) keeps the current behaviour; set to *False* to suppress it. completions_command: Whether to auto-inject the ``completions`` subcommand. *True* (the default) keeps the current behaviour; set to *False* to suppress it. Injection is also skipped when the root command already defines a ``completions`` subcommand or declares positional arguments (since a command with positional args cannot have subcommands). mcp_command: Whether to auto-inject the ``mcp`` subcommand when the optional ``mcp`` package is installed. *True* (the default) keeps the current behaviour; set to *False* to suppress it. Injection is also skipped when the root command already defines an ``mcp`` subcommand or declares positional arguments. logging: Whether Xclif configures standard logging on each dispatch (setting the root logger level from ``--verbose`` and installing its Rich handler). *True* (the default) keeps the built-in behaviour; set to *False* to leave logging entirely to your application — the verbosity count is still available via ``get_context().verbosity``. show_no_description: Default value for ``show_no_description`` on all commands in the tree. When ``False``, suppress the "No description" placeholder in help output for commands without a docstring. When ``None`` (the default), each command uses its own setting (``True`` by default for backward compatibility). Per-command ``@command(show_no_description=...)`` overrides this default. """ root_command: Command version: str | None = None env_prefix: str | None = None config_name: str | None = None local_config: str | None = None config_command: bool = True completions_command: bool = True mcp_command: bool = True logging: bool = True show_no_description: bool | None = None _config_data: dict = field(default_factory=dict, init=False, repr=False) _config_dir: "Path | None" = field(default=None, init=False, repr=False) _finalized: bool = field(default=False, init=False, repr=False) def __post_init__(self) -> None: import platformdirs from xclif.completions import make_completions_command from xclif.config import load_config # Derive defaults from root command name if self.env_prefix is None: self.env_prefix = self.root_command.name.upper() if self.config_name is None: self.config_name = self.root_command.name # Load config file (user-level) self._config_dir = Path(platformdirs.user_config_dir(self.config_name)) self._config_data = load_config(self._config_dir) # Load local config from cwd if configured (overrides user-level) if self.local_config is not None: local_data = _load_local_config(self.local_config) if local_data: self._config_data = _deep_merge(self._config_data, local_data) # Add completions subcommand. Skip when disabled, when the user # already defined a 'completions' subcommand, or when the root command # takes positional arguments (which forbid subcommands entirely). if ( self.completions_command and "completions" not in self.root_command.subcommands and not self.root_command.arguments ): self.root_command.subcommands["completions"] = make_completions_command( self.root_command ) # Inject --version as an implicit option on root command only self.root_command.implicit_options["version"] = _DefinitionOption( "version", bool, "Print program version and exit", ) self.root_command.version = self.version # Add mcp subcommand (only if mcp optional dep is installed). Skip # when the user already defined an 'mcp' subcommand or when the root # command takes positional arguments. if ( self.mcp_command and "mcp" not in self.root_command.subcommands and not self.root_command.arguments ): try: import mcp as _mcp_pkg # noqa: F401 except ImportError: pass # mcp optional dep not installed; subcommand silently absent else: from xclif.mcp import make_mcp_command self.root_command.subcommands["mcp"] = make_mcp_command(self.root_command) def _apply_show_no_description(self, cmd: Command) -> None: """Recursively apply Cli-level show_no_description default to the tree.""" if self.show_no_description is not None: cmd.show_no_description = self.show_no_description seen: set[int] = set() for sub in cmd.subcommands.values(): if id(sub) not in seen: seen.add(id(sub)) self._apply_show_no_description(sub) def _finalize(self) -> None: # """Inject config subcommand and validate WithConfig conflicts. Idempotent.""" if self._finalized: return self._finalized = True # Apply Cli-level show_no_description default self._apply_show_no_description(self.root_command) from xclif.config_commands import _has_with_config, make_config_command from xclif.validation import check_with_config_conflicts # Auto-inject config subcommand if any WithConfig params exist if ( self.config_command and "config" not in self.root_command.subcommands and _has_with_config(self.root_command) ): self.root_command.subcommands["config"] = make_config_command( self._config_dir ) # Validate WithConfig conflicts check_with_config_conflicts(self.root_command, self.env_prefix)
[docs] def serve_mcp(self) -> None: """Start an MCP stdio server exposing all leaf commands as tools. Requires the optional 'mcp' package: pip install xclif[mcp] """ self._finalize() from xclif.mcp import serve_mcp_stdio serve_mcp_stdio(self.root_command)
def __call__(self) -> NoReturn: """Parse ``sys.argv`` and dispatch to the appropriate command, then exit. This is the normal entry point:: if __name__ == "__main__": cli() """ self._finalize() context = { "env_prefix": self.env_prefix, "config_data": self._config_data, "configure_logging": self.logging, } sys.exit(self.root_command.execute(context=context))
[docs] def add_command(self, path: list[str], command: Command) -> None: """Mount *command* at the given path within the command tree. ``path`` is a list of names from the root downward, e.g. ``["server", "start"]`` mounts *command* as ``myapp server start``. Intermediate groups are created automatically if they don't exist. """ cursor = self.root_command for part in path[:-1]: if cursor.arguments: msg = "Cannot add subcommands to a command with arguments" raise ValueError(msg) cursor = cursor.subcommands.setdefault(part, Command(part, lambda: 0)) cursor._assert_no_arguments(adding=command.name) cursor.subcommands[command.name] = command for alias in command.aliases: cursor._assert_no_collision(alias, registering=command.name) cursor.subcommands[alias] = command
[docs] @classmethod def from_manifest( cls, manifest: types.ModuleType, *, version: str | None = None, env_prefix: str | None = None, config_name: str | None = None, local_config: str | None = None, logging: bool = True, show_no_description: bool | None = None, ) -> Self: """Load a pre-compiled manifest produced by ``xclif compile``. This is a faster alternative to :meth:`from_routes` — it skips the ``pkgutil.walk_packages`` + ``inspect.getmembers`` filesystem scan at the cost of a one-time ``xclif compile`` build step. Parameters ---------- manifest: The generated manifest module (typically ``myapp._xclif_manifest``). version: Explicit version string. When *None*, auto-detected from the top-level package of *manifest* (same behaviour as :meth:`from_routes`). local_config: Filename for cwd-based local config. See :class:`Cli`. logging: Whether Xclif configures standard logging on each dispatch. *True* (the default) sets the root logger level from ``--verbose`` and installs a Rich handler; *False* leaves logging entirely to your application (verbosity is still readable via ``get_context()``). """ build_fn = getattr(manifest, "_build_cli", None) if build_fn is None: raise ImportError( f"Manifest module {manifest.__name__!r} has no '_build_cli' function. " "Re-run `python -m xclif compile <routes_module>` to regenerate it." ) if version is None and manifest.__package__: package_name = manifest.__package__.split(".")[0] version = _detect_version(package_name) kwargs = dict( version=version, env_prefix=env_prefix, config_name=config_name, local_config=local_config, show_no_description=show_no_description, ) # `logging` was added to the generated _build_cli later; a manifest built # by an older xclif won't accept it. Only forward it when supported, and # fail loudly if the caller explicitly asked to disable logging. if "logging" in inspect.signature(build_fn).parameters: kwargs["logging"] = logging elif not logging: raise TypeError( f"Manifest module {manifest.__name__!r} predates the 'logging' " "option. Re-run `python -m xclif compile <routes_module>` to " "regenerate it." ) return build_fn(**kwargs)
[docs] @classmethod def from_routes( cls, routes: types.ModuleType, *, version: str | None = None, env_prefix: str | None = None, config_name: str | None = None, local_config: str | None = None, logging: bool = True, show_no_description: bool | None = None, ) -> Self: """Build a :class:`Cli` by walking a routes package at runtime. Each module in *routes* that exports a :class:`~xclif.command.Command` becomes a subcommand. The package structure determines the command hierarchy — see :doc:`routing` for details. Parameters ---------- routes: The routes package module (e.g. ``import myapp.routes as routes``). version: Explicit version string. When *None*, auto-detected from the top-level package of *routes*. env_prefix: Prefix for env-var overrides. Defaults to the uppercased root command name. config_name: App name for config directory resolution. Defaults to the root command name. logging: Whether Xclif configures standard logging on each dispatch. *True* (the default) sets the root logger level from ``--verbose`` and installs a Rich handler; *False* leaves logging entirely to your application (verbosity is still readable via ``get_context()``). .. note:: For production CLIs, prefer :meth:`from_manifest` to avoid the ``pkgutil.walk_packages`` overhead on every invocation. """ members = inspect.getmembers(routes, lambda x: isinstance(x, Command)) if len(members) > 1: msg = f"Multiple commands found in root module ({routes.__name__!r})" raise ValueError(msg) elif len(members) == 0: msg = f"No commands found in root module ({routes.__name__!r})" raise ValueError(msg) if routes.__package__ is None: msg = f"Root module ({routes.__name__!r}) must be part of a package" raise ImportError(msg) # Auto-detect version if not explicitly provided if version is None: package_name = routes.__package__.split(".")[0] version = _detect_version(package_name) root_path = routes.__package__ + "." root_command = members[0][1] if root_command.name is None: msg = "Root command must have a name (it will determine the program name)" raise ValueError(msg) output = cls( root_command=root_command, version=version, env_prefix=env_prefix, config_name=config_name, local_config=local_config, logging=logging, show_no_description=show_no_description, ) for path, module in get_modules(routes): members = inspect.getmembers(module, lambda x: isinstance(x, Command)) if not members: continue if len(members) > 1: msg = f"Multiple commands found in {path!r}" raise ValueError(msg) _name, function = members[0] output.add_command(path.removeprefix(root_path).split("."), function) output._finalize() return output