# 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.
"""Our own flavor of `Option`, `Argument` and `parameters`."""
from __future__ import annotations
import logging
from collections import ChainMap
from contextlib import nullcontext
from functools import cached_property, reduce
from gettext import gettext as _
from operator import getitem
from typing import TypeVar
import click
import cloup
from boltons.pathutils import shrinkuser
from click import ParamType, get_current_context
from click._utils import UNSET
from click.core import ParameterSource
from deepmerge import always_merger
from . import context
# Imported under a private name so this module's namespace does not resurrect
# the moved helper: its canonical home is click_extra._utils, and the public
# `parameters.patch_attr` spelling resolves through the deprecation hook below.
from ._utils import patch_attr as _patch_attr
from .envvar import param_envvar_ids
from .styling import Style
from .types import EnumChoice
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Sequence
from pathlib import Path
from typing import Any, ClassVar, Literal
from boltons.urlutils import URL
logger = logging.getLogger(__name__)
P = TypeVar("P", bound=click.Parameter)
"""Type variable bound to {class}`click.Parameter`, letting
{func}`require_sibling_param` return the exact subclass it was asked to find."""
#: Separator joining the keys of a parameter's fully-qualified path
#: (`cli.subcommand.param`).
PARAM_PATH_SEP = "."
[docs]
def search_params(
params: Iterable[click.Parameter],
klass: type[click.Parameter],
include_subclasses: bool = True,
unique: bool = True,
) -> list[click.Parameter] | click.Parameter | None:
"""Search a particular class of parameter in a list and return them.
:param params: list of parameter instances to search in.
:param klass: the class of the parameters to look for.
:param include_subclasses: if `True`, includes in the results all parameters subclassing
the provided `klass`. If `False`, only matches parameters which are strictly instances of `klass`.
Defaults to `True`.
:param unique: if `True`, raise an error if more than one parameter of the
provided `klass` is found. Defaults to `True`.
"""
param_list = [
p
for p in params
if (include_subclasses and isinstance(p, klass))
or (not include_subclasses and p.__class__ is klass)
]
if not param_list:
return None
if unique:
if len(param_list) != 1:
raise RuntimeError(
f"More than one {klass.__name__} parameters found on command: "
f"{param_list}"
)
return param_list.pop()
return param_list
[docs]
def last_param(
params: Iterable[click.Parameter],
klass: type[click.Parameter],
) -> click.Parameter | None:
"""Return the last parameter of exactly `klass` in *params*, or `None`.
Unlike {func}`search_params`, this matches the exact `klass` (no subclasses)
and tolerates duplicates: when an option is declared more than once (like an
explicit `@verbosity_option` stacked on a Click Extra command that already
ships one), Click keeps the last occurrence, so this mirrors that here instead
of erroring out on the ambiguity.
:param params: the command's parameter list to scan.
:param klass: the exact parameter class to look for.
"""
options = search_params(params, klass, include_subclasses=False, unique=False)
return options[-1] if options else None # type: ignore[index]
[docs]
def require_sibling_param(
params: Iterable[click.Parameter],
requester: click.Parameter,
klass: type[P],
) -> P:
"""Return the sibling *klass* parameter declared on the same command, or raise.
Some options are inert on their own: they drive machinery owned by a sibling
option. `--no-config` and `--validate-config`, for instance, both depend on
the `--config` option ({class}`~click_extra.config.option.ConfigOption`). This
helper centralizes the lookup so every such option raises the same
`RuntimeError` when its required sibling is missing, naming the offending flag.
:param params: the command's parameter list to scan.
:param requester: the parameter requiring the sibling, used to build the error
message from its flag names and class.
:param klass: the sibling parameter class to look for.
"""
sibling = search_params(params, klass)
if not isinstance(sibling, klass):
# RuntimeError (not the type-implied TypeError) is intentional: it keeps
# the historical --no-config contract and unifies all call sites on one
# exception type for a missing-or-wrong-type sibling.
raise RuntimeError( # noqa: TRY004
f"{'/'.join(requester.opts)} {type(requester).__name__} must be used "
f"alongside {klass.__name__}."
)
return sibling
[docs]
def full_short_help(command: click.Command) -> str:
"""Return the command's canonical one-line short help, untruncated.
Click's {meth}`click.Command.get_short_help_str` truncates to 45 characters by
default with a trailing `"..."` so subcommand listings fit a terminal column.
That bound is wrong for generated documentation and completion specs, where the
NAME / COMMANDS sections carry the full description and the renderer wraps text
on its own.
The lookup mirrors Click's order: an explicit `short_help` wins, otherwise the
first paragraph of `command.help` is joined into one line. A truthy
`deprecated` flag prepends `(Deprecated)` so the flag stays visible.
"""
if command.short_help:
text = command.short_help.strip()
elif command.help:
# Click already stores `help` after `inspect.cleandoc`: split on the
# first blank line to grab the leading paragraph, then squash internal
# newlines so the result is one line.
paragraph = command.help.split("\n\n", 1)[0]
text = paragraph.strip().replace("\n", " ")
else:
text = ""
if command.deprecated:
text = f"(Deprecated) {text}".strip()
return text
[docs]
def resolve_param_help(param: click.Parameter, ctx: click.Context) -> str | None:
"""Return a parameter's help text, including the dynamically-generated ones.
Reading `param.help` covers the options that carry a static string, and
misses the ones that compute their help from the context: Click Extra's own
`-v` / `-q` derive theirs from the resolved base verbosity, and leave the
attribute at `None` (see
{meth}`~click_extra.logging.VerboseOption.get_help_record`). Falling back to
the help record picks those up.
The record also carries Click's bracket fields (`[default: β¦]`,
`[required]`, `[env var: β¦]`), appended to the prose behind two spaces. They
are stripped here: they are not the author's documentation, and every
backend of this module renders them (or deliberately does not) from
structured fields of its own.
"""
help_text = getattr(param, "help", None)
if help_text:
return str(help_text)
record = param.get_help_record(ctx)
if not record:
return None
text = record[1]
if isinstance(param, click.Option) and param.get_help_extra(ctx):
marker = text.rfind(" [")
if marker != -1:
text = text[:marker]
return text.strip() or None
[docs]
def param_spellings(param: click.Parameter) -> tuple[str, ...]:
"""All literal spellings of a parameter: primary `opts` then `secondary_opts`.
A boolean flag pair yields both forms (`--foo`, `--no-foo`); a plain option
yields just its declared names.
"""
return tuple(param.opts) + tuple(param.secondary_opts)
[docs]
def short_long_opts(opts: Sequence[str]) -> tuple[str, str]:
"""Split option spellings into the first short (`-x`) and long (`--xy`) form.
Either element is the empty string when that form is absent.
"""
short = next((o for o in opts if o.startswith("-") and not o.startswith("--")), "")
long = next((o for o in opts if o.startswith("--")), "")
return short, long
[docs]
def option_value_kind(
param: click.Parameter,
) -> Literal["flag", "optional", "required"]:
"""Classify how an option consumes a value, the basis for rendering its metavar.
- `"flag"`: takes no value. A boolean switch (`--foo`, `--foo/--no-foo`), a
flag with a custom `flag_value` (`--no-config`), or a counter (`-v`).
- `"optional"`: the value may be omitted. Click models this as
`is_flag=False` with a `flag_value` set, so a bare `--color` stands for
the flag value while `--color=never` passes an explicit one.
- `"required"`: consumes a value (`--config CONFIG_PATH`).
```{note}
The discriminator is Click's `is_flag` (plus `count`), not
`is_bool_flag`: a flag carrying a custom `flag_value` such as
{class}`~click_extra.config.option.NoConfigOption` reports
`is_bool_flag=False` yet still takes no value.
```
"""
if getattr(param, "count", False) or getattr(param, "is_flag", False):
return "flag"
if getattr(param, "secondary_opts", None):
return "flag"
flag_value = getattr(param, "flag_value", None)
# Click's development branch models an absent flag_value as the UNSET
# sentinel, not None.
if flag_value is not None and flag_value is not UNSET:
return "optional"
return "required"
[docs]
def resolve_flag_value(param: click.Parameter) -> Any:
"""The value *param*'s primary declaration stands for.
Released Click materializes it in `flag_value`: `None` for a plain option or
a counter, `True` for a boolean flag, and the declared value otherwise
(`--color` standing for `always`). Click's development branch leaves that
attribute as the `UNSET` sentinel and answers the same question lazily in
`flag_activation_value`, so reading either attribute on its own is right on
only one of the two, and storing the sentinel anywhere it will be read back
as a value silently turns the flag off.
"""
flag_value = getattr(param, "flag_value", None)
if flag_value is UNSET:
return getattr(param, "flag_activation_value", None)
return flag_value
[docs]
def is_repeatable(param: click.Parameter) -> bool:
"""Whether the parameter may be supplied several times (`multiple` or `count`)."""
return bool(getattr(param, "multiple", False) or getattr(param, "count", False))
class _ParameterMixin:
"""Mixin providing shared functionality for Click Extra parameters.
```{warning}
If we want to override any method from Click's `Parameter` class, we have to
use that mixin and have it inherited first in the `Option` and `Argument`
classes below.
Because:
- Cloup does not provide its own `Parameter` class.
- Multiple inheritance cannot be used because of MRO issues.
```
"""
# Attributes provided by the `click.Parameter` subclass this mixin is always
# combined with. Declared here so the methods below can reference them without
# mypy flagging them as undefined on the standalone mixin.
multiple: bool
nargs: int
def get_default(self, ctx: click.Context, call: bool = True):
"""Override `click.Parameter.get_default()` to support `EnumChoice` types.
Reuse the `EnumChoice.get_choice_string()` method to convert an `Enum`
default value to its string representation, to bypass [Click's default behavior of returning the Enum.name](https://github.com/pallets/click/pull/3004).
```{note}
A `multiple` option or a variadic (`nargs=-1`) parameter carries a
*sequence* of members as its default, so each member is resolved on its
own. Converting the sequence as a whole would stringify the tuple itself
(`str((MyEnum.FOO,))`) and later trip Click's ``Value must be an
iterable`` check on the default-value path.
```
"""
default_value = super().get_default(ctx, call) # type: ignore[misc]
if (
hasattr(self, "type")
and isinstance(self.type, EnumChoice)
# Turns out UNSET is also an Enum member, so we need to ignore it.
and default_value is not UNSET
):
if self.multiple or self.nargs == -1:
# A `None` default is not iterable; leave it for Click to turn
# into an empty tuple.
if default_value is not None:
default_value = tuple(
self.type.get_choice_string(member) for member in default_value
)
else:
default_value = self.type.get_choice_string(default_value)
return default_value
[docs]
class Argument(_ParameterMixin, cloup.Argument):
"""Wrap `cloup.Argument`, itself inheriting from `click.Argument`.
Inherits first from `_ParameterMixin` to allow future overrides of Click's
`Parameter` methods.
"""
[docs]
class Option(_ParameterMixin, cloup.Option):
"""Wrap `cloup.Option`, itself inheriting from `click.Option`.
Inherits first from `_ParameterMixin` to allow future overrides of Click's
`Parameter` methods.
"""
[docs]
class ParamStructure:
"""Utilities to introspect CLI options and commands structure.
Structures are represented by a tree-like `dict`.
Access to a node is available using a serialized path string composed of the keys to
descend to that node, separated by a dot `.`.
"""
excluded_params: frozenset[str]
"""Fully-qualified IDs of the parameters to block from the structure.
Set by subclasses: {class}`ShowParamsOption` freezes an empty set, while
`ConfigOption` resolves a dynamic default (or the user-provided list) within
the active context. The two filters are mutually exclusive, a constraint each
subclass enforces in its own constructor.
"""
included_params: frozenset[str] | None
"""Allowlist of parameter IDs, mutually exclusive with `excluded_params`.
`None` disables the allowlist. It is resolved into `excluded_params` by
{meth}`~click_extra.parameters.ParamStructure.build_param_trees`, once every
parameter ID is known.
"""
[docs]
@staticmethod
def init_tree_dict(*path: str, leaf: Any = None) -> Any:
"""Utility method to recursively create a nested dict structure whose keys are
provided by `path` list and at the end is populated by a copy of `leaf`."""
def dive(levels):
if levels:
return {levels[0]: dive(levels[1:])}
return leaf
return dive(path)
[docs]
@staticmethod
def get_tree_value(tree_dict: dict[str, Any], *path: str) -> Any:
"""Get in the `tree_dict` the value located at the `path`.
Raises `KeyError` if no item is found at the provided `path`.
"""
return reduce(getitem, path, tree_dict)
[docs]
def walk_params(self) -> Iterator[tuple[tuple[str, ...], click.Parameter]]:
"""Generate an unfiltered list of all CLI parameters.
Everything is included, from top-level groups to subcommands, and from
options to arguments.
Yields a 2-element tuple:
- a tuple of keys leading to the parameter;
- the parameter object itself.
Thin adapter over {func}`~click_extra.parameters.walk_command_params`: it
resolves the root CLI from the active context and drops the per-parameter
context that the free function also yields.
"""
ctx = get_current_context()
cli = ctx.find_root().command
assert cli.name is not None
for keys, param, _ctx in walk_command_params(cli, ctx, (cli.name,)):
yield keys, param
TYPE_MAP: ClassVar[dict[type[ParamType], type[str | int | float | bool | list]]] = {
click.types.StringParamType: str,
click.types.IntParamType: int,
click.types.FloatParamType: float,
click.types.BoolParamType: bool,
click.types.UUIDParameterType: str,
click.types.UnprocessedParamType: str,
click.types.File: str,
click.types.Path: str,
click.types.Choice: str,
click.types.IntRange: int,
click.types.FloatRange: float,
click.types.DateTime: str,
click.types.Tuple: list,
}
"""Map Click types to their Python equivalent.
Keys are subclasses of `click.types.ParamType`. Values are expected to be simple
builtins Python types.
This mapping can be seen as a reverse of the `click.types.convert_type()` method.
"""
[docs]
@staticmethod
def map_click_type(click_type: ParamType) -> type[str | int | float | bool | list]:
"""Map a Click parameter type instance to its Python equivalent.
Returns `str` for unrecognised custom types, since command-line
parameters are strings by default.
See the list of
[custom types provided by Click](https://click.palletsprojects.com/en/stable/api/#types).
"""
# Try to directly map the Click type to a Python type.
py_type = ParamStructure.TYPE_MAP.get(click_type.__class__)
if py_type is not None:
return py_type
# Try to indirectly map the type by looking at inheritance.
for param_type, mapped_type in ParamStructure.TYPE_MAP.items():
if isinstance(click_type, param_type):
return mapped_type
# Custom parameters are expected to convert from strings, as that's
# the default type of command lines.
# See: https://click.palletsprojects.com/en/stable/api/#click.ParamType
return str
[docs]
@staticmethod
def get_param_type(
param: click.Parameter,
) -> type[str | int | float | bool | list]:
"""Get the Python type of a Click parameter.
Returns `str` for unrecognised custom types, since command-line
parameters are strings by default.
See the list of
[custom types provided by Click](https://click.palletsprojects.com/en/stable/api/#types).
"""
if param.multiple or param.nargs != 1:
return list
if hasattr(param, "is_bool_flag") and param.is_bool_flag:
return bool
return ParamStructure.map_click_type(param.type)
[docs]
def build_param_trees(self) -> dict[str, Any]:
"""Build and return the parameters tree structure.
This removes parameters whose fully-qualified IDs are in the `excluded_params`
blocklist.
If `included_params` was provided, it is resolved into `excluded_params`
here, where all parameter IDs are available.
"""
# Resolve included_params into excluded_params before filtering.
if self.included_params is not None:
all_param_ids = frozenset(
PARAM_PATH_SEP.join(keys) for keys, _ in self.walk_params()
)
self.excluded_params = all_param_ids - self.included_params
objects: dict[str, Any] = {}
for keys, param in self.walk_params():
if PARAM_PATH_SEP.join(keys) in self.excluded_params:
continue
objects = always_merger.merge(
objects, self.init_tree_dict(*keys, leaf=[param])
)
return objects
@staticmethod
def _nullify_leaves(tree: dict[str, Any]) -> dict[str, Any]:
"""Derive a template shape from a tree by replacing all leaves with `None`."""
return {
k: ParamStructure._nullify_leaves(v) if isinstance(v, dict) else None
for k, v in tree.items()
}
[docs]
@cached_property
def params_template(self) -> dict[str, Any]:
"""Returns a tree-like dictionary whose keys shadows the CLI options and
subcommands and values are `None`.
Perfect to serve as a template for configuration files.
"""
return self._nullify_leaves(self.params_objects)
[docs]
@cached_property
def params_objects(self) -> dict[str, Any]:
"""Returns a tree-like dictionary whose keys shadows the CLI options and
subcommands and values are parameter objects.
Perfect to parse configuration files and user-provided parameters.
"""
return self.build_param_trees()
[docs]
def get_param_spec(param: click.Parameter, ctx: click.Context) -> str | None:
"""Extract the option-spec string (like `-v, --verbose`) from a parameter.
Temporarily unhides hidden options so their help record can be produced.
```{note}
The `hidden` property is only supported by `Option`, not `Argument`.
```
```{todo}
Submit a PR to Click to separate production of param spec and help
record. That way we can always produce the param spec even if the
parameter is hidden.
See: https://github.com/kdeldycke/click-extra/issues/689
```
"""
if not hasattr(param, "hidden"):
return None
with _patch_attr(param, "hidden", False) if param.hidden else nullcontext():
help_record = param.get_help_record(ctx)
return help_record[0] if help_record else None
def _structured_value(value: Any) -> Any:
"""Coerce a value to a natively serializable form for structured output.
Scalars and lists (`str`, `int`, `float`, `bool`, `list`, `None`)
pass through unchanged so JSON, YAML and TOML renderers keep their native
types. Anything else (a Click `ParamType`, a `Path`, an arbitrary
object) is rendered with {func}`repr` so it survives serialization as a
readable string.
"""
if isinstance(value, (str, int, float, bool, list, type(None))):
return value
return repr(value)
[docs]
def make_resilient_context(
command: click.Command,
info_name: str | None = None,
parent: click.Context | None = None,
) -> click.Context:
"""Build an introspection context for a command.
Parses no arguments and sets `resilient_parsing=True` so required-argument
errors, prompts and eager-option side effects stay dormant: the canonical way
to materialize a {class}`click.Context` purely to read a command's structure
(its parameters, env-var prefix and subcommands), shared by the man-page and
Carapace exporters.
"""
return command.make_context(info_name, [], parent=parent, resilient_parsing=True)
[docs]
def iter_subcommands(
command: click.Command,
ctx: click.Context,
*,
skip_hidden: bool = True,
) -> Iterator[tuple[str, click.Command]]:
"""Yield a group's direct subcommands as `(name, command)` pairs.
Subcommands are discovered dynamically through
{meth}`click.Group.list_commands` / {meth}`~click.Group.get_command`, in
listing order, so lazily-registered commands are included. A non-group yields
nothing, a name resolving to `None` is skipped, and hidden subcommands are
skipped unless `skip_hidden` is `False` (completion specs keep them,
flagged hidden; documentation drops them).
"""
if not isinstance(command, click.Group):
return
for name in command.list_commands(ctx):
sub = command.get_command(ctx, name)
if sub is None:
continue
if skip_hidden and getattr(sub, "hidden", False):
continue
yield name, sub
[docs]
def iter_params_for_display(
command: click.Command,
ctx: click.Context,
) -> Iterator[click.Parameter]:
"""Yield a command's parameters in the order its help screen lists them.
A Click Extra command keeps two orders apart: `command.params` is the
processing order, which decides when each callback fires, while the help screen
reads the presentation order Cloup caches in `arguments`, `option_groups` and
`ungrouped_options` (see
{meth}`click_extra.commands.Command.param_priority`). Reading `get_params()`
therefore renders a man page, a Markdown document or a completion spec whose
flags no longer match the `--help` its reader just saw. This is the accessor
every such renderer should go through.
Falls back to `get_params()` for a command that carries no Cloup option groups,
where the two orders are the same list. Any parameter attached after
construction, and so absent from the cached groups, is yielded last rather than
dropped.
"""
if not isinstance(command, cloup.Command):
yield from command.get_params(ctx)
return
seen: set[int] = set()
ordered: list[click.Parameter] = [
*command.arguments,
*(option for group in command.option_groups for option in group.options),
*command.get_ungrouped_options(ctx),
]
for param in ordered:
if id(param) not in seen:
seen.add(id(param))
yield param
# A parameter appended to `command.params` after construction never made it into
# the cached groups. Yield it rather than silently dropping it from the render.
for param in command.get_params(ctx):
if id(param) not in seen:
seen.add(id(param))
yield param
[docs]
def walk_command_params(
cmd: click.Command,
ctx: click.Context,
parent_keys: tuple[str, ...] = (),
) -> Iterator[tuple[tuple[str, ...], click.Parameter, click.Context]]:
"""Walk the parameter tree of a Click command and all its subcommands.
Yields `(path_keys, param, owning_ctx)` for every parameter found on
*cmd* and, recursively, on each subcommand. Each subcommand is walked under
its own freshly-built child context, so context-sensitive metadata (notably
the auto-generated environment variable, which derives from
`Context.auto_envvar_prefix`) is computed at the correct nesting level
rather than inherited from the root.
A subcommand whose name collides with a sibling parameter at the same level
is skipped: a single fully-qualified path cannot address both an option and
a subcommand at once.
"""
level_param_names = set()
for param in cmd.get_params(ctx):
if param.name is not None:
level_param_names.add(param.name)
yield (*parent_keys, param.name), param, ctx
if isinstance(cmd, click.Group):
# `list_commands()` is the group's own authority on subcommand order: a
# stock Click group still answers alphabetically, and one that was told
# otherwise gets a `--params` tree matching its help screen.
for subcmd_name in cmd.list_commands(ctx):
if subcmd_name in level_param_names:
logger.debug(
f"{cmd.name}{PARAM_PATH_SEP}{subcmd_name} subcommand shadows a "
f"top-level parameter; excluded from parameter tree.",
)
continue
subcmd = cmd.get_command(ctx, subcmd_name)
if subcmd is None:
continue
subcmd_ctx = click.Context(subcmd, parent=ctx, info_name=subcmd_name)
yield from walk_command_params(
subcmd, subcmd_ctx, (*parent_keys, subcmd_name)
)
[docs]
def replay_raw_args(subject_ctx: click.Context) -> dict[str, Any]:
"""Re-parse the captured raw arguments to recover per-parameter values.
Click discards the pre-parsed arguments once processing is done, so the
value and provenance of each parameter cannot be read back directly. When
{data}`~click_extra.context.RAW_ARGS` was captured on the context (by
`Command`/`Group`), replaying it through a fresh parser rebuilds the
`opts` mapping that `Parameter.consume_value` consults, without re-firing
eager callbacks: `handle_parse_result` is never called here, only the
parser.
Returns an empty mapping when no raw arguments were captured, so callers can
fall back to parameter defaults.
"""
raw_args = context.get(subject_ctx, context.RAW_ARGS)
if raw_args is None:
return {}
parser = subject_ctx.command.make_parser(subject_ctx)
opts, _, _ = parser.parse_args(args=list(raw_args))
return opts
[docs]
def param_config_source(
root_ctx: click.Context,
keys: Sequence[str],
) -> Path | URL | None:
"""Return the configuration file supplying a parameter's `default_map` value.
*keys* is the parameter's fully-qualified path, root command name first
and parameter name last, as yielded by {func}`walk_command_params`.
Returns `None` when no configuration file was loaded, when the parameter
is absent from every loaded layer, or when the context carries no layered
`default_map` at all.
The walk mirrors how Click resolves `default_map`, so the attribution
matches the value Click actually picks:
- Root-level parameters are looked up in the root context's
`~collections.ChainMap`, whose first layers are the loaded files in
precedence order (see
{data}`~click_extra.context.CONF_SOURCES`): the first layer naming the
parameter wins.
- A subcommand section resolves against that same `ChainMap`, but Click
keeps descending *inside the single layer* that named the first
segment: the file owning a subcommand section owns its whole sub-tree.
```{note}
Lazy subcommands receive their section from the merged configuration
document, injected into the front layer by
{meth}`~click_extra.commands.Group._apply_config_to_parent_context`:
those values are attributed to the highest-precedence file even when
several files contributed to the merge.
```
"""
sources: tuple[tuple[Path | URL, dict[str, Any]], ...] | None = context.get(
root_ctx, context.CONF_SOURCES
)
if not sources:
return None
default_map = root_ctx.default_map
if not isinstance(default_map, ChainMap):
return None
layers = default_map.maps[: len(sources)]
segments = keys[1:-1]
param_name = keys[-1]
if segments:
for index, layer in enumerate(layers):
if segments[0] not in layer:
continue
subtree = layer[segments[0]]
for segment in segments[1:]:
if not isinstance(subtree, dict):
return None
subtree = subtree.get(segment)
if isinstance(subtree, dict) and param_name in subtree:
return sources[index][0]
return None
return None
for index, layer in enumerate(layers):
if param_name in layer:
return sources[index][0]
return None
[docs]
def render_params_table(
subject_ctx: click.Context,
*,
default_columns: Sequence[str] | None = None,
) -> None:
"""Introspect `subject_ctx.command` and print its parameter metadata table.
Walks the command and any nested subcommands, emitting one row per
parameter. The table format and column selection are read from
`subject_ctx.meta` (see {data}`~click_extra.context.TABLE_FORMAT` and
{data}`~click_extra.context.COLUMNS`); when neither is set, a sibling
`--table-format` / `--columns` option on the command is consulted, then
the *default_columns* fallback, then the canonical order.
When `subject_ctx.meta` carries pre-parsed
{data}`~click_extra.context.RAW_ARGS`, the `value` and `source` columns
are resolved by replaying those arguments against the command parser;
otherwise they fall back to the parameter defaults.
This is the shared rendering core behind both
{meth}`~click_extra.parameters.ShowParamsOption.print_params` (introspecting
the live CLI) and the `click-extra wrap --params` path (introspecting a
foreign target).
The caller is responsible for exiting the context afterwards.
```{important}
Click does not keep the raw, pre-parsed arguments around, so values and
their provenance cannot be read back directly. The workaround replays
{data}`~click_extra.context.RAW_ARGS` (captured on the context by
`Command`/`Group`) through the command parser, calling
`consume_value()` rather than `handle_parse_result()` so eager
callbacks are not re-triggered.
```
"""
# Imported here to avoid circular imports with the config, table and theme
# modules, which all import from this one.
from .config import ConfigOption
from .table import (
DEFAULT_FORMAT,
SERIALIZATION_FORMATS,
ColumnsOption,
TableFormatOption,
print_table,
select_columns,
select_row,
)
from .theme import KO_GLYPH, OK_GLYPH, get_current_theme
active_theme = get_current_theme()
ok_styled = active_theme.success(OK_GLYPH)
ko_styled = active_theme.error(KO_GLYPH)
cmd = subject_ctx.command
# Resolve the value getter. When the original arguments are available we
# replay them through the command parser to recover each value and its
# provenance; otherwise we only know the parameter defaults. Values are
# resolved against each parameter's owning context, so subcommand
# parameters read their `default_map` from the subcommand's own section
# and their environment variables from the correct nesting level.
opts: dict = {}
raw_args = context.get(subject_ctx, context.RAW_ARGS)
if raw_args is not None:
logger.debug(f"{context.RAW_ARGS}: {raw_args}")
opts = replay_raw_args(subject_ctx)
def get_param_value(param, param_ctx):
# consume_value() can return the UNSET sentinel for a parameter with
# no user input and no default. Normalize it to None, mirroring the
# step click.Command.parse_args runs after parsing, which this
# re-parse bypasses. See the RAW_ARGS dossier in click_extra.context.
value, source = param.consume_value(param_ctx, opts)
return (None if value is UNSET else value), source
else:
def get_param_value(param, param_ctx):
return None, param_ctx.get_parameter_source(param.name)
# Locate a --config option to fill the "allowed in conf?" column.
config_option = search_params(cmd.get_params(subject_ctx), ConfigOption)
assert config_option is None or isinstance(config_option, ConfigOption)
# Resolve the table format: an explicit context entry wins, else a sibling
# --table-format option, else the default.
if context.get(subject_ctx, context.TABLE_FORMAT) is None:
table_option = search_params(cmd.get_params(subject_ctx), TableFormatOption)
if table_option and isinstance(table_option, TableFormatOption):
table_fmt, _ = table_option.consume_value(subject_ctx, opts)
table_option.init_formatter(
subject_ctx,
table_option,
table_option.type.convert(table_fmt, table_option, subject_ctx)
if table_fmt
else table_option.get_default(subject_ctx),
)
table_format = context.get(subject_ctx, context.TABLE_FORMAT) or DEFAULT_FORMAT
is_structured = table_format in SERIALIZATION_FORMATS
# Resolve the column selection: an explicit context entry wins, else a
# sibling --columns option, else the provided default.
if context.get(subject_ctx, context.COLUMNS) is None:
cols_option = search_params(cmd.get_params(subject_ctx), ColumnsOption)
if cols_option and isinstance(cols_option, ColumnsOption):
cols_value, _ = cols_option.consume_value(subject_ctx, opts)
cols_option.init_columns(
subject_ctx,
cols_option,
cols_option.type.convert(cols_value, cols_option, subject_ctx)
if cols_value
else (),
)
selected_ids: tuple[str, ...] = context.get(subject_ctx, context.COLUMNS) or ()
if not selected_ids and default_columns:
selected_ids = tuple(default_columns)
if not selected_ids:
# No projection asked for: show every column but the opt-in ones, which
# would otherwise squeeze all the others (see ColumnSpec.optional).
selected_ids = ShowParamsOption.default_column_ids()
# Validate the requested IDs against the column registry so unknown IDs
# become a clear, actionable UsageError.
canonical_ids = ShowParamsOption.column_ids()
known_ids = set(canonical_ids)
unknown = [col_id for col_id in selected_ids if col_id not in known_ids]
if unknown:
joined = ", ".join(repr(c) for c in unknown)
accepted = ", ".join(canonical_ids)
raise click.UsageError(
f"Unknown --columns ID(s): {joined}. Accepted: {accepted}.",
ctx=subject_ctx,
)
table: list[tuple[Any, ...]] = []
for keys, param, owning_ctx in walk_command_params(
cmd, subject_ctx, (cmd.name or "",)
):
path = PARAM_PATH_SEP.join(keys)
param_value, source = get_param_value(param, owning_ctx)
# Whether the parameter is reachable from a configuration file.
allowed_in_conf_bool = None
if config_option:
config_option.params_template # noqa: B018
allowed_in_conf_bool = path not in config_option.excluded_params
row = format_param_row(param, owning_ctx, path, is_structured)
# Config-file provenance: only meaningful when the effective value
# came from the default_map; the layer walk then names the file it
# resolved from.
config_file: Path | URL | str | None = None
if source is ParameterSource.DEFAULT_MAP:
location = param_config_source(subject_ctx.find_root(), keys)
if location is not None:
config_file = location if is_structured else shrinkuser(str(location))
if is_structured:
param_value = _structured_value(param_value)
row["allowed_in_conf"] = allowed_in_conf_bool
row["value"] = param_value
row["source"] = source.name if source else None
row["config_file"] = str(config_file) if config_file else None
else:
allowed_in_conf = None
if allowed_in_conf_bool is not None:
allowed_in_conf = ok_styled if allowed_in_conf_bool else ko_styled
row["allowed_in_conf"] = allowed_in_conf
row["value"] = repr(param_value)
row["source"] = source.name if source else None
row["config_file"] = config_file
table.append(select_row(row, selected_ids, canonical_ids))
def sort_by_depth(line: Sequence[Any]) -> tuple[int, Any]:
"""Sort by depth first, then path, keeping top-level params on top."""
param_path = line[0]
return len(param_path.split(PARAM_PATH_SEP)), param_path
selected_columns = select_columns(ShowParamsOption.TABLE_HEADERS, selected_ids)
labels = tuple(col.label for col in selected_columns)
header_labels: tuple[Any, ...]
if is_structured:
header_labels = labels
else:
header_style = Style(bold=True)
header_labels = tuple(map(header_style, labels))
print_table(
sorted(table, key=sort_by_depth),
headers=header_labels,
table_format=table_format,
)
[docs]
class ShowParamsOption(ExtraOption, ParamStructure):
"""A pre-configured option adding a `--params` option.
Between configuration files, default values and environment variables, it might be
hard to guess under which set of parameters the CLI will be executed. This option
print information about the parameters that will be fed to the CLI.
```{note}
The flag is named `--params`, not `--show-params`. It names the view it
prints, matching the neighbouring bare-noun informational flags (`--help`,
`--version`, `--man`, `--tree`), none of which carry a `show-` verb
prefix. The class and {func}`@show_params_option
<click_extra.decorators.show_params_option>` decorator keep their historical
names: the class is named for what it does (show the parameters), while the
flag and the parameter's ID use the bare noun.
```
"""
from .table import ColumnSpec as _ColumnSpec
TABLE_HEADERS: ClassVar[tuple[_ColumnSpec, ...]] = (
_ColumnSpec(
id="id",
label="ID",
description=(
"Fully-qualified parameter path (`cli.subcommand.param_name`) "
"derived from the [`click.Command`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Command) "
"tree. Doubles as the key used to address the parameter from a "
"configuration file, which also accepts the kebab-case "
"spelling of the last segment."
),
),
_ColumnSpec(
id="spec",
label="Spec.",
description=(
"Option/argument specification string (like `-v, --verbose`) "
"extracted from [`click.Parameter.get_help_record()`]"
"(https://click.palletsprojects.com/en/stable/api/"
"#click.Parameter)."
),
),
_ColumnSpec(
id="help",
label="Help",
optional=True,
description=(
"The parameter's own help text, as written by the CLI author. "
"Opt-in: it is the only column carrying free-form prose, so it "
"stays out of the default table and is selected by ID "
"(`--columns id,spec,help`). Structured formats are its main "
"audience: it turns a `--params` dump into a self-describing "
"inventory a tool or an agent can read without also parsing the "
"rendered `--help` screen."
),
),
_ColumnSpec(
id="class",
label="Class",
description=(
"Fully-qualified class of the parameter: a subclass of "
"[`click.Option`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Option), "
"[`click.Argument`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Argument), "
"[`cloup.Option`]"
"(https://cloup.readthedocs.io/en/stable/autoapi/cloup/"
"index.html#cloup.Option), "
"or one of Click Extra's own wrappers "
"([`click_extra.parameters.Option`](#click_extra.parameters.Option), "
"[`click_extra.parameters.Argument`]"
"(#click_extra.parameters.Argument), "
"[`click_extra.parameters.ExtraOption`]"
"(#click_extra.parameters.ExtraOption))."
),
),
_ColumnSpec(
id="param_type",
label="Param type",
description=(
"Click value converter class: a subclass of [`click.ParamType`]"
"(https://click.palletsprojects.com/en/stable/api/"
"#click.ParamType) like [`click.IntRange`]"
"(https://click.palletsprojects.com/en/stable/api/#click.IntRange), "
"[`click.Choice`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Choice), "
"or a Click Extra type."
),
),
_ColumnSpec(
id="python_type",
label="Python type",
description=(
"Python built-in type the parsed value resolves to: "
"[`str`](https://docs.python.org/3/library/stdtypes.html"
"#text-sequence-type-str), "
"[`int`](https://docs.python.org/3/library/functions.html#int), "
"[`float`](https://docs.python.org/3/library/functions.html#float), "
"[`bool`](https://docs.python.org/3/library/functions.html#bool), "
"or [`list`](https://docs.python.org/3/library/stdtypes.html#list). "
"Computed by [`ParamStructure.get_param_type()`]"
"(#click_extra.parameters.ParamStructure.get_param_type) from "
"the Click `Param type`."
),
),
_ColumnSpec(
id="hidden",
label="Hidden",
description=(
"Reflects [`click.Option`'s `hidden`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Option) "
"constructor argument: the option is omitted from `--help` output. "
"Empty for [`click.Argument`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Argument), "
"which does not support hiding."
),
),
_ColumnSpec(
id="exposed",
label="Exposed",
description=(
"Reflects [`click.Parameter`'s `expose_value`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Parameter) "
"constructor argument: whether the parsed value is forwarded to "
"the command callback. Eager options like `--params` and "
"`--help` typically run a callback and exit, so they are not "
"exposed."
),
),
_ColumnSpec(
id="allowed_in_conf",
label="Allowed in conf?",
description=(
"Click Extra-specific: whether the parameter is reachable from a "
"configuration file. Controlled by [`ParamStructure.excluded_params`]"
"(#click_extra.parameters.ParamStructure.excluded_params) and "
"[`included_params`]"
"(#click_extra.parameters.ParamStructure.included_params). Empty "
"when the CLI has no [`--config` option](config.md)."
),
),
_ColumnSpec(
id="envvars",
label="Env. vars.",
description=(
"Environment variables read for this parameter: the explicit "
"[`click.Parameter`'s `envvar`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Parameter) "
"plus the auto-resolved IDs documented in "
"[Environment variables](envvar.md)."
),
),
_ColumnSpec(
id="default",
label="Default",
description=(
"Default value returned by [`click.Parameter.get_default()`]"
"(https://click.palletsprojects.com/en/stable/api/"
"#click.Parameter.get_default), rendered as its Python `repr()`."
),
),
_ColumnSpec(
id="is_flag",
label="Is flag",
description=(
"Reflects [`click.Option`'s `is_flag`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Option): "
"whether the option behaves as a flag (no value taken from the "
"command line). Empty for [`click.Argument`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Argument)."
),
),
_ColumnSpec(
id="flag_value",
label="Flag value",
description=(
"Reflects [`click.Option`'s `flag_value`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Option): "
"the Python value substituted for the option when its flag is "
"used. Defaults to `True` for boolean flags, can be any value "
"for flag-value style options "
"(like `@option('--upper', 'transform', flag_value='upper')`)."
),
),
_ColumnSpec(
id="is_bool_flag",
label="Is bool flag",
description=(
"Reflects `click.Option.is_bool_flag` (set internally by Click "
"when `flag_value` is `True` or `False`): the option is a *true* "
"boolean flag, as opposed to a flag-value style option."
),
),
_ColumnSpec(
id="multiple",
label="Multiple",
description=(
"Reflects [`click.Parameter`'s `multiple`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Parameter): "
"the parameter can be repeated on the command line, collecting "
"values into a tuple."
),
),
_ColumnSpec(
id="nargs",
label="Nargs",
description=(
"Reflects [`click.Parameter`'s `nargs`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Parameter): "
"the number of CLI tokens the parameter consumes. `1` is the "
"default; `-1` denotes a variadic argument."
),
),
_ColumnSpec(
id="prompt",
label="Prompt",
description=(
"Reflects [`click.Option`'s `prompt`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Option): "
"the text shown to the user when the option is not provided on "
"the command line. Empty when no prompt is configured."
),
),
_ColumnSpec(
id="confirmation_prompt",
label="Confirmation prompt",
description=(
"Reflects [`click.Option`'s `confirmation_prompt`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Option): "
"whether the user is asked to enter the value twice for "
"confirmation."
),
),
_ColumnSpec(
id="value",
label="Value",
description=(
"Current value of the parameter at invocation time, computed by "
"[`click.Parameter.consume_value()`]"
"(https://click.palletsprojects.com/en/stable/api/#click.Parameter) "
"from the merged sources (CLI, environment, config file, default)."
),
),
_ColumnSpec(
id="source",
label="Source",
description=(
"Provenance of the resolved value: a [`click.core.ParameterSource`]"
"(https://click.palletsprojects.com/en/stable/api/"
"#click.core.ParameterSource) enum member such as `COMMANDLINE`, "
"`ENVIRONMENT`, `DEFAULT_MAP`, or `DEFAULT`."
),
),
_ColumnSpec(
id="config_file",
label="Config file",
optional=True,
description=(
"The configuration file the effective value was loaded from, "
"when `Source` reports `DEFAULT_MAP`. With "
"[`cascade=True`](config.md#cascading-configuration-files) "
"several files are layered and this column names the one that "
"won the parameter; with a single loaded file, every "
"config-sourced parameter names that file. Empty for every "
"other source and when no configuration file was loaded. "
"Opt-in, like `help`: paths are wide and stay redundant with "
"`Source` until several files take part."
),
),
)
"""Rich column registry for the `--params` table.
Each entry is a {class}`click_extra.table.ColumnSpec` carrying the column's
stable `id` (used by `--columns` and as structured-format key), its
display `label`, and a MyST/Markdown `description` consumed by the
documentation's auto-generated *Available columns* section. Iteration
yields columns in canonical display order.
"""
[docs]
@classmethod
def column_labels(cls) -> tuple[str, ...]:
"""Return just the display labels of {data}`TABLE_HEADERS` (in order)."""
return tuple(col.label for col in cls.TABLE_HEADERS)
[docs]
@classmethod
def column_ids(cls) -> tuple[str, ...]:
"""Return just the stable IDs of {data}`TABLE_HEADERS` (in order)."""
return tuple(col.id for col in cls.TABLE_HEADERS)
[docs]
@classmethod
def default_columns(cls) -> tuple[_ColumnSpec, ...]:
"""Return the columns rendered when `--columns` asks for no projection.
Every column but the {attr}`~click_extra.table.ColumnSpec.optional` ones,
which stay addressable by ID and out of the way until named.
"""
return tuple(col for col in cls.TABLE_HEADERS if not col.optional)
[docs]
@classmethod
def default_column_ids(cls) -> tuple[str, ...]:
"""Return the stable IDs of {meth}`default_columns` (in order)."""
return tuple(col.id for col in cls.default_columns())
[docs]
@classmethod
def default_column_labels(cls) -> tuple[str, ...]:
"""Return the display labels of {meth}`default_columns` (in order)."""
return tuple(col.label for col in cls.default_columns())
[docs]
@classmethod
def find_column(cls, column_id: str):
"""Return the {class}`~click_extra.table.ColumnSpec` matching `column_id`.
Raises `KeyError` if no column has this ID; callers should convert
the error into a {class}`click.UsageError` when surfaced to a user.
"""
for col in cls.TABLE_HEADERS:
if col.id == column_id:
return col
msg = f"Unknown column ID {column_id!r}"
raise KeyError(msg)
[docs]
@classmethod
def render_doc_table(cls) -> str:
"""Render {data}`TABLE_HEADERS` as a Markdown table for documentation.
Used by the `show_params_columns_table` MyST substitution in
`docs/conf.py` to feed the *Available columns* section of
`docs/parameters.md`: editing a description here automatically
rebuilds the docs table on the next `sphinx-build`.
"""
# Imported here to avoid a circular import: table imports from this module.
from .table import render_columns_markdown_table
return render_columns_markdown_table(cls.TABLE_HEADERS)
def __init__(
self,
param_decls: Sequence[str] | None = None,
is_flag=True,
expose_value=False,
is_eager=True,
help=_(
"Show all CLI parameters, their provenance, defaults and value, then exit.",
),
**kwargs,
) -> None:
if not param_decls:
param_decls = ("--params",)
kwargs.setdefault("callback", self.print_params)
self.excluded_params = frozenset()
"""Deactivates the blocking of any parameter."""
self.included_params = None
"""No allowlist filter; show all parameters."""
super().__init__(
param_decls=param_decls,
is_flag=is_flag,
expose_value=expose_value,
is_eager=is_eager,
help=help,
**kwargs,
)
[docs]
def print_params(
self,
ctx: click.Context,
param: click.Parameter,
value: bool,
) -> None:
"""Introspect the current CLI and print its parameter metadata table.
Thin wrapper over
{func}`~click_extra.parameters.render_params_table`, the shared rendering core
also driving `click-extra wrap --params` for foreign CLIs. The
live invocation context carries everything the core needs: the captured
{data}`~click_extra.context.RAW_ARGS` (attached by
`Command`/`Group`) for value and source resolution, plus
any sibling `--table-format` / `--columns` options.
"""
# Exit early if the callback was processed but the option wasn't set.
if not value:
return
# Load the configuration file first, so the value and source columns
# reflect it even when this flag was processed ahead of the --config
# option. Imported here to avoid a circular import with the config
# module, which imports from this one.
from .config import ensure_config_loaded
ensure_config_loaded(ctx)
# Warn when the live command is not a Command: without the captured
# raw arguments, the value/source columns fall back to defaults.
if context.get(ctx, context.RAW_ARGS) is None:
logger.warning(
f"Cannot extract parameters values: "
f"{ctx.command} does not inherits from Command.",
)
render_params_table(ctx)
ctx.exit()
def __getattr__(name: str) -> Any:
"""Resolve deprecated `parameters` symbols via the PEP 562 `__getattr__` hook.
The generic helpers (`patch_attr`, `generator_tag`,
`missing_extra_message`) moved to {mod}`click_extra._utils`. Fires only for
names not defined in this module. See {mod}`click_extra._deprecated`.
"""
from ._deprecated import resolve_deprecated
return resolve_deprecated(__name__, name)