Source code for click_extra.decorators

# 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.
"""Decorators for group, commands and options."""

from __future__ import annotations

import inspect
from functools import wraps
from typing import overload

import click
import cloup
from click.decorators import _param_memo

from .accessibility import AccessibleOption
from .color import ColorOption, NoColorOption
from .command_doc import HelpFormatOption, ManOption
from .commands import (
    DEFAULT_HELP_NAMES,
    Command,
    Group,
    LazyGroup,
    _assign_option_groups,
    default_params,
)
from .config import (
    ConfigOption,
    ExportConfigOption,
    NoConfigOption,
    ValidateConfigOption,
)
from .execution import TimerOption, ZeroExitOption
from .jobs import JobsOption
from .logging import DebugOption, QuietOption, VerboseOption, VerbosityOption
from .multicall import MulticallGroup
from .parameters import Argument, Option
from .show_params import ShowParamsOption
from .spinner import ProgressOption
from .table_options import ColumnsOption, SortByOption, TableFormatOption
from .telemetry import TelemetryOption
from .theme import ThemeOption
from .tree import TreeOption
from .version import VersionOption

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

    _AnyCallable = Callable[..., Any]
    CommandT_co = TypeVar("CommandT_co", bound=click.Command, covariant=True)
    SubCommandT = TypeVar("SubCommandT", bound=click.Command)
    ParamT = TypeVar("ParamT", bound=click.Parameter)
    FC = TypeVar("FC", bound="_AnyCallable | click.Command")

    class CommandDecorator(Protocol[CommandT_co]):
        """Static type of the command decorators built by `decorator_factory`.

        Mirrors Click's own overloads for `@command`/`@group` so type
        checkers infer the produced command class, while also covering the
        no-parenthesis form enabled by `allow_missing_parenthesis`.
        """

        @overload
        def __call__(self, name: _AnyCallable, /) -> CommandT_co:
            """Bare `@command` form: the callback is the only argument."""

        @overload
        def __call__(
            self,
            name: str | None = ...,
            *,
            cls: type[SubCommandT],
            **attrs: Any,
        ) -> Callable[[_AnyCallable], SubCommandT]:
            """Parenthesized form with an explicit `cls` override."""

        @overload
        def __call__(
            self,
            name: str | None = ...,
            *,
            cls: None = ...,
            **attrs: Any,
        ) -> Callable[[_AnyCallable], CommandT_co]:
            """Parenthesized form using the decorator's default command class."""

    class ParameterDecorator(Protocol):
        """Static type of the option and argument decorators built by
        `decorator_factory`.

        These decorators attach a parameter to the callback and return it
        unchanged, so the decorated function keeps its own type. The two
        overloads cover the bare (no-parenthesis) and parenthesized forms.
        """

        @overload
        def __call__(self, func: FC, /) -> FC:
            """Bare `@option` form: the callback is the only argument."""

        @overload
        def __call__(self, *param_decls: str, **attrs: Any) -> Callable[[FC], FC]:
            """Parenthesized form: option flags and attributes are forwarded."""


[docs] def allow_missing_parenthesis(dec_factory): """Allow to use decorators with or without parenthesis. As proposed in [Cloup issue #127](https://github.com/janluke/cloup/issues/127#issuecomment-1264704896). """ @wraps(dec_factory) def new_factory(*args, **kwargs): if args and callable(args[0]): return dec_factory(*args[1:], **kwargs)(args[0]) return dec_factory(*args, **kwargs) return new_factory
@overload def decorator_factory( dec: Any, *new_args: Any, cls: type[CommandT_co], **new_defaults: Any ) -> CommandDecorator[CommandT_co]: ... @overload def decorator_factory( dec: Any, *new_args: Any, cls: type[ParamT] | None = ..., **new_defaults: Any ) -> ParameterDecorator: ...
[docs] def decorator_factory(dec, *new_args, **new_defaults): """Clone decorator with a set of new defaults. Builds Click Extra's decorators for its own commands and options out of Cloup's. The two overloads give static type checkers a precise signature for the decorators this factory produces: command-style decorators (`cls` is a `click.Command` subclass) report the resulting command class, while parameter-style decorators (`cls` is a `click.Parameter` subclass, or absent) return the decorated callback unchanged. Both overloads model the optional-parenthesis behaviour added by `allow_missing_parenthesis`, which plain inference cannot recover. See `CommandDecorator` and `ParameterDecorator` for the produced shapes. ```{attention} The `cls` argument passed to the factory is used as the reference class from which the produced decorator's `cls` argument must inherit. That way the `@command` decorator re-implemented by Click Extra always produces a subclass of `Command`, even when the user overrides the `cls` argument, so it can rely on the properties and methods Click Extra adds on top of Cloup and Click. ``` """ @allow_missing_parenthesis def decorator(*args, **kwargs): """Returns a new decorator instantiated with custom defaults. These defaults values are merged with the user's own arguments. A special case is made for the `params` argument, to allow it to be callable. This limits the issue of the mutable options being shared between commands. This decorator can be used with or without arguments. """ if not args: args = new_args # Validate that the provided 'cls' is a subclass of the default one. if ( "cls" in new_defaults and "cls" in kwargs and not issubclass(kwargs["cls"], new_defaults["cls"]) ): mro_list = ", ".join( f"{k.__module__}.{k.__name__}" for k in kwargs["cls"].__mro__ ) raise TypeError( f"The 'cls' argument must be a subclass of " f"{new_defaults['cls'].__name__}, got: {mro_list}" ) def merged_kwargs() -> dict[str, Any]: """The defaults under the caller's arguments, the `params` hook called. A callable `params` is called for a fresh list of options each time, so the mutable options are never shared between commands. The hook's output gets its help-screen sections back: an option it swapped for a subclass of it was built after `default_params` filed the original. See `_assign_option_groups`. """ merged = {**new_defaults, **kwargs} params = merged.get("params") if callable(params): hook_params = params() _assign_option_groups(hook_params) merged["params"] = hook_params return merged has_hook = callable(kwargs.get("params", new_defaults.get("params"))) # Return the original decorator with the new defaults. result = dec(*args, **merged_kwargs()) # When the result is a decorator (not a Command), and `params` is a hook, # the decorator captures the evaluated params list in its closure. Click # mutates that list with params.extend() on every application to a # function, so a pre-instantiated decorator (like one stored in a pytest # parametrize list as `command()`) would accumulate options across uses, # creating duplicate parameters. So only the first application takes the # decorator built above, and each later one calls the hook again. if has_hook and not isinstance(result, click.Command): unused = [result] def apply(f): return (unused.pop() if unused else dec(*args, **merged_kwargs()))(f) return apply return result # Surface the parameter class's constructor signature on the produced decorator, # instead of the opaque `(*args, **kwargs)`, so editors, `help()` and Sphinx # autodoc show the real options. Restricted to parameter decorators (`option`, # `argument` and their subclasses): their `cls.__init__` mirrors what the # decorator forwards, whereas `command`/`group` wrap a different (Cloup) # signature and `help_option` has no `cls`. cls = new_defaults.get("cls") if isinstance(cls, type) and issubclass(cls, click.Parameter): try: init_params = list(inspect.signature(cls.__init__).parameters.values()) except (TypeError, ValueError): pass else: # Drop `self` and keep the rest as the decorator's public signature. decorator.__signature__ = inspect.Signature(init_params[1:]) return decorator
# Replace and extend existing Click and Cloup commands decorators. command = decorator_factory( dec=cloup.command, cls=Command, params=default_params, ) group = decorator_factory( dec=cloup.group, cls=Group, params=default_params, ) # Replace and extend existing Click parameter decorators. option = decorator_factory(dec=cloup.option, cls=Option) argument = decorator_factory(dec=cloup.argument, cls=Argument) help_option = decorator_factory(click.decorators.help_option, *DEFAULT_HELP_NAMES) def _register_grouped_option(f, option, group): """Memoize *option* on *f*, then wire its Cloup option-group membership. The shared tail of the hand-written `version_option` and `sort_by_option` decorators (the ones the `param_decls`-first `decorator_factory` cannot build): both register an already-built option with the `_param_memo` primitive Cloup uses, then attach the option to *group* (hiding it when the group is hidden). Returns *f* so a decorator can `return` it directly. """ _param_memo(f, option) new_option = f.__click_params__[-1] new_option.group = group if group and group.hidden: new_option.hidden = True return f
[docs] @allow_missing_parenthesis def version_option(version=None, *param_decls, cls=VersionOption, group=None, **kwargs): """Attach a {class}`~click_extra.version.VersionOption` to a command. Drop-in compatible with Click's `@version_option`: the first positional argument may be an explicit version string. click-extra otherwise auto-detects the version and treats positional arguments as option flags (like every other option decorator), so the two are disambiguated by their leading character: a value starting with `-` is a flag declaration, anything else is a Click-style version string forwarded into the `version` template field. ```{code-block} python @command @version_option("1.2.3") # Click idiom: pins the displayed version. def my_cmd(): ... ``` ```{note} Hand-written instead of produced by {func}`~click_extra.decorators.decorator_factory` because Click's leading `version` positional conflicts with the `param_decls`-first convention the factory relies on. ``` """ if version is not None and not str(version).startswith("-"): # Click idiom `@version_option("1.2.3")`: forward as a `version` override. fields = dict(kwargs.pop("fields", None) or {}) if fields.get("version", version) != version: raise TypeError( "version supplied both positionally and via fields={'version': ...}.", ) fields["version"] = version kwargs["fields"] = fields elif version is not None: # Leading `-`: a flag declaration, not a version. Restore it as a param_decl. param_decls = (version, *param_decls) def decorator(f): return _register_grouped_option(f, cls(param_decls, **kwargs), group) return decorator
# Introduce new commands decorators specific to Click Extra. lazy_group = decorator_factory(dec=group, cls=LazyGroup) multicall_group = decorator_factory(dec=group, cls=MulticallGroup) # Introduce new parameter decorators specific to Click Extra. accessible_option = decorator_factory(dec=option, cls=AccessibleOption) color_option = decorator_factory(dec=option, cls=ColorOption) columns_option = decorator_factory(dec=option, cls=ColumnsOption) config_option = decorator_factory(dec=option, cls=ConfigOption) debug_option = decorator_factory(dec=option, cls=DebugOption) export_config_option = decorator_factory(dec=option, cls=ExportConfigOption) help_format_option = decorator_factory(dec=option, cls=HelpFormatOption) jobs_option = decorator_factory(dec=option, cls=JobsOption) man_option = decorator_factory(dec=option, cls=ManOption) no_color_option = decorator_factory(dec=option, cls=NoColorOption) no_config_option = decorator_factory(dec=option, cls=NoConfigOption) progress_option = decorator_factory(dec=option, cls=ProgressOption) quiet_option = decorator_factory(dec=option, cls=QuietOption) show_params_option = decorator_factory(dec=option, cls=ShowParamsOption) table_format_option = decorator_factory(dec=option, cls=TableFormatOption) telemetry_option = decorator_factory(dec=option, cls=TelemetryOption) theme_option = decorator_factory(dec=option, cls=ThemeOption) timer_option = decorator_factory(dec=option, cls=TimerOption) tree_option = decorator_factory(dec=option, cls=TreeOption) validate_config_option = decorator_factory(dec=option, cls=ValidateConfigOption) verbose_option = decorator_factory(dec=option, cls=VerboseOption) verbosity_option = decorator_factory(dec=option, cls=VerbosityOption) zero_exit_option = decorator_factory(dec=option, cls=ZeroExitOption)
[docs] def sort_by_option(*header_defs, cls=SortByOption, group=None, **kwargs): """Attach a {class}`~click_extra.table_options.SortByOption` to a command. Forwards the positional `header_defs` ({class}`~click_extra.columns.ColumnSpec` instances or bare column IDs) straight to the option constructor and registers it with the same `_param_memo` primitive Cloup uses, so the `--sort-by` option composes with `@option_group` and `@constraint` like any other option decorator. ```{note} Hand-written instead of produced by {func}`~click_extra.decorators.decorator_factory` because {class}`~click_extra.table_options.SortByOption` accepts its column definitions as positional arguments, which conflicts with the `param_decls`-first convention the factory relies on. Routing through the factory would force them into a keyword. ``` """ def decorator(f): return _register_grouped_option(f, cls(*header_defs, **kwargs), group) return decorator