# 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 . import Command, Group
from .colorize import ColorOption, ExtraHelpColorsMixin, HelpExtraFormatter
from .config import ConfigOption, NoConfigOption
from .envvar import clean_envvar_id, param_envvar_ids
from .logging import VerboseOption, VerbosityOption
from .parameters import ExtraOption, ShowParamsOption, search_params
from .table import TableFormatOption
from .timer import TimerOption
from .version import ExtraVersionOption
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Any, NoReturn
from . import Option
DEFAULT_HELP_NAMES: tuple[str, ...] = ("--help", "-h")
[docs]
class ExtraCommand(ExtraHelpColorsMixin, 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: str | None = None,
extra_option_at_end: bool = True,
populate_auto_envvars: bool = True,
**kwargs: Any,
) -> None:
"""List of extra parameters:
:param version: allows a version string to be set directly on the command. Will
be passed to the first instance of ``ExtraVersionOption`` parameter
attached to the command.
: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
@extra_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
if populate_auto_envvars:
for param in self.params:
param.envvar = param_envvar_ids(param, self.context_settings)
if version:
version_param = search_params(self.params, ExtraVersionOption)
if version_param:
version_param.version = version # type: ignore[union-attr]
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) # type: ignore[attr-defined]
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 | NoReturn:
"""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 LazyGroup(ExtraGroup):
"""An ``ExtraGroup`` that supports lazy loading of subcommands.
This implementation adds special handling for ``config_option`` to ensure
configuration values are passed to lazily loaded commands correctly.
.. 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>`_.
And has been adapted 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:
"""Initialize the ``LazyGroup``.
Args:
*args: Positional arguments for the parent class.
lazy_subcommands (dict, optional): Mapping of command names to import paths.
**kwargs: Keyword arguments for the parent class.
.. tip::
``lazy_subcommands`` is a map of the form:
.. code-block:: text
`{"<command-name>": "<module-name>.<command-object-name>"}`
Example:
.. code-block:: text
`{"mycmd": "my_cli.commands.mycmd:mycmd"}`
"""
super().__init__(*args, **kwargs)
self.lazy_subcommands = lazy_subcommands if lazy_subcommands else {}
[docs]
def list_commands(self, ctx: click.Context) -> list[str]:
"""List all commands, including lazy subcommands.
Returns the list of command names, including the lazy-loaded.
"""
base = super().list_commands(ctx)
lazy = sorted(self.lazy_subcommands.keys())
return 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."""
if cmd_name in self.lazy_subcommands:
return self._lazy_load(ctx, cmd_name)
return super().get_command(ctx, cmd_name)
def _lazy_load(self, ctx: click.Context, cmd_name: str) -> click.Command:
"""Lazily load a command from its import path."""
import_path = self.lazy_subcommands[cmd_name]
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 ValueError(
f"Lazy loading of {import_path!r} failed by returning a non-command "
f"object: {cmd_object!r}"
)
# Fix for config_option: ensure name is set correctly.
cmd_object.name = cmd_name
# Extract and apply config at load time, before any parameter resolution
self._apply_config_to_parent_context(ctx, cmd_name)
return cmd_object
def _apply_config_to_parent_context(
self, ctx: click.Context, cmd_name: str
) -> None:
"""Apply configuration values to the parent context's ``default_map``.
This is the key fix for ``config_option`` with lazy commands. Instead of trying
to apply config to the command's context (which doesn't exist yet), we apply it
to the parent context's default_map with the command name as the key.
When Click later creates the command's context, it will automatically inherit
this config through Click's standard context inheritance mechanism.
"""
logger = logging.getLogger("click_extra")
try:
# Skip if no click_extra config was loaded.
if not ctx or not ctx.meta or "click_extra.conf_full" not in ctx.meta:
return
# Get the full configuration loaded by click_extra.
full_config = ctx.meta["click_extra.conf_full"]
if not full_config:
return
# Get root command name.
root = ctx.find_root()
root_name = (
root.command.name if root.command and root.command.name else None
)
if not root_name or root_name not in full_config:
return
# Get parent command name (our current group).
parent_cmd_name = self.name if hasattr(self, "name") else None
if not parent_cmd_name:
return
# Find config for our command.
try:
# Start with root config.
config_branch = full_config[root_name]
# Navigate to parent group's config.
current_ctx = ctx
path_segments: list[str] = []
# Build path from root to our parent group (excluding root).
while current_ctx and current_ctx.parent:
if current_ctx.command and current_ctx.command.name:
if current_ctx.command.name != root_name: # Skip root command.
path_segments.insert(0, current_ctx.command.name)
current_ctx = current_ctx.parent
# Navigate through path segments in config.
for segment in path_segments:
if segment in config_branch and isinstance(
config_branch[segment], dict
):
config_branch = config_branch[segment]
else:
# Path doesn't exist in config.
return
# Now check for our command's config.
if cmd_name in config_branch and isinstance(
config_branch[cmd_name], dict
):
cmd_config = config_branch[cmd_name]
# Initialize parent context's default_map if needed
if ctx.default_map is None:
ctx.default_map = {}
# Set up for this command in parent's default_map
if cmd_name not in ctx.default_map:
ctx.default_map[cmd_name] = {}
# Apply the command's config to parent context's default_map.
# Click will automatically pass this to the command's context.
ctx.default_map[cmd_name].update(cmd_config)
logger.debug(f"Config found for {cmd_name}: {cmd_config}")
# Log error but continue.
except (KeyError, AttributeError, TypeError) as ex:
logger.error(f"Error finding config: {ex}")
# Log error but continue - better to run without config than crash.
except (KeyError, AttributeError, TypeError) as ex:
logger.error(f"Error applying config: {ex}")