Source code for click_extra.commands

# 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.
"""Wraps vanilla Click and Cloup commands with extra features.

Our flavor of commands, groups and context are all subclasses of their vanilla
counterparts, but are pre-configured with good and common defaults. You can still
leverage the mixins in here to build up your own custom variants.
"""

from __future__ import annotations

import importlib
import logging

import click
import cloup

from .colorize import ColorOption, ExtraHelpColorsMixin, HelpExtraFormatter
from .config import (
    DEFAULT_SUBCOMMANDS_KEY,
    PREPEND_SUBCOMMANDS_KEY,
    ConfigOption,
    NoConfigOption,
    ValidateConfigOption,
)
from .envvar import clean_envvar_id, param_envvar_ids
from .logging import VerboseOption, VerbosityOption
from .parameters import ExtraOption, ShowParamsOption
from .table import TableFormatOption
from .timer import TimerOption
from .version import ExtraVersionOption

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Sequence
    from typing import Any


[docs] class ExtraContext(cloup.Context): """Like ``cloup._context.Context``, but with the ability to populate the context's ``meta`` property at instantiation. Also defaults ``color`` to ``True`` for root contexts (i.e. without a parent), so help screens are always colorized β€” even when piped. Click's own default is ``None`` (auto-detect via TTY), which strips colors in non-interactive contexts. Parent-to-child color inheritance is handled by Click itself at ``Context.__init__`` time, so no property override is needed. .. todo:: Propose addition of ``meta`` keyword upstream to Click. """ formatter_class = HelpExtraFormatter """Use our own formatter to colorize the help screen.""" def __init__(self, *args, meta: dict[str, Any] | None = None, **kwargs) -> None: """Like parent's context but with an extra ``meta`` keyword-argument. Also force ``color`` default to ``True`` if not provided by user and this context has no parent. """ super().__init__(*args, **kwargs) # Click defaults root ``ctx.color`` to ``None`` (auto-detect via TTY), which # strips colors when piped. Override to ``True`` for parentless contexts so # help screens are always colorized by default. The ``ColorOption`` callback # will set the final value later, respecting ``--no-color`` and env vars. if not self.parent and self.color is None: self.color = True # Update the context's meta property with the one provided by user. if meta: self._meta.update(meta)
[docs] def default_extra_params() -> list[click.Option]: """Default additional options added to ``@command`` and ``@group``. .. caution:: The order of options has been carefully crafted to handle subtle edge-cases and avoid leaky states in unittests. You can still override this hard-coded order for easthetic reasons and it should be fine. Your end-users are unlikely to be affected by these sneaky bugs, as the CLI context is going to be naturraly reset after each invocation (which is not the case in unitests). #. ``--time`` / ``--no-time`` .. hint:: ``--time`` is placed at the top of all other eager options so all other options' processing time can be measured. #. ``--config CONFIG_PATH`` .. hint:: ``--config`` is at the top so it can have a direct influence on the default behavior and value of the other options. #. ``--no-config`` #. ``--validate-config CONFIG_PATH`` #. ``--color``, ``--ansi`` / ``--no-color``, ``--no-ansi`` #. ``--show-params`` #. ``--table-format FORMAT`` #. ``--verbosity LEVEL`` #. ``-v``, ``--verbose`` #. ``--version`` #. ``-h``, ``--help`` .. attention:: This is the option produced by the `@click.decorators.help_option <https://click.palletsprojects.com/en/stable/api/#click.help_option>`_ decorator. It is not explicitly referenced in the implementation of this function. That's because it's `going to be added by Click itself <https://github.com/pallets/click/blob/c9f7d9d/src/click/core.py#L966-L969>`_, at the end of the list of options. By letting Click handle this, we ensure that the help option will take into account the `help_option_names <https://click.palletsprojects.com/en/stable/documentation/#help-parameter-customization>`_ setting. .. important:: Sensitivity to order still remains to be proven. With the code of Click Extra and its dependencies moving fast, there is a non-zero chance that all the options are now sound enough to be re-ordered in a more natural way. .. todo:: For bullet-proof handling of edge-cases, we should probably add an indirection layer to have the processing order of options (the one below) different from the presentation order of options in the help screen. This is probably something that has been `requested in issue #544 <https://github.com/kdeldycke/click-extra/issues/544>`_. """ return [ TimerOption(), ColorOption(), ConfigOption(), NoConfigOption(), ValidateConfigOption(), ShowParamsOption(), TableFormatOption(), VerbosityOption(), VerboseOption(), ExtraVersionOption(), # @click.decorators.help_option(), ]
DEFAULT_HELP_NAMES: tuple[str, ...] = ("--help", "-h")
[docs] class ExtraCommand(ExtraHelpColorsMixin, cloup.Command): # type: ignore[misc] """Like ``cloup.command``, with sane defaults and extra help screen colorization.""" context_class: type[cloup.Context] = ExtraContext def __init__( self, *args, version_fields: dict[str, Any] | None = None, config_schema: type | Callable[[dict[str, Any]], Any] | None = None, fallback_sections: Sequence[str] = (), extra_option_at_end: bool = True, populate_auto_envvars: bool = True, **kwargs: Any, ) -> None: """List of extra parameters: :param version_fields: dictionary of ``ExtraVersionOption`` template field overrides forwarded to the version option. Accepts any field from ``ExtraVersionOption.template_fields`` (e.g. ``prog_name``, ``version``, ``git_branch``). Lets you customize ``--version`` output from the command decorator without replacing the default ``params`` list. :param extra_option_at_end: `reorders all parameters attached to the command <https://kdeldycke.github.io/click-extra/commands.html#option-order>`_, by moving all instances of ``ExtraOption`` at the end of the parameter list. The original order of the options is preserved among themselves. :param populate_auto_envvars: forces all parameters to have their auto-generated environment variables registered. This address the shortcoming of ``click`` which only evaluates them dynamiccaly. By forcing their registration, the auto-generated environment variables gets displayed in the help screen, fixing `click#2483 issue <https://github.com/pallets/click/issues/2483>`_. On Windows, environment variable names are case-insensitive, so we normalize them to uppercase. By default, these `Click context settings <https://click.palletsprojects.com/en/stable/api/#click.Context>`_ are applied: - ``auto_envvar_prefix = self.name`` (*Click feature*) Auto-generate environment variables for all options, using the command ID as prefix. The prefix is normalized to be uppercased and all non-alphanumerics replaced by underscores. - ``help_option_names = ("--help", "-h")`` (*Click feature*) `Allow help screen to be invoked with either --help or -h options <https://click.palletsprojects.com/en/stable/documentation/#help-parameter-customization>`_. - ``show_default = True`` (*Click feature*) `Show all default values <https://click.palletsprojects.com/en/stable/api/#click.Context.show_default>`_ in help screen. Additionally, these `Cloup context settings <https://cloup.readthedocs.io/en/stable/pages/formatting.html#formatting-settings>`_ are set: - ``align_option_groups = False`` (*Cloup feature*) `Aligns option groups in help screen <https://cloup.readthedocs.io/en/stable/pages/option-groups.html#aligned-vs-non-aligned-groups>`_. - ``show_constraints = True`` (*Cloup feature*) `Show all constraints in help screen <https://cloup.readthedocs.io/en/stable/pages/constraints.html#the-constraint-decorator>`_. - ``show_subcommand_aliases = True`` (*Cloup feature*) `Show all subcommand aliases in help screen <https://cloup.readthedocs.io/en/stable/pages/aliases.html?highlight=show_subcommand_aliases#help-output-of-the-group>`_. Click Extra also adds its own ``context_settings``: - ``show_choices = None`` (*Click Extra feature*) If set to ``True`` or ``False``, will force that value on all options, so we can globally show or hide choices when prompting a user for input. Only makes sense for options whose ``prompt`` property is set. Defaults to ``None``, which will leave all options untouched, and let them decide of their own ``show_choices`` setting. - ``show_envvar = None`` (*Click Extra feature*) If set to ``True`` or ``False``, will force that value on all options, so we can globally enable or disable the display of environment variables in help screen. Defaults to ``None``, which will leave all options untouched, and let them decide of their own ``show_envvar`` setting. The rationale being that discoverability of environment variables is enabled by the ``--show-params`` option, which is active by default on extra commands. So there is no need to surcharge the help screen. This addresses the `click#2313 issue <https://github.com/pallets/click/issues/2313>`_. To override these defaults, you can pass your own settings with the ``context_settings`` parameter: .. code-block:: python @command( context_settings={ "show_default": False, ... } ) """ super().__init__(*args, **kwargs) # List of additional global settings for options. extra_option_settings = [ "show_choices", "show_envvar", ] default_ctx_settings: dict[str, Any] = { # Click settings: # "default_map": {"verbosity": "DEBUG"}, "help_option_names": DEFAULT_HELP_NAMES, "show_default": True, # Cloup settings: "align_option_groups": False, "show_constraints": True, "show_subcommand_aliases": True, # Click Extra settings: "show_choices": None, "show_envvar": None, } # Generate environment variables for all options based on the command name. if self.name: default_ctx_settings["auto_envvar_prefix"] = clean_envvar_id(self.name) # Merge defaults and user settings. default_ctx_settings.update(self.context_settings) # If set, force extra settings on all options. for setting in extra_option_settings: if default_ctx_settings[setting] is not None: for param in self.params: # These attributes are specific to options. if isinstance(param, click.Option): param.show_envvar = default_ctx_settings[setting] # Remove Click Extra-specific settings, before passing it to Cloup and Click. for setting in extra_option_settings: del default_ctx_settings[setting] self.context_settings: dict[str, Any] = default_ctx_settings # Forward version template fields to the version option. if version_fields: for param in self.params: if isinstance(param, ExtraVersionOption): for field_id, field_value in version_fields.items(): if field_id not in param.template_fields: msg = ( f"Unknown version field {field_id!r}." f" Must be one of {param.template_fields}." ) raise TypeError(msg) setattr(param, field_id, field_value) # Forward config schema and fallback sections to the config option. if config_schema is not None or fallback_sections: for param in self.params: if isinstance(param, ConfigOption): if config_schema is not None: param.config_schema = config_schema param._config_schema_callable = param._make_schema_callable( config_schema ) if fallback_sections: param.fallback_sections = tuple(fallback_sections) if populate_auto_envvars: for param in self.params: param.envvar = param_envvar_ids(param, self.context_settings) if extra_option_at_end: self.params.sort(key=lambda p: isinstance(p, ExtraOption)) # Forces re-identification of grouped and non-grouped options as we re-ordered # them above and added our own extra options since initialization. _grouped_params = self._group_params(self.params) self.arguments, self.option_groups, self.ungrouped_options = _grouped_params
[docs] def main( # type: ignore[override] self, args: Sequence[str] | None = None, prog_name: str | None = None, **kwargs: Any, ) -> Any: """Pre-invocation step that is instantiating the context, then call ``invoke()`` within it. .. caution:: During context instantiation, each option's callbacks are called. These might break the execution flow (like ``--help`` or ``--version``). Sets the default CLI's ``prog_name`` to the command's name if not provided, instead of relying on Click's auto-detection via the ``_detect_program_name()`` method. This is to avoid the CLI being called ``python -m <module_name>``, which is not very user-friendly. """ if not prog_name and self.name: prog_name = self.name return super().main(args=args, prog_name=prog_name, **kwargs)
[docs] def make_context( self, info_name: str | None, args: list[str], parent: click.Context | None = None, **extra: Any, ) -> Any: """Intercept the call to the original ``click.core.Command.make_context`` so we can keep a copy of the raw, pre-parsed arguments provided to the CLI. The result are passed to our own ``ExtraContext`` constructor which is able to initialize the context's ``meta`` property under our own ``click_extra.raw_args`` entry. This will be used in ``ShowParamsOption.print_params()`` to print the table of parameters fed to the CLI. .. seealso:: This workaround is being discussed upstream in `click#1279 <https://github.com/pallets/click/issues/1279#issuecomment-1493348208>`_. """ # ``args`` needs to be copied: its items are consumed by the parsing process. extra.update({"meta": {"click_extra.raw_args": args.copy()}}) return super().make_context(info_name, args, parent, **extra)
[docs] def invoke(self, ctx: click.Context) -> Any: """Main execution of the command, just after the context has been instantiated in ``main()``. """ return super().invoke(ctx)
[docs] class ExtraGroup(ExtraCommand, cloup.Group): # type: ignore[misc] """Like ``cloup.Group``, with sane defaults and extra help screen colorization.""" command_class = ExtraCommand """Makes commands of an ``ExtraGroup`` be instances of ``ExtraCommand``. That way all subcommands created from an ``ExtraGroup`` benefits from the same defaults and extra help screen colorization. See: https://click.palletsprojects.com/en/stable/api/#click.Group.command_class """ group_class = type """Let ``ExtraGroup`` produce sub-groups that are also of ``ExtraGroup`` type. See: https://click.palletsprojects.com/en/stable/api/#click.Group.group_class """
[docs] def invoke(self, ctx: click.Context) -> Any: """Inject ``_default_subcommands`` and ``_prepend_subcommands`` from config. If the user has not provided any subcommands explicitly, and the loaded configuration contains a ``_default_subcommands`` list for this group, those subcommands are injected into ``ctx.protected_args`` so that Click's normal ``Group.invoke()`` dispatches them. ``_prepend_subcommands`` always prepends subcommands to the invocation, regardless of whether CLI subcommands were provided. Only works with ``chain=True`` groups. """ if not ctx._protected_args and not ctx.args: default_subcmds = self._get_default_subcommands(ctx) if default_subcmds is not None: ctx._protected_args = list(default_subcmds) elif ctx._protected_args or ctx.args: # CLI subcommands were given explicitly; log if config defaults exist. default_subcmds = self._get_default_subcommands(ctx) if default_subcmds is not None: logger = logging.getLogger("click_extra") logger.debug( f"CLI subcommands provided; ignoring {DEFAULT_SUBCOMMANDS_KEY}" f" config: {default_subcmds!r}." ) # Always prepend _prepend_subcommands, regardless of CLI args. prepend_subcmds = self._get_prepend_subcommands(ctx) if prepend_subcmds is not None: logger = logging.getLogger("click_extra") logger.info( f"Prepending {PREPEND_SUBCOMMANDS_KEY} config: {prepend_subcmds!r}." ) ctx._protected_args = list(prepend_subcmds) + ctx._protected_args return super().invoke(ctx)
def _get_default_subcommands(self, ctx: click.Context) -> list[str] | None: """Read and validate ``_default_subcommands`` from the loaded configuration.""" full_config = ctx.meta.get("click_extra.conf_full") if not full_config: return None root_ctx = ctx.find_root() config_branch = full_config.get(root_ctx.command.name) if not isinstance(config_branch, dict): return None # Walk from root context down to the current group. path: list[str] = [] current: click.Context | None = ctx while current is not None and current is not root_ctx: if current.command.name is not None: path.append(current.command.name) current = current.parent path.reverse() for segment in path: config_branch = config_branch.get(segment) if not isinstance(config_branch, dict): return None raw = config_branch.get(DEFAULT_SUBCOMMANDS_KEY) if raw is None: return None # Validate type. if not isinstance(raw, list) or not all(isinstance(s, str) for s in raw): raise click.UsageError( f"{DEFAULT_SUBCOMMANDS_KEY} must be a list of strings, got {raw!r}." ) if not raw: return None # Deduplicate, keeping first occurrence, and warn on duplicates. seen: set[str] = set() deduped: list[str] = [] for name in raw: if name in seen: continue seen.add(name) deduped.append(name) if len(deduped) < len(raw): logger = logging.getLogger("click_extra") logger.warning( f"Duplicate entries in {DEFAULT_SUBCOMMANDS_KEY}: {raw!r}. " f"Keeping first occurrences: {deduped!r}." ) raw = deduped # Non-chained groups can only have one default subcommand. if not self.chain and len(raw) > 1: raise click.UsageError( f"Non-chained group {self.name!r} can have at most 1 default " f"subcommand, got {len(raw)}: {raw!r}." ) # Validate that all subcommands exist. for name in raw: if self.get_command(ctx, name) is None: raise click.UsageError( f"Default subcommand {name!r} not found in group {self.name!r}." ) return raw def _get_prepend_subcommands(self, ctx: click.Context) -> list[str] | None: """Read and validate ``_prepend_subcommands`` from the loaded configuration.""" full_config = ctx.meta.get("click_extra.conf_full") if not full_config: return None root_ctx = ctx.find_root() config_branch = full_config.get(root_ctx.command.name) if not isinstance(config_branch, dict): return None # Walk from root context down to the current group. path: list[str] = [] current: click.Context | None = ctx while current is not None and current is not root_ctx: if current.command.name is not None: path.append(current.command.name) current = current.parent path.reverse() for segment in path: config_branch = config_branch.get(segment) if not isinstance(config_branch, dict): return None raw = config_branch.get(PREPEND_SUBCOMMANDS_KEY) if raw is None: return None # Validate type. if not isinstance(raw, list) or not all(isinstance(s, str) for s in raw): raise click.UsageError( f"{PREPEND_SUBCOMMANDS_KEY} must be a list of strings, got {raw!r}." ) if not raw: return None # Prepend subcommands only work with chained groups. if not self.chain: raise click.UsageError( f"{PREPEND_SUBCOMMANDS_KEY} requires chain=True on group {self.name!r}." ) # Deduplicate, keeping first occurrence, and warn on duplicates. seen: set[str] = set() deduped: list[str] = [] for name in raw: if name in seen: continue seen.add(name) deduped.append(name) if len(deduped) < len(raw): logger = logging.getLogger("click_extra") logger.warning( f"Duplicate entries in {PREPEND_SUBCOMMANDS_KEY}: {raw!r}. " f"Keeping first occurrences: {deduped!r}." ) raw = deduped # Validate that all subcommands exist. for name in raw: if self.get_command(ctx, name) is None: raise click.UsageError( f"Prepend subcommand {name!r} not found in group {self.name!r}." ) return raw
[docs] class LazyGroup(ExtraGroup): """An ``ExtraGroup`` that supports lazy loading of subcommands. .. hint:: This implementation is based on the snippet from Click's documentation: `Defining the lazy group <https://click.palletsprojects.com/en/stable/complex/#defining-the-lazy-group>`_. It has been extended to work with Click Extra's ``config_option`` in `click_extra#1332 issue <https://github.com/kdeldycke/click-extra/issues/1332#issuecomment-3299486142>`_. """ def __init__( self, *args: Any, lazy_subcommands: dict[str, str] | None = None, **kwargs: Any, ) -> None: """``lazy_subcommands`` maps command names to their import paths. .. tip:: ``lazy_subcommands`` is a map of the form: .. code-block:: python {"<command-name>": "<module-name>.<command-object-name>"} Example: .. code-block:: python {"mycmd": "my_cli.commands.mycmd"} """ super().__init__(*args, **kwargs) # Sort for predictable and stable lazy loading order. self.lazy_subcommands: dict[str, str] = ( dict(sorted(lazy_subcommands.items())) if lazy_subcommands else {} )
[docs] def list_commands(self, ctx: click.Context) -> list[str]: """List all commands, including not-yet-loaded lazy subcommands.""" base = super().list_commands(ctx) # Only include lazy subcommands that haven't been loaded into # self.commands yet (add_command moves them there). lazy = [name for name in self.lazy_subcommands if name not in self.commands] return sorted(base + lazy)
[docs] def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: """Get a command by name, loading lazily if necessary. .. todo:: Allow passing extra parameters to the ``self.lazy_subcommands`` so we can register commands with custom settings like Cloup's ``section`` or ``fallback_to_default_section``: - section: Optional[Section] = None, - fallback_to_default_section: bool = True, See: https://github.com/janluke/cloup/blob/master/cloup/_sections.py#L169 """ if cmd_name in self.lazy_subcommands and cmd_name not in self.commands: cmd_object = self._lazy_load(cmd_name) # Register with Click's API so help and Cloup sections work properly. self.add_command(cmd_object) # Inject the lazy command's config section into the context's # default_map, since it was missed by ConfigOption.merge_default_map. self._apply_config_to_parent_context(ctx, cmd_name) return super().get_command(ctx, cmd_name)
def _lazy_load(self, cmd_name: str) -> click.Command: """Import and return the command object for ``cmd_name``.""" import_path = self.lazy_subcommands[cmd_name] if "." not in import_path: raise ValueError( f"Lazy subcommand {cmd_name!r} has invalid import path " f"{import_path!r}: expected 'module.attribute' form." ) modname, cmd_object_name = import_path.rsplit(".", 1) mod = importlib.import_module(modname) cmd_object = getattr(mod, cmd_object_name) if not isinstance(cmd_object, click.Command): raise TypeError( f"Lazy loading of {import_path!r} failed by returning a non-command " f"object: {cmd_object!r}" ) # Override name with the lazy_subcommands key, since the imported # command object may have a different name. cmd_object.name = cmd_name return cmd_object def _apply_config_to_parent_context( self, ctx: click.Context, cmd_name: str ) -> None: """Inject a lazy command's config into ``ctx.default_map``. Lazy commands are not yet registered when ``ConfigOption.merge_default_map`` builds ``params_template``, so their config sections get filtered out. This method compensates by reading the full config from ``ctx.meta`` and placing the lazy command's section into ``ctx.default_map[cmd_name]``. Click will then pass that dict as the ``default_map`` of the command's own context. """ full_config = ctx.meta.get("click_extra.conf_full") if not full_config: return # Descend into the root command's config section. root_ctx = ctx.find_root() config_branch = full_config.get(root_ctx.command.name) if not isinstance(config_branch, dict): return # For nested lazy groups, walk from the current context up to the root # to collect intermediate group names, then descend through the config. path: list[str] = [] current: click.Context | None = ctx while current is not None and current is not root_ctx: if current.command.name is not None: path.append(current.command.name) current = current.parent path.reverse() for segment in path: config_branch = config_branch.get(segment) if not isinstance(config_branch, dict): return # Extract the lazy command's config section. cmd_config = config_branch.get(cmd_name) if not isinstance(cmd_config, dict): return if ctx.default_map is None: ctx.default_map = {} ctx.default_map.setdefault(cmd_name, {}).update(cmd_config) logging.getLogger("click_extra").debug( f"Lazy config for {cmd_name!r}: {cmd_config}" )