Source code for xclif

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

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, get_logger, level_from_verbosity

__version__ = "0.5.1"

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


[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. 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 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} 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, 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`. """ 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) return build_fn(version=version, env_prefix=env_prefix, config_name=config_name, local_config=local_config, show_no_description=show_no_description)
[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, 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. .. 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, 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