# 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 ``--show-params``, ``--man`` and ``--tree``
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 configparser import ConfigParser
from importlib import metadata
from pathlib import Path
import click
import cloup
from click.core import ParameterSource
from click.utils import make_str
from . import EnumChoice, context, option
from .carapace import dump_carapace_spec, install_carapace_spec
from .commands import ColorizedCommand, ColorizedGroup, Group
from .context import Context
from .decorators import columns_option
from .highlight import HelpFormatter, _HelpColorsMixin
from .man_page import render_manpage, write_manpages
from .parameters import (
ShowParamsOption,
make_resilient_context,
render_params_table,
)
from .table import DEFAULT_FORMAT, TableFormat
from .theme import BUILTIN_THEMES, HelpTheme, nocolor_theme, set_default_theme
from .tree import render_command_tree
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib # type: ignore[import-not-found]
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
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
[docs]
class WrapperGroup(Group):
"""Group that falls back to the ``wrap`` subcommand for unknown names.
Known subcommands and their aliases are dispatched normally. 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.
resolved = super().resolve_command_name(ctx, 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.
:param theme: Color theme to use. ``None`` keeps the current default.
: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.
"""
if color is False:
class _NoColorContext(Context):
"""Context variant that forces colors off."""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# Pin colors off for a root context, overriding the auto default
# Context would otherwise resolve from the environment.
if not self.parent:
self.color = False
ColorizedCommand.context_class = _NoColorContext
ColorizedGroup.context_class = _NoColorContext
else:
ColorizedCommand.context_class = Context
ColorizedGroup.context_class = Context
_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.
color_flag = color
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_flag
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() now works on any command: static methods are
# class-qualified and extra_keywords uses getattr with defaults.
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.
if theme is not None:
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.
"""
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]
# Reset context classes to defaults.
ColorizedCommand.context_class = Context
ColorizedGroup.context_class = Context
# Restore the default theme. Fall back to the colorless theme when
# themes.toml is absent (some packaging setups drop the data file, so the
# built-in "dark" palette is unavailable).
set_default_theme(BUILTIN_THEMES.get("dark", nocolor_theme))
def _read_project_scripts(directory: Path) -> dict[str, str]:
"""Read the console-script entry points declared by a local project.
Inspects PEP 621 ``[project.scripts]`` in ``pyproject.toml`` first, then
``console_scripts`` under ``[options.entry_points]`` in ``setup.cfg``. The
dynamic ``setup.py`` form cannot be parsed statically and is ignored.
:returns: a mapping of each console-script name to its ``module:function``
target. Empty when the project declares none.
"""
pyproject = directory / "pyproject.toml"
if pyproject.is_file():
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
scripts = data.get("project", {}).get("scripts", {})
if scripts:
return {str(name): str(target) for name, target in scripts.items()}
setup_cfg = directory / "setup.cfg"
if setup_cfg.is_file():
parser = ConfigParser()
parser.read(setup_cfg, encoding="utf-8")
raw = parser.get("options.entry_points", "console_scripts", fallback="")
scripts = {}
for line in raw.strip().splitlines():
entry = line.strip()
if not entry or "=" not in entry:
continue
name, _, target = entry.partition("=")
scripts[name.strip()] = target.strip()
return scripts
return {}
def _locate_package_root(directory: Path, top_package: str) -> Path | None:
"""Find which directory must be on ``sys.path`` to import *top_package*.
Handles the two common project layouts: the flat layout (the package sits
directly under the project root) and the src layout (under a ``src/``
subdirectory).
:returns: the directory to prepend to ``sys.path``, or ``None`` when the
package cannot be located on disk.
"""
for root in (directory, directory / "src"):
package = root / top_package
if (package / "__init__.py").is_file():
return root
if (root / f"{top_package}.py").is_file():
return root
if package.is_dir():
return root
return None
def _resolve_project_dir(directory: Path) -> tuple[str, str, Path]:
"""Resolve a local project directory to an importable target.
Reads the project's packaging metadata to find its console-script entry
point, then 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.
:returns: ``(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.
"""
scripts = _read_project_scripts(directory)
if not scripts:
raise click.ClickException(
f"No console-script entry point found in {directory}. Looked for "
f"[project.scripts] in pyproject.toml and console_scripts in "
f"setup.cfg. Pass the target explicitly as module:function."
)
targets = sorted(set(scripts.values()))
if len(targets) > 1:
listing = ", ".join(
f"{name} = {target}" for name, target in sorted(scripts.items())
)
raise click.ClickException(
f"Multiple console scripts in {directory}: {listing}. "
f"Pass one explicitly as module:function."
)
module_path, _, function_name = targets[0].partition(":")
top_package = module_path.split(".")[0]
syspath_entry = _locate_package_root(directory, top_package)
if syspath_entry is None:
raise click.ClickException(
f"Found entry point {targets[0]!r} in {directory}, but its top-level "
f"package {top_package!r} is not under {directory} or "
f"{directory / 'src'}."
)
return module_path, function_name, syspath_entry
[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.
:returns: ``(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.
for ep in metadata.entry_points().select(group="console_scripts"):
if ep.name == script:
module_path, _, function_name = ep.value.partition(":")
logger.info(
"Resolved %r as console_scripts entry point: %s:%s.",
script,
module_path,
function_name,
)
return module_path, function_name
# 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.
if script.endswith(".py"):
if Path(script).is_file():
logger.info("Resolved %r as .py file.", script)
return script, ""
# .py path that does not exist: fall through to bare module lookup.
# Skip the module:function check below: a .py name is never valid
# module:function notation.
else:
# 4. Explicit module:function notation.
if ":" 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.
: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
try:
sys.argv = [script, *args]
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
[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 ``wrap`` command's introspection modes (``--show-params``,
``--man``, ``--carapace``, ``--tree``) 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
if not isinstance(cli_obj, click.Command):
# The entry point might be a wrapper function. Scan the module
# for Click command instances, preferring groups.
groups = {}
commands = {}
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
if len(groups) == 1:
cli_obj = next(iter(groups.values()))
elif groups:
names = ", ".join(sorted(groups))
raise click.ClickException(
f"Multiple command groups in {module_path}: {names}. "
f"Specify the correct one with module:name notation."
)
elif len(commands) == 1:
cli_obj = next(iter(commands.values()))
elif commands:
names = ", ".join(sorted(commands))
raise click.ClickException(
f"Multiple commands in {module_path}: {names}. "
f"Specify the correct one with module:name notation."
)
else:
raise click.ClickException(
f"No Click command found in {module_path}. If its entry point "
f"imports the command lazily, point at the module that defines "
f"it with module:function notation (for example, mypackage.cli:cli)."
)
# Navigate to the requested subcommand, if any.
assert isinstance(cli_obj, click.Command)
cmd: click.Command = cli_obj
# Propagate ``context_settings`` (notably ``auto_envvar_prefix``) so
# introspection sees the same envvar layout the CLI exposes at runtime.
# Click reads these only through ``make_context``; the raw ``Context``
# constructor ignores them. Build through ``cmd.context_class`` (not
# ``click.Context``) exactly as ``make_context`` does: a Cloup command's
# ``context_settings`` carry Cloup-only keys (``align_option_groups``,
# ``show_constraints``, β¦) that a plain ``click.Context`` rejects with a
# ``TypeError``. Child contexts inherit ``auto_envvar_prefix`` from their
# parent automatically.
cmd_ctx = cmd.context_class(
cmd, info_name=cmd.name or script, **dict(cmd.context_settings)
)
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_class(child, parent=cmd_ctx, info_name=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** (``--show-params``, ``--man``, ``--tree``) describe
and exit without running the target. Their group-level twins
(``click-extra --show-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.parameters.render_params_table`,
:func:`~click_extra.man_page.render_manpage`,
:func:`~click_extra.tree.render_command_tree`), so a new introspection
feature only has to add one option here, never a parallel subcommand.
- **Modifiers** (``--table-format``, ``--columns``, ``--output-dir``)
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 --show-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 in ShowParamsOption.TABLE_HEADERS 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"))``.
"""
nav = []
rest = list(args)
while rest and not rest[0].startswith("-"):
nav.append(rest.pop(0))
return tuple(nav), tuple(rest)
def _wrap_show_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.parameters.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 = click.Context(cmd, info_name=cmd.name or 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)
def _wrap_man(
script: str,
nav: tuple[str, ...],
output_dir: Path | None,
) -> None:
"""Resolve a foreign target and render its man page (roff).
With ``output_dir`` set, writes one ``.1`` file per (sub)command of the
tree rooted at SCRIPT; otherwise prints a single page to stdout.
"""
cmd, _ = resolve_target_command(script, nav)
if output_dir is not None:
if nav:
raise click.ClickException(
"--output-dir always emits the full tree rooted at SCRIPT and "
"cannot be combined with extra SUBCOMMAND arguments. To render "
"a single subcommand page, drop --output-dir and redirect "
"stdout into a .1 file instead."
)
prog_name = cmd.name or script
for path in write_manpages(cmd, output_dir, prog_name=prog_name):
click.echo(str(path))
else:
prog_name = " ".join((script, *nav))
click.echo(render_manpage(cmd, prog_name=prog_name))
def _wrap_carapace(
ctx: click.Context,
script: str,
nav: tuple[str, ...],
install: bool,
) -> None:
"""Resolve a foreign target and emit its Carapace completion spec (YAML).
Unlike the man page, the whole command tree serializes into a single spec, so
there is no per-subcommand output mode: the spec is printed to stdout, or with
``install`` written into Carapace's user spec directory (its path is echoed).
The reconstructed wrap command is recorded in the spec's header comment.
"""
cmd, _ = resolve_target_command(script, nav)
prog_name = cmd.name or (nav[-1] if nav else script)
# Rebuild the wrap command from the context rather than sys.argv, so the
# header is correct under CliRunner (tests, Sphinx) too. command_path is
# split so its words are not quoted as one shell token.
parts = [*ctx.command_path.split(), "--carapace"]
if install:
parts.append("--install")
parts.extend(("--", script, *nav))
invocation = shlex.join(parts)
if install:
path = install_carapace_spec(cmd, prog_name=prog_name, invocation=invocation)
click.echo(str(path))
else:
click.echo(dump_carapace_spec(cmd, prog_name=prog_name, invocation=invocation))
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((script, *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 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 ()
extra: list[str] = []
for key, value in target_section.items():
# Convert underscores to dashes for CLI option names.
opt_name = f"--{key.replace('_', '-')}"
if isinstance(value, bool):
# Only pass the flag when true. A false boolean means "don't
# pass the flag," which is the default for Click flags. Sending
# --no-<name> would fail on plain is_flag=True options that
# don't define a --flag/--no-flag pair.
if value:
extra.append(opt_name)
elif isinstance(value, list):
for item in value:
extra.append(opt_name)
extra.append(str(item))
else:
extra.append(opt_name)
extra.append(str(value))
return tuple(extra)
@click.command(
name="wrap",
aliases=["run"],
cls=_WrapCommand,
context_settings={"allow_interspersed_args": False},
)
@option(
"--show-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="Show the man page (roff) of the target CLI and exit, without running it.",
)
@option(
"--carapace",
"carapace_spec",
is_flag=True,
default=False,
help="Show the Carapace completion spec (YAML) 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(
"--output-dir",
type=click.Path(file_okay=False, dir_okay=True, writable=True, path_type=Path),
default=None,
help="With --man, write one .1 file per (sub)command into this directory "
"instead of printing a single page to stdout. Created if missing.",
)
@option(
"--install",
is_flag=True,
default=False,
help="With --carapace, write the spec into Carapace's user spec directory "
"instead of printing it, and echo the written path.",
)
@option(
"--table-format",
type=EnumChoice(TableFormat),
default=DEFAULT_FORMAT,
help="With --show-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, ...],
show_params: bool,
man: bool,
carapace_spec: bool,
tree: bool,
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 --show-params, --man, --carapace or --tree, SCRIPT is loaded and
described without being run. Extra arguments after SCRIPT navigate into
nested subcommands; for --show-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((show_params, man, carapace_spec, tree)) > 1:
raise click.UsageError(
"--show-params, --man, --carapace and --tree are mutually exclusive."
)
if output_dir is not None and not man:
raise click.UsageError("--output-dir requires --man.")
if install and not carapace_spec:
raise click.UsageError("--install requires --carapace.")
script = script_and_args[0]
args = script_and_args[1:]
# Introspection modes: load the target and describe it without running it.
if show_params or man or carapace_spec or tree:
nav, target_args = _split_navigation(args)
if man:
_wrap_man(script, nav, output_dir)
elif carapace_spec:
_wrap_carapace(ctx, script, nav, install)
elif tree:
_wrap_tree(ctx, script, nav)
else:
_wrap_show_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 reassigned
# ``theme.default_theme`` to the user's pick. ``theme=None`` instructs
# ``patch_click`` to keep that current default.
patch_click(theme=None, color=ctx.color)
invoke_target(script, module_path, function_name, args)