Source code for click_extra.cli_wrapper

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""The `click-extra wrap` command and the machinery to wrap a foreign Click CLI.

Monkey-patches Click's decorator functions before importing (or running) a
target module so its `@click.command()` / `@click.group()` produce
colorized, keyword-highlighted, themed variants. Also resolves and invokes the
target, and introspects it for `--params`, `--man`, `--tree` and `--help-format`
without firing its callbacks.

Not to be confused with text wrapping: that is {func}`click.wrap_text`, exposed
at the package root as `click_extra.wrap_text`.
"""

from __future__ import annotations

import importlib
import importlib.util
import logging
import runpy
import shlex
import sys
from pathlib import Path

import click
import cloup
from click.core import ParameterSource
from click.utils import make_str

from . import context
from ._utils import console_script
from .carapace import (
    dump_carapace_spec,
    install_carapace_spec,
    write_carapace_spec,
)
from .command_doc import (
    INSTALLABLE_FORMATS,
    HelpFormat,
    install_manpages,
    read_manpage,
    render_help,
    write_manpages,
)
from .commands import ColorizedCommand, ColorizedGroup, Context, LazyGroup
from .config.flags import config_table_to_flags
from .decorators import columns_option, option
from .highlight import HelpFormatter, HelpKeywords, _HelpColorsMixin
from .param_tools import child_context, make_resilient_context
from .project import resolve_entry_point
from .show_params import ShowParamsOption, render_params_table
from .table import TableFormat
from .table_options import TableFormatOption
from .theme import (
    HelpTheme,
    get_current_theme,
    get_default_theme,
    set_default_theme,
)
from .tree import render_command_tree
from .types import EnumChoice

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable
    from types import ModuleType
    from typing import Any

logger = logging.getLogger(__name__)

# Save pristine references before any patching occurs.
_original_click_command = click.decorators.command
_original_click_group = click.decorators.group
_original_get_help = click.Command.get_help
_original_format_help = click.Command.format_help

_theme_before_patch: HelpTheme | None = None
"""The process-wide default theme a {func}`patch_click` call replaced.

`None` while no call replaced it: {func}`unpatch_click` then keeps the default
as it is.
"""


[docs] class WrapperGroup(LazyGroup): """Group that falls back to the `wrap` subcommand for unknown names. Known subcommands and their aliases are dispatched normally, the lazy ones included. Anything else is treated as a target script and forwarded to `wrap`. """
[docs] def resolve_command( self, ctx: click.Context, args: list[str], ) -> tuple[str | None, click.Command | None, list[str]]: cmd_name = make_str(args[0]) # Cloup's resolve_command_name handles both direct names and aliases, but # knows only the subcommands registered so far: a lazy one not imported # yet answers to its name alone. resolved = super().resolve_command_name(ctx, cmd_name) if resolved is None and cmd_name in self.lazy_subcommands: resolved = cmd_name if resolved is not None: cmd = self.get_command(ctx, resolved) if cmd is not None: return cmd_name, cmd, args[1:] # Unknown name: delegate the entire arg list to `wrap`. wrap_cmd = self.get_command(ctx, "wrap") if wrap_cmd is not None: return "wrap", wrap_cmd, args return super().resolve_command(ctx, args)
def _make_patched_decorator( original: Callable[..., Any], default_cls: type[click.Command], ) -> Callable[..., Any]: """Build a drop-in replacement for `click.command` / `click.group`. The returned wrapper defaults its `cls` to `default_cls` (a colorized variant) while forwarding to `original`. It handles both the parenthesized form (`@command(...)`) and the bare form (`@command`), where the decorated function arrives as the first positional `name` argument. """ def _patched(name=None, cls=None, **attrs): cls = cls or default_cls # Handle bare usage (no parentheses): the decorated function is passed as # the first positional argument. if callable(name): return original(cls=cls, **attrs)(name) return original(name=name, cls=cls, **attrs) return _patched
[docs] def patch_click( theme: HelpTheme | None = None, color: bool | None = True, ) -> None: """Replace Click's decorator functions with colorized variants. Must be called before importing the target CLI module so that `@click.command()` and `@click.group()` decorators produce colorized commands. ```{note} Only the decorator functions are replaced, not the class names (`click.Command`, `click.Group`). Replacing class names would break `isinstance` and `issubclass` checks in Click internals (`_param_memo`) and Cloup's decorator validators. ``` ```{note} A decided *color* (anything but `None`) is pinned in two places, because the two kinds of target are colorized by different routes. One built with a plain `@click.command()` picks up the patched decorator and therefore *is* a `_HelpColorsMixin`: the context class installed below carries the decision for it. One carrying an explicit `cls=` (like Flask's `FlaskGroup`) keeps its own class and a stock `click.Context`, and is served by the `get_help` patch further down instead. Cover a single route and the flag silently works for half the CLIs in the wild. ``` :param theme: Color theme to use, as the process-wide default. `None` keeps the current default. {func}`unpatch_click` gives back the default this replaced. :param color: Tri-state ANSI control mirroring `ctx.color`: `True` forces colors on, `False` strips them, and `None` (the GNU `auto` default) defers to the output stream's TTY status. """ global _theme_before_patch if color is None: ColorizedCommand.context_class = Context ColorizedGroup.context_class = Context else: class _PinnedColorContext(Context): """Context variant pinning the caller's colorization decision.""" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) # Pin the decision on a root context, overriding the auto default # Context would otherwise resolve from the environment. if not self.parent: self.color = color ColorizedCommand.context_class = _PinnedColorContext ColorizedGroup.context_class = _PinnedColorContext _patched_command_func = _make_patched_decorator( _original_click_command, ColorizedCommand ) _patched_group_func = _make_patched_decorator(_original_click_group, ColorizedGroup) # Replace decorator functions in both namespaces so both `click.command` # and `from click.decorators import command` resolve to the wrappers. click.command = _patched_command_func click.group = _patched_group_func click.decorators.command = _patched_command_func click.decorators.group = _patched_group_func logger.debug("Patched click.command and click.group decorators.") # Patch Command methods to colorize ALL commands, including those with # explicit `cls=` (like Flask's `FlaskGroup`). Commands that already # have `_HelpColorsMixin` skip this path to avoid double-processing: the # context class installed above is what carries the color decision to them. def _patched_get_help(self, ctx): if not isinstance(self, _HelpColorsMixin): ctx.formatter_class = HelpFormatter if not ctx.parent and ctx.color is None: ctx.color = color return _original_get_help(self, ctx) def _patched_format_help(self, ctx, formatter): if isinstance(formatter, HelpFormatter) and not isinstance( self, _HelpColorsMixin ): logger.debug( "Collecting keywords for %s (%s).", type(self).__name__, self.name, ) # collect_keywords() works on any command: its static methods are # class-qualified and it reads extra_keywords with a default. formatter.keywords = _HelpColorsMixin.collect_keywords(self, ctx) formatter.excluded_keywords = _HelpColorsMixin._collect_excluded_keywords( ctx ) _original_format_help(self, ctx, formatter) click.Command.get_help = _patched_get_help # type: ignore[method-assign] click.Command.format_help = _patched_format_help # type: ignore[method-assign] logger.debug("Patched click.Command.get_help and format_help methods.") # Override the default theme if requested. Only the first override saves the # default it replaces: unpatch_click() goes back to the state before any patch. if theme is not None: if _theme_before_patch is None: _theme_before_patch = get_default_theme() set_default_theme(theme) logger.info( "Click patched: color=%s, theme=%s.", color, type(theme).__name__ if theme is not None else "default", )
[docs] def unpatch_click() -> None: """Restore Click's original decorator functions and methods. Reverses the changes made by {func}`patch_click`. Useful in tests to avoid leaking global state between test cases. The process-wide default theme goes back to the one in place before the first {func}`patch_click` call that set a theme. If no call set one, the default stays as it is, so a {func}`~click_extra.theme.set_default_theme` call made while patched is kept. """ global _theme_before_patch click.command = _original_click_command click.group = _original_click_group click.decorators.command = _original_click_command click.decorators.group = _original_click_group click.Command.get_help = _original_get_help # type: ignore[method-assign] click.Command.format_help = _original_format_help # type: ignore[method-assign] ColorizedCommand.context_class = Context ColorizedGroup.context_class = Context if _theme_before_patch is not None: set_default_theme(_theme_before_patch) _theme_before_patch = None
def _resolve_project_dir(directory: Path) -> tuple[str, str, Path]: """Resolve a local project directory to an importable target. Reads the project's console-script entry point from its packaging metadata, with {func}`~click_extra.project.resolve_entry_point`, which also locates the top-level package on disk, so it can be imported without an install step. This lets `wrap` target a checked-out project by its directory, the way an editable install plus its console script would. :return: `(module_path, function_name, syspath_entry)`. *syspath_entry* is the directory to prepend to `sys.path` so *module_path* resolves. :raises click.ClickException: when no entry point can be determined, or the package backing it cannot be found on disk. """ try: entry_point = resolve_entry_point(directory) except ValueError as error: raise click.ClickException( f"{error} Pass the target explicitly as module:function." ) from error if entry_point.root is None: raise click.ClickException( f"Found entry point {entry_point.target!r} in {directory}, but its " f"top-level package {entry_point.package!r} is not under {directory} " f"or {directory / 'src'}." ) return entry_point.module, entry_point.function, entry_point.root
[docs] def resolve_target(script: str) -> tuple[str, str]: """Resolve a script name to a module path and function name. Resolution order: 1. `console_scripts` entry points from installed packages. 2. A local project directory: its `console_scripts` entry point is read from `pyproject.toml` / `setup.cfg` and its package is added to `sys.path`. 3. `.py` file path. 4. Explicit `module:function` notation. 5. Bare Python module or package name. :return: `(module_path, function_name)` tuple. *function_name* is empty when the target should be invoked as a module or script file. :raises click.ClickException: If the script cannot be resolved. ```{note} Resolving a local project directory has a side effect: the directory holding its top-level package is prepended to `sys.path` so the subsequent import succeeds. The target's own dependencies must still be importable in the current environment. ``` """ logger.debug("Resolving target %r.", script) # 1. Console scripts entry points. entry_point = console_script(script) if entry_point is not None: logger.info( "Resolved %r as console_scripts entry point: %s:%s.", script, entry_point.module, entry_point.attr, ) return entry_point.module, entry_point.attr # 2. Local project directory: discover its console-script entry point and # make its package importable. Checked before the path/module heuristics so # a real directory on disk is never mistaken for module:function notation or # a bare module name. script_path = Path(script) if script and script_path.is_dir(): module_path, function_name, syspath_entry = _resolve_project_dir(script_path) entry_str = str(syspath_entry.resolve()) if entry_str not in sys.path: sys.path.insert(0, entry_str) logger.debug("Prepended %r to sys.path.", entry_str) logger.info( "Resolved %r as local project: %s:%s.", script, module_path, function_name, ) return module_path, function_name # 3. .py file path. Checked before the module:function heuristic so that # Windows absolute paths (like `C:\...\foo.py`) are not mistaken for # `module:function` notation: the drive-letter colon would otherwise # split the path at the wrong position. A .py path that does not exist # falls through to the bare module lookup, skipping the module:function # check: a .py name is never valid module:function notation. if script.endswith(".py"): if script_path.is_file(): logger.info("Resolved %r as .py file.", script) return script, "" # 4. Explicit module:function notation. elif ":" in script: module_path, function_name = script.rsplit(":", 1) logger.info( "Resolved %r as module:function: %s:%s.", script, module_path, function_name, ) return module_path, function_name # 5. Bare module name. Check existence without importing so the module # is not loaded before patch_click() runs. try: spec = importlib.util.find_spec(script) except (ModuleNotFoundError, ValueError): spec = None if spec is not None: logger.info("Resolved %r as Python module.", script) return script, "" msg = ( f"Cannot resolve {script!r} as a console_scripts entry point, " f"module:function, .py file, or Python module." ) raise click.ClickException(msg)
[docs] def invoke_target( script: str, module_path: str, function_name: str, args: tuple[str, ...], ) -> None: """Import and call the target CLI. Reconstructs `sys.argv` so Click's argument parsing sees the target's program name and arguments, and pins `__main__.__package__` so the program name Click *detects* is that same script name. ```{note} A target whose Click command is invoked without an explicit `prog_name` falls back to `click.utils._detect_program_name()`, which reads `__main__.__package__`. That attribute states how *click-extra itself* was launched: under a documentation build running `python -m sphinx`, a wrapped usage line would read `python -m sphinx.flask`, and under `python -m click_extra` it would read `python -m click_extra.flask`. Emptying it for the call pins the detection on its file-execution branch, which answers the basename of `sys.argv[0]`: the script name set above. ``` :param script: Original script name (used as `sys.argv[0]`). :param module_path: Dotted module path or `.py` file path. :param function_name: Function to call, or empty for module execution. :param args: Arguments to pass to the target CLI. """ logger.info( "Invoking target: script=%r, module=%r, function=%r, args=%r.", script, module_path, function_name, args, ) original_argv = sys.argv main_module = sys.modules["__main__"] original_package = getattr(main_module, "__package__", None) try: sys.argv = [script, *args] # Hide how click-extra itself was launched, so a target detecting its # own program name lands on the script above, not on the launcher. main_module.__package__ = "" if function_name: logger.debug("Importing %s and calling %s().", module_path, function_name) mod = importlib.import_module(module_path) func = getattr(mod, function_name) func() elif module_path.endswith(".py"): logger.debug("Running %s as script.", module_path) runpy.run_path(module_path, run_name="__main__") else: logger.debug("Running %s as module.", module_path) runpy.run_module(module_path, run_name="__main__") finally: sys.argv = original_argv main_module.__package__ = original_package
def _scan_for_command(mod: ModuleType, module_path: str) -> click.Command: """The one Click command a module defines at its top level, groups first. An entry point can be a plain function wrapping the command, so the module's namespace is scanned instead: a single group wins, then a single command. :raises click.ClickException: when the module defines several candidates of the winning kind, or none at all. """ groups: dict[str, click.Command] = {} commands: dict[str, click.Command] = {} for attr_name in dir(mod): obj = getattr(mod, attr_name, None) if isinstance(obj, click.Group): groups[attr_name] = obj elif isinstance(obj, click.Command): commands[attr_name] = obj for kind, found in (("command groups", groups), ("commands", commands)): if len(found) == 1: return next(iter(found.values())) if found: raise click.ClickException( f"Multiple {kind} in {module_path}: {', '.join(sorted(found))}. " "Specify the correct one with module:name notation." ) raise click.ClickException( f"No Click command found in {module_path}. If its entry point imports " "the command lazily, point at the module that defines it with " "module:function notation (for example, mypackage.cli:cli)." ) def _root_context(command: click.Command, script: str) -> click.Context: """Build the root context of `command` without parsing any argument. Built from the command's own `context_class` and `context_settings`, as {meth}`click.Command.make_context` builds the context of a run, so introspection reads what a run reads: the `auto_envvar_prefix`, the help option spellings, the `default_map` and the subcommand order. A plain `click.Context` would not do: a Cloup command's `context_settings` carry Cloup-only keys (`align_option_groups`, `show_constraints`, …) that it rejects with a `TypeError`. The root counterpart of {func}`~click_extra.param_tools.child_context`. :param command: the command to build the context of. :param script: the name the command answers to when it has none of its own. :return: the root context, with no argument parsed. """ return command.context_class( command, info_name=command.name or script, **command.context_settings )
[docs] def resolve_target_command( script: str, subcommands: tuple[str, ...] = (), ) -> tuple[click.Command, click.Context]: """Import SCRIPT and return its Click command object and a matching context. Resolves SCRIPT through {func}`resolve_target`, imports the module, then obtains the command object without running the CLI: the entry-point attribute when it is itself a command, otherwise by scanning the module's namespace for Click command instances (preferring groups). Optional `subcommands` navigate into nested groups, mirroring the path a user would type. Shared by the introspection modes of the `wrap` command (`--params`, `--man`, `--tree` and `--help-format`) and by the man page hook of the Sphinx extension, so all describe the exact same resolved command. :raises click.ClickException: when no unambiguous Click command can be found, or a requested subcommand does not exist. """ module_path, function_name = resolve_target(script) if module_path.endswith(".py"): # Load the file as a module. exec_module runs it under a synthetic # name, so the target's `if __name__ == "__main__"` guard does not # fire: the command objects are defined, not executed. spec = importlib.util.spec_from_file_location( "_click_extra_target", module_path ) if spec is None or spec.loader is None: raise click.ClickException(f"Cannot load {module_path!r} as a module.") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) else: mod = importlib.import_module(module_path) cli_obj = getattr(mod, function_name) if function_name else None cmd = ( cli_obj if isinstance(cli_obj, click.Command) else _scan_for_command(mod, module_path) ) # Each context is built from its command's own `context_settings`, so the # environment variables introspection names are the ones a run reads. Click # applies these only through `make_context`, which would also parse # arguments. cmd_ctx = _root_context(cmd, script) for sub in subcommands: if not isinstance(cmd, click.Group): raise click.ClickException( f"{cmd.name!r} is not a group; cannot navigate to {sub!r}." ) child = cmd.get_command(cmd_ctx, sub) if child is None: raise click.ClickException(f"No subcommand {sub!r} in {cmd.name!r}.") cmd_ctx = child_context(child, cmd_ctx, sub) cmd = child return cmd, cmd_ctx
class _WrapCommand(_HelpColorsMixin, cloup.Command): # type: ignore[misc] """Cloup Command for the `wrap` subcommand. Uses Cloup (not vanilla Click) to support aliases. Like {class}`~click_extra.commands.ColorizedCommand` but based on `cloup.Command`. ```{note} This extends `cloup.Command` instead of {class}`~click_extra.commands.Command`, so it does **not** inherit the full {func}`~click_extra.commands.default_params` set. `wrap` carries only the options that act on the *target* CLI rather than on click-extra itself: - **Action flags** (`--params`, `--man`, `--tree`, `--help-format`) describe the target and exit without running it. Their group-level twins (`click-extra --params`) introspect the `click-extra` CLI itself, so they cannot reach a wrapped foreign command: the subject differs, which is why these are *not* redundant with the group versions. They route through the same rendering cores as the group-level options ({func}`~click_extra.show_params.render_params_table`, {func}`~click_extra.command_doc.read_manpage`, {func}`~click_extra.tree.render_command_tree`, {func}`~click_extra.command_doc.render_help`), so a new introspection feature only has to add one option here, never a parallel subcommand. - **Modifiers** (`--table-format`, `--columns`, `--output-dir`, `--install`) shape the action output and are inert in the default run mode. Presentation flags that style the *wrapping* (`--color`, `--theme`, `--verbosity`) stay on the parent {class}`WrapperGroup` and are inherited through the context, so they are deliberately absent here. ``` """ context_class: type[cloup.Context] = Context #: Default columns for the standalone `wrap --params`: the full registry #: minus `allowed_in_conf`, which only a click-extra `--config` option can #: populate and a foreign CLI therefore always leaves empty. _FOREIGN_PARAM_COLUMNS: tuple[str, ...] = tuple( col_id for col_id in ShowParamsOption.default_column_ids() if col_id != "allowed_in_conf" ) def _split_navigation(args: tuple[str, ...]) -> tuple[tuple[str, ...], tuple[str, ...]]: """Split target arguments into subcommand navigation and replay arguments. Leading tokens that do not look like options are treated as subcommand names to descend into; everything from the first option-like token onward is returned separately, to be replayed against the resolved command so the `value` and `source` columns reflect those arguments. `("run", "--port", "8080")` splits into `(("run",), ("--port", "8080"))`. """ split = next( (index for index, arg in enumerate(args) if arg.startswith("-")), len(args) ) return args[:split], args[split:] def _wrap_params( ctx: click.Context, script: str, nav: tuple[str, ...], target_args: tuple[str, ...], table_format: TableFormat, ) -> None: """Resolve a foreign target and print its parameter table. Re-roots the parameter walk at the resolved (sub)command while preserving the `auto_envvar_prefix` computed along the navigation path, then defers to the shared {func}`~click_extra.show_params.render_params_table` core. The table format resolves in priority order: the `wrap --table-format` flag when given explicitly, then the click-extra group's `--table-format` (shared through the context), then the default. """ cmd, drill_ctx = resolve_target_command(script, nav) # `render_params_table` walks from `subject_ctx.command` downward, so a # fresh root context scopes the table to the resolved node (matching what # the user navigated to). The drilled context already carries the nested # envvar prefix, so copy it over. subject_ctx = _root_context(cmd, script) subject_ctx.auto_envvar_prefix = drill_ctx.auto_envvar_prefix # An explicit `wrap --table-format` wins; otherwise defer to the group's # value (threaded through the shared context meta), then the default. if ctx.get_parameter_source("table_format") == ParameterSource.DEFAULT: resolved_format = context.get(ctx, context.TABLE_FORMAT) or table_format else: resolved_format = table_format context.set(subject_ctx, context.TABLE_FORMAT, resolved_format) selected_columns = context.get(ctx, context.COLUMNS) if selected_columns: context.set(subject_ctx, context.COLUMNS, selected_columns) if target_args: context.set(subject_ctx, context.RAW_ARGS, list(target_args)) render_params_table(subject_ctx, default_columns=_FOREIGN_PARAM_COLUMNS)
[docs] def target_prog_name(script: str, command: click.Command) -> str: """Return the name a user would type to run SCRIPT. Every rendering of a target is titled with this: a man page's `.TH` line, a Carapace spec's `name`, a Markdown heading, the root of a `--tree`. None of them wants the string that was typed to *reach* the command, which is a filesystem path or a dotted import path as often as it is a command name. Three shapes, in the order they are distinguished: 1. A path (a separator, or a `.py` suffix): its basename, dropping that suffix. `path/to/my_cli.py` is run as `my_cli`, not as its own path. 2. A dotted or colon-separated import path: the command's own name, which Click took from the function or an explicit `name=`. An import path names a module, never a binary. 3. Anything else is a console-script name, already exactly what a user types, and beats the command's own name when the two differ (Flask's entry point is `flask`, its group object is named `cli`). ```{caution} Case 2 is a best effort. A target reached as `python -m my_package.cli` has no single name a user types, so the command's own is the closest thing to one. Pass an explicit `prog_name` to the underlying renderer when that is not what you want in the output. ``` """ if script.endswith(".py"): return Path(script).stem if "/" in script or "\\" in script: return Path(script).name if ":" in script or "." in script: return command.name or script return script
def _wrap_man(script: str, nav: tuple[str, ...]) -> None: """Resolve a foreign target and read its manual page. Typesets and pages it, exactly as a CLI's own `--man` does, so a manual can be read for a CLI that ships none. The roff source a packager wants is `--help-format man`, which also takes `--output-dir` and `--install`. """ cmd, _ = resolve_target_command(script, nav) read_manpage(cmd) def _wrap_help_format( ctx: click.Context, script: str, nav: tuple[str, ...], help_format: HelpFormat, output_dir: Path | None, install: bool, ) -> None: """Resolve a foreign target and render it in one of the {class}`HelpFormat` formats. The target needs no cooperation for this: it is loaded, walked and described from the outside, like `--params` and `--man`. A CLI whose author never heard of Click Extra is therefore as machine-readable as one that ships `--help-format` itself. Stdout is the default destination. `output_dir` and `install` redirect it for the two formats that exist to be *installed* somewhere a consumer reads them from: a man page under a `man` directory, a Carapace spec under Carapace's. The other formats are documents, with no such place to be put, and both flags are refused for them by the caller. Each format's own module owns where it installs, and resolves that directory at call time, so an XDG override set for one invocation is honored. """ cmd, _ = resolve_target_command(script, nav) if help_format is HelpFormat.CARAPACE: # A spec is keyed on the binary a shell completes, not on the path typed # to reach it (see render_help). The reconstructed wrap command goes in # the header, rebuilt from the context rather than sys.argv so it is also # correct under CliRunner, in tests and Sphinx. prog_name = nav[-1] if nav else target_prog_name(script, cmd) parts = [*ctx.command_path.split(), "--help-format", "carapace"] if install: parts.append("--install") parts.extend(("--", script, *nav)) invocation = shlex.join(parts) if output_dir: written = write_carapace_spec( cmd, output_dir / f"{prog_name}.yaml", prog_name=prog_name, invocation=invocation, ) elif install: written = install_carapace_spec( cmd, prog_name=prog_name, invocation=invocation ) else: click.echo( dump_carapace_spec(cmd, prog_name=prog_name, invocation=invocation) ) return click.echo(str(written)) return if help_format is HelpFormat.MAN and (output_dir or install): if nav: raise click.ClickException( "Writing man pages always emits the full tree rooted at SCRIPT " "and cannot be combined with extra SUBCOMMAND arguments. To " "write a single subcommand page, drop --output-dir/--install and " "redirect stdout into a .1 file instead." ) prog_name = target_prog_name(script, cmd) written_pages = ( write_manpages(cmd, output_dir, prog_name=prog_name) if output_dir else install_manpages(cmd, prog_name=prog_name) ) for path in written_pages: click.echo(str(path)) return prog_name = " ".join((target_prog_name(script, cmd), *nav)) click.echo(render_help(cmd, help_format, prog_name=prog_name), color=False) def _wrap_tree( ctx: click.Context, script: str, nav: tuple[str, ...], ) -> None: """Resolve a foreign target and print its subcommand tree. Roots the tree at the resolved (sub)command, labeled with the navigation path the user typed, and defers to the shared {func}`~click_extra.tree.render_command_tree` core. Accessibility mode is carried over from the wrapping context so the rail degrades to ASCII (the resolved target gets a fresh root context whose `meta` would otherwise lose the {data}`~click_extra.context.ACCESSIBLE` entry). """ cmd, _ = resolve_target_command(script, nav) prog_name = " ".join((target_prog_name(script, cmd), *nav)) subject_ctx = make_resilient_context(cmd, prog_name) if context.get(ctx, context.ACCESSIBLE, False): context.set(subject_ctx, context.ACCESSIBLE, True) click.echo(render_command_tree(cmd, ctx=subject_ctx), color=ctx.color) def _config_args_for_target( ctx: click.Context, script: str, ) -> tuple[str, ...]: """Read the `[wrap.<script>]` config section and convert to CLI args. Looks for a config section named after the target script under `wrap`. For example, `click-extra wrap -- flask` reads `[tool.click-extra.wrap.flask]` in `pyproject.toml`. All keys in that section are converted to CLI arguments through {func}`~click_extra.config.flags.config_table_to_flags`, after their underscores become dashes, and prepended to the target's invocation. Invalid options are naturally caught by the target CLI itself, producing standard Click error messages like "No such option: --foo". ```{code-block} toml [tool.click-extra.wrap.flask] app = "myapp:create_app" debug = true ``` """ # `ctx.meta` is shared across the parent/child hierarchy, so reading # from the local context is sufficient. The root command's name is still # needed below to locate the right TOML section. full_conf = context.get(ctx, context.CONF_FULL) if not full_conf: return () # Extract the [click-extra.wrap.<script>] section from the raw config. app_name = ctx.find_root().command.name or "" # Normalize path separators to forward slashes so Windows absolute paths # like "C:\...\script.py" match TOML keys written as "C:/.../script.py". # TOML basic strings interpret backslashes as escape sequences, so users # must use forward slashes in their pyproject.toml keys. normalized_script = script.replace("\\", "/") target_section = ( full_conf.get(app_name, {}).get("wrap", {}).get(normalized_script, {}) ) if not target_section or not isinstance(target_section, dict): return () return tuple( config_table_to_flags({ key.replace("_", "-"): value for key, value in target_section.items() }) ) #: Sample invocations closing the `wrap` help screen. WRAP_EPILOG = """\b Examples: \b Run a CLI with Click Extra's colored help: $ click-extra wrap -- my-cli --help \b Read the help of one of its subcommands: $ click-extra wrap -- my-cli pick --help \b Report what a subcommand accepts, and where each value comes from: $ click-extra wrap --params -- my-cli pick \b Write a shell-completion spec where its consumer looks for it: $ click-extra wrap --help-format carapace --install -- my-cli """ @click.command( name="wrap", aliases=["run"], cls=_WrapCommand, context_settings={"allow_interspersed_args": False}, epilog=WRAP_EPILOG, ) @option( "--params", is_flag=True, default=False, help="Show the parameters of the target CLI and exit, without running it.", ) @option( "--man", is_flag=True, default=False, help="Read the manual page of the target CLI and exit, without running it.", ) @option( "--tree", is_flag=True, default=False, help="Show the tree of nested subcommands of the target CLI and exit, " "without running it.", ) @option( "--help-format", type=EnumChoice(HelpFormat), default=None, help="Render the target CLI in the given format and exit, without running it.", ) @option( "--output-dir", type=click.Path(file_okay=False, dir_okay=True, writable=True, path_type=Path), default=None, help="With --help-format, write the rendering into this directory instead of " "printing it. Created if missing.", ) @option( "--install", is_flag=True, default=False, help="With --help-format, write the rendering where its consumer looks for it " "instead of printing it, and echo the written path.", ) @option( # Reuses the canonical option, so its name, type, metavar and default are # declared once. This copy exposes its value and drops the class callback: # `_wrap_params` resolves the format against the group's pick and sets it on # the target's own context, which the callback would preempt. cls=TableFormatOption, expose_value=True, is_eager=False, callback=None, help="With --params, the rendering style of the parameter table. " "Falls back to the click-extra group's --table-format when not set here.", ) @columns_option(columns=ShowParamsOption.TABLE_HEADERS) @click.argument( "script_and_args", nargs=-1, type=click.UNPROCESSED, metavar="SCRIPT [ARGS]...", ) @click.pass_context def wrap( ctx: click.Context, script_and_args: tuple[str, ...], params: bool, man: bool, tree: bool, help_format: HelpFormat | None, output_dir: Path | None, install: bool, table_format: TableFormat, ) -> None: """Run, or introspect, any Click CLI through Click Extra. By default, runs SCRIPT with keyword highlighting and themed styling for its help screens. The target CLI is not modified. With --params, --man, --tree or --help-format, SCRIPT is loaded and described without being run. The first three answer a question a person is asking right now; --help-format renders the target as an artifact for a program or a build step. Extra arguments after SCRIPT navigate into nested subcommands; for --params, any trailing options are replayed against the resolved command so the parameter table reports their value and source. Resolution order for SCRIPT: installed console_scripts entry point, a local project directory (its entry point is read from pyproject.toml or setup.cfg), Python file path, module:function notation, or Python module name. """ if not script_and_args: click.echo(ctx.get_help(), color=ctx.color) ctx.exit(0) if sum((params, man, tree, bool(help_format))) > 1: raise click.UsageError( "--params, --man, --tree and --help-format are mutually exclusive." ) # Only the formats a consumer installs somewhere have a destination to be # redirected to. The rest are documents: stdout is the only answer. if (output_dir is not None or install) and help_format not in INSTALLABLE_FORMATS: flag = "--output-dir" if output_dir is not None else "--install" formats = ", ".join(sorted(INSTALLABLE_FORMATS)) raise click.UsageError(f"{flag} requires --help-format with one of: {formats}.") script = script_and_args[0] args = script_and_args[1:] # Introspection modes: load the target and describe it without running it. if params or man or tree or help_format: nav, target_args = _split_navigation(args) if man: _wrap_man(script, nav) elif tree: _wrap_tree(ctx, script, nav) elif help_format: _wrap_help_format(ctx, script, nav, help_format, output_dir, install) else: _wrap_params(ctx, script, nav, target_args, table_format) ctx.exit(0) # Default mode: run the target with colorized help. # Extract config from the [wrap.<script>] section and prepend as CLI # arguments for the target. config_args = _config_args_for_target(ctx, script) if config_args: logger.info("Config args for target CLI: %s.", config_args) args = (*config_args, *args) module_path, function_name = resolve_target(script) # Color and theme are inherited from the parent group's context: ColorOption # has already processed --color/--no-color flags and environment variables # (NO_COLOR, CLICOLOR, etc.), and ThemeOption has recorded the user's --theme # pick on the shared context meta. The wrapped target runs under its own fresh # context with no meta, so `get_current_theme` (reading that meta, or the # dark default when --theme was omitted) resolves the pick here and hands it to # `patch_click`, which installs it as the process-wide default the target's # help then renders with. patch_click(theme=get_current_theme(), color=ctx.color) invoke_target(script, module_path, function_name, args) #: Choice values of this command's own options that are also plain English #: words its description uses: the parameter table "reports their value and #: source". Excluded from the cross-reference pass, which would otherwise paint #: them as values wherever the prose says the word. Their own metavars keep #: their coloring, see `HelpFormatter.highlight_extra_keywords`. wrap.excluded_keywords = HelpKeywords( choices={"default", "help", "source", "value"}, )