diff --git a/.buildinfo b/.buildinfo new file mode 100644 index 000000000..abb17c924 --- /dev/null +++ b/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 83fb934d6434783524fa8970fcaeb88b +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/.doctrees/changelog.doctree b/.doctrees/changelog.doctree new file mode 100644 index 000000000..a2a1340f4 Binary files /dev/null and b/.doctrees/changelog.doctree differ diff --git a/.doctrees/click_extra.doctree b/.doctrees/click_extra.doctree new file mode 100644 index 000000000..35d0647ef Binary files /dev/null and b/.doctrees/click_extra.doctree differ diff --git a/.doctrees/click_extra.tests.doctree b/.doctrees/click_extra.tests.doctree new file mode 100644 index 000000000..560076fed Binary files /dev/null and b/.doctrees/click_extra.tests.doctree differ diff --git a/.doctrees/code-of-conduct.doctree b/.doctrees/code-of-conduct.doctree new file mode 100644 index 000000000..28b6bb3af Binary files /dev/null and b/.doctrees/code-of-conduct.doctree differ diff --git a/.doctrees/colorize.doctree b/.doctrees/colorize.doctree new file mode 100644 index 000000000..e19721113 Binary files /dev/null and b/.doctrees/colorize.doctree differ diff --git a/.doctrees/commands.doctree b/.doctrees/commands.doctree new file mode 100644 index 000000000..d5d774f88 Binary files /dev/null and b/.doctrees/commands.doctree differ diff --git a/.doctrees/config.doctree b/.doctrees/config.doctree new file mode 100644 index 000000000..5490c1092 Binary files /dev/null and b/.doctrees/config.doctree differ diff --git a/.doctrees/environment.pickle b/.doctrees/environment.pickle new file mode 100644 index 000000000..9228ae4df Binary files /dev/null and b/.doctrees/environment.pickle differ diff --git a/.doctrees/index.doctree b/.doctrees/index.doctree new file mode 100644 index 000000000..a75babd94 Binary files /dev/null and b/.doctrees/index.doctree differ diff --git a/.doctrees/install.doctree b/.doctrees/install.doctree new file mode 100644 index 000000000..599d941f5 Binary files /dev/null and b/.doctrees/install.doctree differ diff --git a/.doctrees/issues.doctree b/.doctrees/issues.doctree new file mode 100644 index 000000000..cafaa7515 Binary files /dev/null and b/.doctrees/issues.doctree differ diff --git a/.doctrees/license.doctree b/.doctrees/license.doctree new file mode 100644 index 000000000..c67f96a19 Binary files /dev/null and b/.doctrees/license.doctree differ diff --git a/.doctrees/logging.doctree b/.doctrees/logging.doctree new file mode 100644 index 000000000..d213350db Binary files /dev/null and b/.doctrees/logging.doctree differ diff --git a/.doctrees/parameters.doctree b/.doctrees/parameters.doctree new file mode 100644 index 000000000..1bd9526d1 Binary files /dev/null and b/.doctrees/parameters.doctree differ diff --git a/.doctrees/platforms.doctree b/.doctrees/platforms.doctree new file mode 100644 index 000000000..c80e4ebbf Binary files /dev/null and b/.doctrees/platforms.doctree differ diff --git a/.doctrees/pygments.doctree b/.doctrees/pygments.doctree new file mode 100644 index 000000000..11c6b8a17 Binary files /dev/null and b/.doctrees/pygments.doctree differ diff --git a/.doctrees/sphinx.doctree b/.doctrees/sphinx.doctree new file mode 100644 index 000000000..d58cc6ebd Binary files /dev/null and b/.doctrees/sphinx.doctree differ diff --git a/.doctrees/tabulate.doctree b/.doctrees/tabulate.doctree new file mode 100644 index 000000000..8905f0862 Binary files /dev/null and b/.doctrees/tabulate.doctree differ diff --git a/.doctrees/testing.doctree b/.doctrees/testing.doctree new file mode 100644 index 000000000..173d0bf98 Binary files /dev/null and b/.doctrees/testing.doctree differ diff --git a/.doctrees/timer.doctree b/.doctrees/timer.doctree new file mode 100644 index 000000000..01bb7c3a3 Binary files /dev/null and b/.doctrees/timer.doctree differ diff --git a/.doctrees/todolist.doctree b/.doctrees/todolist.doctree new file mode 100644 index 000000000..2b87135d4 Binary files /dev/null and b/.doctrees/todolist.doctree differ diff --git a/.doctrees/tutorial.doctree b/.doctrees/tutorial.doctree new file mode 100644 index 000000000..84749741b Binary files /dev/null and b/.doctrees/tutorial.doctree differ diff --git a/.doctrees/version.doctree b/.doctrees/version.doctree new file mode 100644 index 000000000..345b5b05f Binary files /dev/null and b/.doctrees/version.doctree differ diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/_modules/click/core.html b/_modules/click/core.html new file mode 100644 index 000000000..1f7e61661 --- /dev/null +++ b/_modules/click/core.html @@ -0,0 +1,3371 @@ + + + + + + + + click.core - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.core

+import enum
+import errno
+import inspect
+import os
+import sys
+import typing as t
+from collections import abc
+from contextlib import contextmanager
+from contextlib import ExitStack
+from functools import update_wrapper
+from gettext import gettext as _
+from gettext import ngettext
+from itertools import repeat
+from types import TracebackType
+
+from . import types
+from .exceptions import Abort
+from .exceptions import BadParameter
+from .exceptions import ClickException
+from .exceptions import Exit
+from .exceptions import MissingParameter
+from .exceptions import UsageError
+from .formatting import HelpFormatter
+from .formatting import join_options
+from .globals import pop_context
+from .globals import push_context
+from .parser import _flag_needs_value
+from .parser import OptionParser
+from .parser import split_opt
+from .termui import confirm
+from .termui import prompt
+from .termui import style
+from .utils import _detect_program_name
+from .utils import _expand_args
+from .utils import echo
+from .utils import make_default_short_help
+from .utils import make_str
+from .utils import PacifyFlushWrapper
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+    from .shell_completion import CompletionItem
+
+F = t.TypeVar("F", bound=t.Callable[..., t.Any])
+V = t.TypeVar("V")
+
+
+def _complete_visible_commands(
+    ctx: "Context", incomplete: str
+) -> t.Iterator[t.Tuple[str, "Command"]]:
+    """List all the subcommands of a group that start with the
+    incomplete value and aren't hidden.
+
+    :param ctx: Invocation context for the group.
+    :param incomplete: Value being completed. May be empty.
+    """
+    multi = t.cast(MultiCommand, ctx.command)
+
+    for name in multi.list_commands(ctx):
+        if name.startswith(incomplete):
+            command = multi.get_command(ctx, name)
+
+            if command is not None and not command.hidden:
+                yield name, command
+
+
+def _check_multicommand(
+    base_command: "MultiCommand", cmd_name: str, cmd: "Command", register: bool = False
+) -> None:
+    if not base_command.chain or not isinstance(cmd, MultiCommand):
+        return
+    if register:
+        hint = (
+            "It is not possible to add multi commands as children to"
+            " another multi command that is in chain mode."
+        )
+    else:
+        hint = (
+            "Found a multi command as subcommand to a multi command"
+            " that is in chain mode. This is not supported."
+        )
+    raise RuntimeError(
+        f"{hint}. Command {base_command.name!r} is set to chain and"
+        f" {cmd_name!r} was added as a subcommand but it in itself is a"
+        f" multi command. ({cmd_name!r} is a {type(cmd).__name__}"
+        f" within a chained {type(base_command).__name__} named"
+        f" {base_command.name!r})."
+    )
+
+
+def batch(iterable: t.Iterable[V], batch_size: int) -> t.List[t.Tuple[V, ...]]:
+    return list(zip(*repeat(iter(iterable), batch_size)))
+
+
+@contextmanager
+def augment_usage_errors(
+    ctx: "Context", param: t.Optional["Parameter"] = None
+) -> t.Iterator[None]:
+    """Context manager that attaches extra information to exceptions."""
+    try:
+        yield
+    except BadParameter as e:
+        if e.ctx is None:
+            e.ctx = ctx
+        if param is not None and e.param is None:
+            e.param = param
+        raise
+    except UsageError as e:
+        if e.ctx is None:
+            e.ctx = ctx
+        raise
+
+
+def iter_params_for_processing(
+    invocation_order: t.Sequence["Parameter"],
+    declaration_order: t.Sequence["Parameter"],
+) -> t.List["Parameter"]:
+    """Given a sequence of parameters in the order as should be considered
+    for processing and an iterable of parameters that exist, this returns
+    a list in the correct order as they should be processed.
+    """
+
+    def sort_key(item: "Parameter") -> t.Tuple[bool, float]:
+        try:
+            idx: float = invocation_order.index(item)
+        except ValueError:
+            idx = float("inf")
+
+        return not item.is_eager, idx
+
+    return sorted(declaration_order, key=sort_key)
+
+
+
[docs]class ParameterSource(enum.Enum): + """This is an :class:`~enum.Enum` that indicates the source of a + parameter's value. + + Use :meth:`click.Context.get_parameter_source` to get the + source for a parameter by name. + + .. versionchanged:: 8.0 + Use :class:`~enum.Enum` and drop the ``validate`` method. + + .. versionchanged:: 8.0 + Added the ``PROMPT`` value. + """ + + COMMANDLINE = enum.auto() + """The value was provided by the command line args.""" + ENVIRONMENT = enum.auto() + """The value was provided with an environment variable.""" + DEFAULT = enum.auto() + """Used the default specified by the parameter.""" + DEFAULT_MAP = enum.auto() + """Used a default provided by :attr:`Context.default_map`.""" + PROMPT = enum.auto() + """Used a prompt to confirm a default or provide a value."""
+ + +class Context: + """The context is a special internal object that holds state relevant + for the script execution at every single level. It's normally invisible + to commands unless they opt-in to getting access to it. + + The context is useful as it can pass internal objects around and can + control special execution features such as reading data from + environment variables. + + A context can be used as context manager in which case it will call + :meth:`close` on teardown. + + :param command: the command class for this context. + :param parent: the parent context. + :param info_name: the info name for this invocation. Generally this + is the most descriptive name for the script or + command. For the toplevel script it is usually + the name of the script, for commands below it it's + the name of the script. + :param obj: an arbitrary object of user data. + :param auto_envvar_prefix: the prefix to use for automatic environment + variables. If this is `None` then reading + from environment variables is disabled. This + does not affect manually set environment + variables which are always read. + :param default_map: a dictionary (like object) with default values + for parameters. + :param terminal_width: the width of the terminal. The default is + inherit from parent context. If no context + defines the terminal width then auto + detection will be applied. + :param max_content_width: the maximum width for content rendered by + Click (this currently only affects help + pages). This defaults to 80 characters if + not overridden. In other words: even if the + terminal is larger than that, Click will not + format things wider than 80 characters by + default. In addition to that, formatters might + add some safety mapping on the right. + :param resilient_parsing: if this flag is enabled then Click will + parse without any interactivity or callback + invocation. Default values will also be + ignored. This is useful for implementing + things such as completion support. + :param allow_extra_args: if this is set to `True` then extra arguments + at the end will not raise an error and will be + kept on the context. The default is to inherit + from the command. + :param allow_interspersed_args: if this is set to `False` then options + and arguments cannot be mixed. The + default is to inherit from the command. + :param ignore_unknown_options: instructs click to ignore options it does + not know and keeps them for later + processing. + :param help_option_names: optionally a list of strings that define how + the default help parameter is named. The + default is ``['--help']``. + :param token_normalize_func: an optional function that is used to + normalize tokens (options, choices, + etc.). This for instance can be used to + implement case insensitive behavior. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are used in texts that Click prints which is by + default not the case. This for instance would affect + help output. + :param show_default: Show the default value for commands. If this + value is not set, it defaults to the value from the parent + context. ``Command.show_default`` overrides this default for the + specific command. + + .. versionchanged:: 8.1 + The ``show_default`` parameter is overridden by + ``Command.show_default``, instead of the other way around. + + .. versionchanged:: 8.0 + The ``show_default`` parameter defaults to the value from the + parent context. + + .. versionchanged:: 7.1 + Added the ``show_default`` parameter. + + .. versionchanged:: 4.0 + Added the ``color``, ``ignore_unknown_options``, and + ``max_content_width`` parameters. + + .. versionchanged:: 3.0 + Added the ``allow_extra_args`` and ``allow_interspersed_args`` + parameters. + + .. versionchanged:: 2.0 + Added the ``resilient_parsing``, ``help_option_names``, and + ``token_normalize_func`` parameters. + """ + + #: The formatter class to create with :meth:`make_formatter`. + #: + #: .. versionadded:: 8.0 + formatter_class: t.Type["HelpFormatter"] = HelpFormatter + + def __init__( + self, + command: "Command", + parent: t.Optional["Context"] = None, + info_name: t.Optional[str] = None, + obj: t.Optional[t.Any] = None, + auto_envvar_prefix: t.Optional[str] = None, + default_map: t.Optional[t.MutableMapping[str, t.Any]] = None, + terminal_width: t.Optional[int] = None, + max_content_width: t.Optional[int] = None, + resilient_parsing: bool = False, + allow_extra_args: t.Optional[bool] = None, + allow_interspersed_args: t.Optional[bool] = None, + ignore_unknown_options: t.Optional[bool] = None, + help_option_names: t.Optional[t.List[str]] = None, + token_normalize_func: t.Optional[t.Callable[[str], str]] = None, + color: t.Optional[bool] = None, + show_default: t.Optional[bool] = None, + ) -> None: + #: the parent context or `None` if none exists. + self.parent = parent + #: the :class:`Command` for this context. + self.command = command + #: the descriptive information name + self.info_name = info_name + #: Map of parameter names to their parsed values. Parameters + #: with ``expose_value=False`` are not stored. + self.params: t.Dict[str, t.Any] = {} + #: the leftover arguments. + self.args: t.List[str] = [] + #: protected arguments. These are arguments that are prepended + #: to `args` when certain parsing scenarios are encountered but + #: must be never propagated to another arguments. This is used + #: to implement nested parsing. + self.protected_args: t.List[str] = [] + #: the collected prefixes of the command's options. + self._opt_prefixes: t.Set[str] = set(parent._opt_prefixes) if parent else set() + + if obj is None and parent is not None: + obj = parent.obj + + #: the user object stored. + self.obj: t.Any = obj + self._meta: t.Dict[str, t.Any] = getattr(parent, "meta", {}) + + #: A dictionary (-like object) with defaults for parameters. + if ( + default_map is None + and info_name is not None + and parent is not None + and parent.default_map is not None + ): + default_map = parent.default_map.get(info_name) + + self.default_map: t.Optional[t.MutableMapping[str, t.Any]] = default_map + + #: This flag indicates if a subcommand is going to be executed. A + #: group callback can use this information to figure out if it's + #: being executed directly or because the execution flow passes + #: onwards to a subcommand. By default it's None, but it can be + #: the name of the subcommand to execute. + #: + #: If chaining is enabled this will be set to ``'*'`` in case + #: any commands are executed. It is however not possible to + #: figure out which ones. If you require this knowledge you + #: should use a :func:`result_callback`. + self.invoked_subcommand: t.Optional[str] = None + + if terminal_width is None and parent is not None: + terminal_width = parent.terminal_width + + #: The width of the terminal (None is autodetection). + self.terminal_width: t.Optional[int] = terminal_width + + if max_content_width is None and parent is not None: + max_content_width = parent.max_content_width + + #: The maximum width of formatted content (None implies a sensible + #: default which is 80 for most things). + self.max_content_width: t.Optional[int] = max_content_width + + if allow_extra_args is None: + allow_extra_args = command.allow_extra_args + + #: Indicates if the context allows extra args or if it should + #: fail on parsing. + #: + #: .. versionadded:: 3.0 + self.allow_extra_args = allow_extra_args + + if allow_interspersed_args is None: + allow_interspersed_args = command.allow_interspersed_args + + #: Indicates if the context allows mixing of arguments and + #: options or not. + #: + #: .. versionadded:: 3.0 + self.allow_interspersed_args: bool = allow_interspersed_args + + if ignore_unknown_options is None: + ignore_unknown_options = command.ignore_unknown_options + + #: Instructs click to ignore options that a command does not + #: understand and will store it on the context for later + #: processing. This is primarily useful for situations where you + #: want to call into external programs. Generally this pattern is + #: strongly discouraged because it's not possibly to losslessly + #: forward all arguments. + #: + #: .. versionadded:: 4.0 + self.ignore_unknown_options: bool = ignore_unknown_options + + if help_option_names is None: + if parent is not None: + help_option_names = parent.help_option_names + else: + help_option_names = ["--help"] + + #: The names for the help options. + self.help_option_names: t.List[str] = help_option_names + + if token_normalize_func is None and parent is not None: + token_normalize_func = parent.token_normalize_func + + #: An optional normalization function for tokens. This is + #: options, choices, commands etc. + self.token_normalize_func: t.Optional[ + t.Callable[[str], str] + ] = token_normalize_func + + #: Indicates if resilient parsing is enabled. In that case Click + #: will do its best to not cause any failures and default values + #: will be ignored. Useful for completion. + self.resilient_parsing: bool = resilient_parsing + + # If there is no envvar prefix yet, but the parent has one and + # the command on this level has a name, we can expand the envvar + # prefix automatically. + if auto_envvar_prefix is None: + if ( + parent is not None + and parent.auto_envvar_prefix is not None + and self.info_name is not None + ): + auto_envvar_prefix = ( + f"{parent.auto_envvar_prefix}_{self.info_name.upper()}" + ) + else: + auto_envvar_prefix = auto_envvar_prefix.upper() + + if auto_envvar_prefix is not None: + auto_envvar_prefix = auto_envvar_prefix.replace("-", "_") + + self.auto_envvar_prefix: t.Optional[str] = auto_envvar_prefix + + if color is None and parent is not None: + color = parent.color + + #: Controls if styling output is wanted or not. + self.color: t.Optional[bool] = color + + if show_default is None and parent is not None: + show_default = parent.show_default + + #: Show option default values when formatting help text. + self.show_default: t.Optional[bool] = show_default + + self._close_callbacks: t.List[t.Callable[[], t.Any]] = [] + self._depth = 0 + self._parameter_source: t.Dict[str, ParameterSource] = {} + self._exit_stack = ExitStack() + + def to_info_dict(self) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. This traverses the entire CLI + structure. + + .. code-block:: python + + with Context(cli) as ctx: + info = ctx.to_info_dict() + + .. versionadded:: 8.0 + """ + return { + "command": self.command.to_info_dict(self), + "info_name": self.info_name, + "allow_extra_args": self.allow_extra_args, + "allow_interspersed_args": self.allow_interspersed_args, + "ignore_unknown_options": self.ignore_unknown_options, + "auto_envvar_prefix": self.auto_envvar_prefix, + } + + def __enter__(self) -> "Context": + self._depth += 1 + push_context(self) + return self + + def __exit__( + self, + exc_type: t.Optional[t.Type[BaseException]], + exc_value: t.Optional[BaseException], + tb: t.Optional[TracebackType], + ) -> None: + self._depth -= 1 + if self._depth == 0: + self.close() + pop_context() + + @contextmanager + def scope(self, cleanup: bool = True) -> t.Iterator["Context"]: + """This helper method can be used with the context object to promote + it to the current thread local (see :func:`get_current_context`). + The default behavior of this is to invoke the cleanup functions which + can be disabled by setting `cleanup` to `False`. The cleanup + functions are typically used for things such as closing file handles. + + If the cleanup is intended the context object can also be directly + used as a context manager. + + Example usage:: + + with ctx.scope(): + assert get_current_context() is ctx + + This is equivalent:: + + with ctx: + assert get_current_context() is ctx + + .. versionadded:: 5.0 + + :param cleanup: controls if the cleanup functions should be run or + not. The default is to run these functions. In + some situations the context only wants to be + temporarily pushed in which case this can be disabled. + Nested pushes automatically defer the cleanup. + """ + if not cleanup: + self._depth += 1 + try: + with self as rv: + yield rv + finally: + if not cleanup: + self._depth -= 1 + + @property + def meta(self) -> t.Dict[str, t.Any]: + """This is a dictionary which is shared with all the contexts + that are nested. It exists so that click utilities can store some + state here if they need to. It is however the responsibility of + that code to manage this dictionary well. + + The keys are supposed to be unique dotted strings. For instance + module paths are a good choice for it. What is stored in there is + irrelevant for the operation of click. However what is important is + that code that places data here adheres to the general semantics of + the system. + + Example usage:: + + LANG_KEY = f'{__name__}.lang' + + def set_language(value): + ctx = get_current_context() + ctx.meta[LANG_KEY] = value + + def get_language(): + return get_current_context().meta.get(LANG_KEY, 'en_US') + + .. versionadded:: 5.0 + """ + return self._meta + + def make_formatter(self) -> HelpFormatter: + """Creates the :class:`~click.HelpFormatter` for the help and + usage output. + + To quickly customize the formatter class used without overriding + this method, set the :attr:`formatter_class` attribute. + + .. versionchanged:: 8.0 + Added the :attr:`formatter_class` attribute. + """ + return self.formatter_class( + width=self.terminal_width, max_width=self.max_content_width + ) + + def with_resource(self, context_manager: t.ContextManager[V]) -> V: + """Register a resource as if it were used in a ``with`` + statement. The resource will be cleaned up when the context is + popped. + + Uses :meth:`contextlib.ExitStack.enter_context`. It calls the + resource's ``__enter__()`` method and returns the result. When + the context is popped, it closes the stack, which calls the + resource's ``__exit__()`` method. + + To register a cleanup function for something that isn't a + context manager, use :meth:`call_on_close`. Or use something + from :mod:`contextlib` to turn it into a context manager first. + + .. code-block:: python + + @click.group() + @click.option("--name") + @click.pass_context + def cli(ctx): + ctx.obj = ctx.with_resource(connect_db(name)) + + :param context_manager: The context manager to enter. + :return: Whatever ``context_manager.__enter__()`` returns. + + .. versionadded:: 8.0 + """ + return self._exit_stack.enter_context(context_manager) + + def call_on_close(self, f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + """Register a function to be called when the context tears down. + + This can be used to close resources opened during the script + execution. Resources that support Python's context manager + protocol which would be used in a ``with`` statement should be + registered with :meth:`with_resource` instead. + + :param f: The function to execute on teardown. + """ + return self._exit_stack.callback(f) + + def close(self) -> None: + """Invoke all close callbacks registered with + :meth:`call_on_close`, and exit all context managers entered + with :meth:`with_resource`. + """ + self._exit_stack.close() + # In case the context is reused, create a new exit stack. + self._exit_stack = ExitStack() + + @property + def command_path(self) -> str: + """The computed command path. This is used for the ``usage`` + information on the help page. It's automatically created by + combining the info names of the chain of contexts to the root. + """ + rv = "" + if self.info_name is not None: + rv = self.info_name + if self.parent is not None: + parent_command_path = [self.parent.command_path] + + if isinstance(self.parent.command, Command): + for param in self.parent.command.get_params(self): + parent_command_path.extend(param.get_usage_pieces(self)) + + rv = f"{' '.join(parent_command_path)} {rv}" + return rv.lstrip() + + def find_root(self) -> "Context": + """Finds the outermost context.""" + node = self + while node.parent is not None: + node = node.parent + return node + + def find_object(self, object_type: t.Type[V]) -> t.Optional[V]: + """Finds the closest object of a given type.""" + node: t.Optional["Context"] = self + + while node is not None: + if isinstance(node.obj, object_type): + return node.obj + + node = node.parent + + return None + + def ensure_object(self, object_type: t.Type[V]) -> V: + """Like :meth:`find_object` but sets the innermost object to a + new instance of `object_type` if it does not exist. + """ + rv = self.find_object(object_type) + if rv is None: + self.obj = rv = object_type() + return rv + + @t.overload + def lookup_default( + self, name: str, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @t.overload + def lookup_default( + self, name: str, call: "te.Literal[False]" = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def lookup_default(self, name: str, call: bool = True) -> t.Optional[t.Any]: + """Get the default for a parameter from :attr:`default_map`. + + :param name: Name of the parameter. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. + """ + if self.default_map is not None: + value = self.default_map.get(name) + + if call and callable(value): + return value() + + return value + + return None + + def fail(self, message: str) -> "te.NoReturn": + """Aborts the execution of the program with a specific error + message. + + :param message: the error message to fail with. + """ + raise UsageError(message, self) + + def abort(self) -> "te.NoReturn": + """Aborts the script.""" + raise Abort() + + def exit(self, code: int = 0) -> "te.NoReturn": + """Exits the application with a given exit code.""" + raise Exit(code) + + def get_usage(self) -> str: + """Helper method to get formatted usage string for the current + context and command. + """ + return self.command.get_usage(self) + + def get_help(self) -> str: + """Helper method to get formatted help page for the current + context and command. + """ + return self.command.get_help(self) + + def _make_sub_context(self, command: "Command") -> "Context": + """Create a new context of the same type as this context, but + for a new command. + + :meta private: + """ + return type(self)(command, info_name=command.name, parent=self) + + @t.overload + def invoke( + __self, # noqa: B902 + __callback: "t.Callable[..., V]", + *args: t.Any, + **kwargs: t.Any, + ) -> V: + ... + + @t.overload + def invoke( + __self, # noqa: B902 + __callback: "Command", + *args: t.Any, + **kwargs: t.Any, + ) -> t.Any: + ... + + def invoke( + __self, # noqa: B902 + __callback: t.Union["Command", "t.Callable[..., V]"], + *args: t.Any, + **kwargs: t.Any, + ) -> t.Union[t.Any, V]: + """Invokes a command callback in exactly the way it expects. There + are two ways to invoke this method: + + 1. the first argument can be a callback and all other arguments and + keyword arguments are forwarded directly to the function. + 2. the first argument is a click command object. In that case all + arguments are forwarded as well but proper click parameters + (options and click arguments) must be keyword arguments and Click + will fill in defaults. + + Note that before Click 3.2 keyword arguments were not properly filled + in against the intention of this code and no context was created. For + more information about this change and why it was done in a bugfix + release see :ref:`upgrade-to-3.2`. + + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if :meth:`forward` is called at multiple levels. + """ + if isinstance(__callback, Command): + other_cmd = __callback + + if other_cmd.callback is None: + raise TypeError( + "The given command does not have a callback that can be invoked." + ) + else: + __callback = t.cast("t.Callable[..., V]", other_cmd.callback) + + ctx = __self._make_sub_context(other_cmd) + + for param in other_cmd.params: + if param.name not in kwargs and param.expose_value: + kwargs[param.name] = param.type_cast_value( # type: ignore + ctx, param.get_default(ctx) + ) + + # Track all kwargs as params, so that forward() will pass + # them on in subsequent calls. + ctx.params.update(kwargs) + else: + ctx = __self + + with augment_usage_errors(__self): + with ctx: + return __callback(*args, **kwargs) + + def forward( + __self, __cmd: "Command", *args: t.Any, **kwargs: t.Any # noqa: B902 + ) -> t.Any: + """Similar to :meth:`invoke` but fills in default keyword + arguments from the current context if the other command expects + it. This cannot invoke callbacks directly, only other commands. + + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if ``forward`` is called at multiple levels. + """ + # Can only forward to other commands, not direct callbacks. + if not isinstance(__cmd, Command): + raise TypeError("Callback is not a command.") + + for param in __self.params: + if param not in kwargs: + kwargs[param] = __self.params[param] + + return __self.invoke(__cmd, *args, **kwargs) + + def set_parameter_source(self, name: str, source: ParameterSource) -> None: + """Set the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + + :param name: The name of the parameter. + :param source: A member of :class:`~click.core.ParameterSource`. + """ + self._parameter_source[name] = source + + def get_parameter_source(self, name: str) -> t.Optional[ParameterSource]: + """Get the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + + This can be useful for determining when a user specified a value + on the command line that is the same as the default value. It + will be :attr:`~click.core.ParameterSource.DEFAULT` only if the + value was actually taken from the default. + + :param name: The name of the parameter. + :rtype: ParameterSource + + .. versionchanged:: 8.0 + Returns ``None`` if the parameter was not provided from any + source. + """ + return self._parameter_source.get(name) + + +
[docs]class BaseCommand: + """The base command implements the minimal API contract of commands. + Most code will never use this as it does not implement a lot of useful + functionality but it can act as the direct subclass of alternative + parsing methods that do not depend on the Click parser. + + For instance, this can be used to bridge Click and other systems like + argparse or docopt. + + Because base commands do not implement a lot of the API that other + parts of Click take for granted, they are not supported for all + operations. For instance, they cannot be used with the decorators + usually and they have no built-in callback system. + + .. versionchanged:: 2.0 + Added the `context_settings` parameter. + + :param name: the name of the command to use unless a group overrides it. + :param context_settings: an optional dictionary with defaults that are + passed to the context object. + """ + + #: The context class to create with :meth:`make_context`. + #: + #: .. versionadded:: 8.0 + context_class: t.Type[Context] = Context + #: the default for the :attr:`Context.allow_extra_args` flag. + allow_extra_args = False + #: the default for the :attr:`Context.allow_interspersed_args` flag. + allow_interspersed_args = True + #: the default for the :attr:`Context.ignore_unknown_options` flag. + ignore_unknown_options = False + + def __init__( + self, + name: t.Optional[str], + context_settings: t.Optional[t.MutableMapping[str, t.Any]] = None, + ) -> None: + #: the name the command thinks it has. Upon registering a command + #: on a :class:`Group` the group will default the command name + #: with this information. You should instead use the + #: :class:`Context`\'s :attr:`~Context.info_name` attribute. + self.name = name + + if context_settings is None: + context_settings = {} + + #: an optional dictionary with defaults passed to the context. + self.context_settings: t.MutableMapping[str, t.Any] = context_settings + +
[docs] def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. This traverses the entire structure + below this command. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + :param ctx: A :class:`Context` representing this command. + + .. versionadded:: 8.0 + """ + return {"name": self.name}
+ + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + +
[docs] def get_usage(self, ctx: Context) -> str: + raise NotImplementedError("Base commands cannot get usage")
+ +
[docs] def get_help(self, ctx: Context) -> str: + raise NotImplementedError("Base commands cannot get help")
+ +
[docs] def make_context( + self, + info_name: t.Optional[str], + args: t.List[str], + parent: t.Optional[Context] = None, + **extra: t.Any, + ) -> Context: + """This function when given an info name and arguments will kick + off the parsing and create a new :class:`Context`. It does not + invoke the actual command callback though. + + To quickly customize the context class used without overriding + this method, set the :attr:`context_class` attribute. + + :param info_name: the info name for this invocation. Generally this + is the most descriptive name for the script or + command. For the toplevel script it's usually + the name of the script, for commands below it's + the name of the command. + :param args: the arguments to parse as list of strings. + :param parent: the parent context if available. + :param extra: extra keyword arguments forwarded to the context + constructor. + + .. versionchanged:: 8.0 + Added the :attr:`context_class` attribute. + """ + for key, value in self.context_settings.items(): + if key not in extra: + extra[key] = value + + ctx = self.context_class( + self, info_name=info_name, parent=parent, **extra # type: ignore + ) + + with ctx.scope(cleanup=False): + self.parse_args(ctx, args) + return ctx
+ +
[docs] def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: + """Given a context and a list of arguments this creates the parser + and parses the arguments, then modifies the context as necessary. + This is automatically invoked by :meth:`make_context`. + """ + raise NotImplementedError("Base commands do not know how to parse arguments.")
+ +
[docs] def invoke(self, ctx: Context) -> t.Any: + """Given a context, this invokes the command. The default + implementation is raising a not implemented error. + """ + raise NotImplementedError("Base commands are not invocable by default")
+ +
[docs] def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. Looks + at the names of chained multi-commands. + + Any command could be part of a chained multi-command, so sibling + commands are valid at any point during command completion. Other + command classes will return more completions. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results: t.List["CompletionItem"] = [] + + while ctx.parent is not None: + ctx = ctx.parent + + if isinstance(ctx.command, MultiCommand) and ctx.command.chain: + results.extend( + CompletionItem(name, help=command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) + if name not in ctx.protected_args + ) + + return results
+ + @t.overload + def main( + self, + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: "te.Literal[True]" = True, + **extra: t.Any, + ) -> "te.NoReturn": + ... + + @t.overload + def main( + self, + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: bool = ..., + **extra: t.Any, + ) -> t.Any: + ... + +
[docs] def main( + self, + args: t.Optional[t.Sequence[str]] = None, + prog_name: t.Optional[str] = None, + complete_var: t.Optional[str] = None, + standalone_mode: bool = True, + windows_expand_args: bool = True, + **extra: t.Any, + ) -> t.Any: + """This is the way to invoke a script with all the bells and + whistles as a command line application. This will always terminate + the application after a call. If this is not wanted, ``SystemExit`` + needs to be caught. + + This method is also available by directly calling the instance of + a :class:`Command`. + + :param args: the arguments that should be used for parsing. If not + provided, ``sys.argv[1:]`` is used. + :param prog_name: the program name that should be used. By default + the program name is constructed by taking the file + name from ``sys.argv[0]``. + :param complete_var: the environment variable that controls the + bash completion support. The default is + ``"_<prog_name>_COMPLETE"`` with prog_name in + uppercase. + :param standalone_mode: the default behavior is to invoke the script + in standalone mode. Click will then + handle exceptions and convert them into + error messages and the function will never + return but shut down the interpreter. If + this is set to `False` they will be + propagated to the caller and the return + value of this function is the return value + of :meth:`invoke`. + :param windows_expand_args: Expand glob patterns, user dir, and + env vars in command line args on Windows. + :param extra: extra keyword arguments are forwarded to the context + constructor. See :class:`Context` for more information. + + .. versionchanged:: 8.0.1 + Added the ``windows_expand_args`` parameter to allow + disabling command line arg expansion on Windows. + + .. versionchanged:: 8.0 + When taking arguments from ``sys.argv`` on Windows, glob + patterns, user dir, and env vars are expanded. + + .. versionchanged:: 3.0 + Added the ``standalone_mode`` parameter. + """ + if args is None: + args = sys.argv[1:] + + if os.name == "nt" and windows_expand_args: + args = _expand_args(args) + else: + args = list(args) + + if prog_name is None: + prog_name = _detect_program_name() + + # Process shell completion requests and exit early. + self._main_shell_completion(extra, prog_name, complete_var) + + try: + try: + with self.make_context(prog_name, args, **extra) as ctx: + rv = self.invoke(ctx) + if not standalone_mode: + return rv + # it's not safe to `ctx.exit(rv)` here! + # note that `rv` may actually contain data like "1" which + # has obvious effects + # more subtle case: `rv=[None, None]` can come out of + # chained commands which all returned `None` -- so it's not + # even always obvious that `rv` indicates success/failure + # by its truthiness/falsiness + ctx.exit() + except (EOFError, KeyboardInterrupt) as e: + echo(file=sys.stderr) + raise Abort() from e + except ClickException as e: + if not standalone_mode: + raise + e.show() + sys.exit(e.exit_code) + except OSError as e: + if e.errno == errno.EPIPE: + sys.stdout = t.cast(t.TextIO, PacifyFlushWrapper(sys.stdout)) + sys.stderr = t.cast(t.TextIO, PacifyFlushWrapper(sys.stderr)) + sys.exit(1) + else: + raise + except Exit as e: + if standalone_mode: + sys.exit(e.exit_code) + else: + # in non-standalone mode, return the exit code + # note that this is only reached if `self.invoke` above raises + # an Exit explicitly -- thus bypassing the check there which + # would return its result + # the results of non-standalone execution may therefore be + # somewhat ambiguous: if there are codepaths which lead to + # `ctx.exit(1)` and to `return 1`, the caller won't be able to + # tell the difference between the two + return e.exit_code + except Abort: + if not standalone_mode: + raise + echo(_("Aborted!"), file=sys.stderr) + sys.exit(1)
+ + def _main_shell_completion( + self, + ctx_args: t.MutableMapping[str, t.Any], + prog_name: str, + complete_var: t.Optional[str] = None, + ) -> None: + """Check if the shell is asking for tab completion, process + that, then exit early. Called from :meth:`main` before the + program is invoked. + + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. Defaults to + ``_{PROG_NAME}_COMPLETE``. + + .. versionchanged:: 8.2.0 + Dots (``.``) in ``prog_name`` are replaced with underscores (``_``). + """ + if complete_var is None: + complete_name = prog_name.replace("-", "_").replace(".", "_") + complete_var = f"_{complete_name}_COMPLETE".upper() + + instruction = os.environ.get(complete_var) + + if not instruction: + return + + from .shell_completion import shell_complete + + rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction) + sys.exit(rv) + + def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + """Alias for :meth:`main`.""" + return self.main(*args, **kwargs)
+ + +class Command(BaseCommand): + """Commands are the basic building block of command line interfaces in + Click. A basic command handles command line parsing and might dispatch + more parsing to commands nested below it. + + :param name: the name of the command to use unless a group overrides it. + :param context_settings: an optional dictionary with defaults that are + passed to the context object. + :param callback: the callback to invoke. This is optional. + :param params: the parameters to register with this command. This can + be either :class:`Option` or :class:`Argument` objects. + :param help: the help string to use for this command. + :param epilog: like the help string but it's printed at the end of the + help page after everything else. + :param short_help: the short help to use for this command. This is + shown on the command listing of the parent command. + :param add_help_option: by default each command registers a ``--help`` + option. This can be disabled by this parameter. + :param no_args_is_help: this controls what happens if no arguments are + provided. This option is disabled by default. + If enabled this will add ``--help`` as argument + if no arguments are passed + :param hidden: hide this command from help outputs. + + :param deprecated: issues a message indicating that + the command is deprecated. + + .. versionchanged:: 8.1 + ``help``, ``epilog``, and ``short_help`` are stored unprocessed, + all formatting is done when outputting help text, not at init, + and is done even if not using the ``@command`` decorator. + + .. versionchanged:: 8.0 + Added a ``repr`` showing the command name. + + .. versionchanged:: 7.1 + Added the ``no_args_is_help`` parameter. + + .. versionchanged:: 2.0 + Added the ``context_settings`` parameter. + """ + + def __init__( + self, + name: t.Optional[str], + context_settings: t.Optional[t.MutableMapping[str, t.Any]] = None, + callback: t.Optional[t.Callable[..., t.Any]] = None, + params: t.Optional[t.List["Parameter"]] = None, + help: t.Optional[str] = None, + epilog: t.Optional[str] = None, + short_help: t.Optional[str] = None, + options_metavar: t.Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + ) -> None: + super().__init__(name, context_settings) + #: the callback to execute when the command fires. This might be + #: `None` in which case nothing happens. + self.callback = callback + #: the list of parameters for this command in the order they + #: should show up in the help page and execute. Eager parameters + #: will automatically be handled before non eager ones. + self.params: t.List["Parameter"] = params or [] + self.help = help + self.epilog = epilog + self.options_metavar = options_metavar + self.short_help = short_help + self.add_help_option = add_help_option + self.no_args_is_help = no_args_is_help + self.hidden = hidden + self.deprecated = deprecated + + def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict(ctx) + info_dict.update( + params=[param.to_info_dict() for param in self.get_params(ctx)], + help=self.help, + epilog=self.epilog, + short_help=self.short_help, + hidden=self.hidden, + deprecated=self.deprecated, + ) + return info_dict + + def get_usage(self, ctx: Context) -> str: + """Formats the usage line into a string and returns it. + + Calls :meth:`format_usage` internally. + """ + formatter = ctx.make_formatter() + self.format_usage(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_params(self, ctx: Context) -> t.List["Parameter"]: + rv = self.params + help_option = self.get_help_option(ctx) + + if help_option is not None: + rv = [*rv, help_option] + + return rv + + def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the usage line into the formatter. + + This is a low-level method called by :meth:`get_usage`. + """ + pieces = self.collect_usage_pieces(ctx) + formatter.write_usage(ctx.command_path, " ".join(pieces)) + + def collect_usage_pieces(self, ctx: Context) -> t.List[str]: + """Returns all the pieces that go into the usage line and returns + it as a list of strings. + """ + rv = [self.options_metavar] if self.options_metavar else [] + + for param in self.get_params(ctx): + rv.extend(param.get_usage_pieces(ctx)) + + return rv + + def get_help_option_names(self, ctx: Context) -> t.List[str]: + """Returns the names for the help option.""" + all_names = set(ctx.help_option_names) + for param in self.params: + all_names.difference_update(param.opts) + all_names.difference_update(param.secondary_opts) + return list(all_names) + + def get_help_option(self, ctx: Context) -> t.Optional["Option"]: + """Returns the help option object.""" + help_options = self.get_help_option_names(ctx) + + if not help_options or not self.add_help_option: + return None + + def show_help(ctx: Context, param: "Parameter", value: str) -> None: + if value and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + return Option( + help_options, + is_flag=True, + is_eager=True, + expose_value=False, + callback=show_help, + help=_("Show this message and exit."), + ) + + def make_parser(self, ctx: Context) -> OptionParser: + """Creates the underlying option parser for this command.""" + parser = OptionParser(ctx) + for param in self.get_params(ctx): + param.add_to_parser(parser, ctx) + return parser + + def get_help(self, ctx: Context) -> str: + """Formats the help into a string and returns it. + + Calls :meth:`format_help` internally. + """ + formatter = ctx.make_formatter() + self.format_help(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_short_help_str(self, limit: int = 45) -> str: + """Gets short help for the command or makes it by shortening the + long help string. + """ + if self.short_help: + text = inspect.cleandoc(self.short_help) + elif self.help: + text = make_default_short_help(self.help, limit) + else: + text = "" + + if self.deprecated: + text = _("(Deprecated) {text}").format(text=text) + + return text.strip() + + def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help into the formatter if it exists. + + This is a low-level method called by :meth:`get_help`. + + This calls the following methods: + + - :meth:`format_usage` + - :meth:`format_help_text` + - :meth:`format_options` + - :meth:`format_epilog` + """ + self.format_usage(ctx, formatter) + self.format_help_text(ctx, formatter) + self.format_options(ctx, formatter) + self.format_epilog(ctx, formatter) + + def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help text to the formatter if it exists.""" + if self.help is not None: + # truncate the help text to the first form feed + text = inspect.cleandoc(self.help).partition("\f")[0] + else: + text = "" + + if self.deprecated: + text = _("(Deprecated) {text}").format(text=text) + + if text: + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(text) + + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes all the options into the formatter if they exist.""" + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + opts.append(rv) + + if opts: + with formatter.section(_("Options")): + formatter.write_dl(opts) + + def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the epilog into the formatter if it exists.""" + if self.epilog: + epilog = inspect.cleandoc(self.epilog) + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(epilog) + + def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + parser = self.make_parser(ctx) + opts, args, param_order = parser.parse_args(args=args) + + for param in iter_params_for_processing(param_order, self.get_params(ctx)): + value, args = param.handle_parse_result(ctx, opts, args) + + if args and not ctx.allow_extra_args and not ctx.resilient_parsing: + ctx.fail( + ngettext( + "Got unexpected extra argument ({args})", + "Got unexpected extra arguments ({args})", + len(args), + ).format(args=" ".join(map(str, args))) + ) + + ctx.args = args + ctx._opt_prefixes.update(parser._opt_prefixes) + return args + + def invoke(self, ctx: Context) -> t.Any: + """Given a context, this invokes the attached callback (if it exists) + in the right way. + """ + if self.deprecated: + message = _( + "DeprecationWarning: The command {name!r} is deprecated." + ).format(name=self.name) + echo(style(message, fg="red"), err=True) + + if self.callback is not None: + return ctx.invoke(self.callback, **ctx.params) + + def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. Looks + at the names of options and chained multi-commands. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results: t.List["CompletionItem"] = [] + + if incomplete and not incomplete[0].isalnum(): + for param in self.get_params(ctx): + if ( + not isinstance(param, Option) + or param.hidden + or ( + not param.multiple + and ctx.get_parameter_source(param.name) # type: ignore + is ParameterSource.COMMANDLINE + ) + ): + continue + + results.extend( + CompletionItem(name, help=param.help) + for name in [*param.opts, *param.secondary_opts] + if name.startswith(incomplete) + ) + + results.extend(super().shell_complete(ctx, incomplete)) + return results + + +
[docs]class MultiCommand(Command): + """A multi command is the basic implementation of a command that + dispatches to subcommands. The most common version is the + :class:`Group`. + + :param invoke_without_command: this controls how the multi command itself + is invoked. By default it's only invoked + if a subcommand is provided. + :param no_args_is_help: this controls what happens if no arguments are + provided. This option is enabled by default if + `invoke_without_command` is disabled or disabled + if it's enabled. If enabled this will add + ``--help`` as argument if no arguments are + passed. + :param subcommand_metavar: the string that is used in the documentation + to indicate the subcommand place. + :param chain: if this is set to `True` chaining of multiple subcommands + is enabled. This restricts the form of commands in that + they cannot have optional arguments but it allows + multiple commands to be chained together. + :param result_callback: The result callback to attach to this multi + command. This can be set or changed later with the + :meth:`result_callback` decorator. + :param attrs: Other command arguments described in :class:`Command`. + """ + + allow_extra_args = True + allow_interspersed_args = False + + def __init__( + self, + name: t.Optional[str] = None, + invoke_without_command: bool = False, + no_args_is_help: t.Optional[bool] = None, + subcommand_metavar: t.Optional[str] = None, + chain: bool = False, + result_callback: t.Optional[t.Callable[..., t.Any]] = None, + **attrs: t.Any, + ) -> None: + super().__init__(name, **attrs) + + if no_args_is_help is None: + no_args_is_help = not invoke_without_command + + self.no_args_is_help = no_args_is_help + self.invoke_without_command = invoke_without_command + + if subcommand_metavar is None: + if chain: + subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." + else: + subcommand_metavar = "COMMAND [ARGS]..." + + self.subcommand_metavar = subcommand_metavar + self.chain = chain + # The result callback that is stored. This can be set or + # overridden with the :func:`result_callback` decorator. + self._result_callback = result_callback + + if self.chain: + for param in self.params: + if isinstance(param, Argument) and not param.required: + raise RuntimeError( + "Multi commands in chain mode cannot have" + " optional arguments." + ) + +
[docs] def to_info_dict(self, ctx: Context) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict(ctx) + commands = {} + + for name in self.list_commands(ctx): + command = self.get_command(ctx, name) + + if command is None: + continue + + sub_ctx = ctx._make_sub_context(command) + + with sub_ctx.scope(cleanup=False): + commands[name] = command.to_info_dict(sub_ctx) + + info_dict.update(commands=commands, chain=self.chain) + return info_dict
+ +
[docs] def collect_usage_pieces(self, ctx: Context) -> t.List[str]: + rv = super().collect_usage_pieces(ctx) + rv.append(self.subcommand_metavar) + return rv
+ +
[docs] def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + super().format_options(ctx, formatter) + self.format_commands(ctx, formatter)
+ +
[docs] def result_callback(self, replace: bool = False) -> t.Callable[[F], F]: + """Adds a result callback to the command. By default if a + result callback is already registered this will chain them but + this can be disabled with the `replace` parameter. The result + callback is invoked with the return value of the subcommand + (or the list of return values from all subcommands if chaining + is enabled) as well as the parameters as they would be passed + to the main callback. + + Example:: + + @click.group() + @click.option('-i', '--input', default=23) + def cli(input): + return 42 + + @cli.result_callback() + def process_result(result, input): + return result + input + + :param replace: if set to `True` an already existing result + callback will be removed. + + .. versionchanged:: 8.0 + Renamed from ``resultcallback``. + + .. versionadded:: 3.0 + """ + + def decorator(f: F) -> F: + old_callback = self._result_callback + + if old_callback is None or replace: + self._result_callback = f + return f + + def function(__value, *args, **kwargs): # type: ignore + inner = old_callback(__value, *args, **kwargs) + return f(inner, *args, **kwargs) + + self._result_callback = rv = update_wrapper(t.cast(F, function), f) + return rv + + return decorator
+ +
[docs] def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None: + """Extra format methods for multi methods that adds all the commands + after the options. + """ + commands = [] + for subcommand in self.list_commands(ctx): + cmd = self.get_command(ctx, subcommand) + # What is this, the tool lied about a command. Ignore it + if cmd is None: + continue + if cmd.hidden: + continue + + commands.append((subcommand, cmd)) + + # allow for 3 times the default spacing + if len(commands): + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) + + rows = [] + for subcommand, cmd in commands: + help = cmd.get_short_help_str(limit) + rows.append((subcommand, help)) + + if rows: + with formatter.section(_("Commands")): + formatter.write_dl(rows)
+ +
[docs] def parse_args(self, ctx: Context, args: t.List[str]) -> t.List[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + rest = super().parse_args(ctx, args) + + if self.chain: + ctx.protected_args = rest + ctx.args = [] + elif rest: + ctx.protected_args, ctx.args = rest[:1], rest[1:] + + return ctx.args
+ +
[docs] def invoke(self, ctx: Context) -> t.Any: + def _process_result(value: t.Any) -> t.Any: + if self._result_callback is not None: + value = ctx.invoke(self._result_callback, value, **ctx.params) + return value + + if not ctx.protected_args: + if self.invoke_without_command: + # No subcommand was invoked, so the result callback is + # invoked with the group return value for regular + # groups, or an empty list for chained groups. + with ctx: + rv = super().invoke(ctx) + return _process_result([] if self.chain else rv) + ctx.fail(_("Missing command.")) + + # Fetch args back out + args = [*ctx.protected_args, *ctx.args] + ctx.args = [] + ctx.protected_args = [] + + # If we're not in chain mode, we only allow the invocation of a + # single command but we also inform the current context about the + # name of the command to invoke. + if not self.chain: + # Make sure the context is entered so we do not clean up + # resources until the result processor has worked. + with ctx: + cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None + ctx.invoked_subcommand = cmd_name + super().invoke(ctx) + sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) + with sub_ctx: + return _process_result(sub_ctx.command.invoke(sub_ctx)) + + # In chain mode we create the contexts step by step, but after the + # base command has been invoked. Because at that point we do not + # know the subcommands yet, the invoked subcommand attribute is + # set to ``*`` to inform the command that subcommands are executed + # but nothing else. + with ctx: + ctx.invoked_subcommand = "*" if args else None + super().invoke(ctx) + + # Otherwise we make every single context and invoke them in a + # chain. In that case the return value to the result processor + # is the list of all invoked subcommand's results. + contexts = [] + while args: + cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None + sub_ctx = cmd.make_context( + cmd_name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + ) + contexts.append(sub_ctx) + args, sub_ctx.args = sub_ctx.args, [] + + rv = [] + for sub_ctx in contexts: + with sub_ctx: + rv.append(sub_ctx.command.invoke(sub_ctx)) + return _process_result(rv)
+ +
[docs] def resolve_command( + self, ctx: Context, args: t.List[str] + ) -> t.Tuple[t.Optional[str], t.Optional[Command], t.List[str]]: + cmd_name = make_str(args[0]) + original_cmd_name = cmd_name + + # Get the command + cmd = self.get_command(ctx, cmd_name) + + # If we can't find the command but there is a normalization + # function available, we try with that one. + if cmd is None and ctx.token_normalize_func is not None: + cmd_name = ctx.token_normalize_func(cmd_name) + cmd = self.get_command(ctx, cmd_name) + + # If we don't find the command we want to show an error message + # to the user that it was not provided. However, there is + # something else we should do: if the first argument looks like + # an option we want to kick off parsing again for arguments to + # resolve things like --help which now should go to the main + # place. + if cmd is None and not ctx.resilient_parsing: + if split_opt(cmd_name)[0]: + self.parse_args(ctx, ctx.args) + ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) + return cmd_name if cmd else None, cmd, args[1:]
+ +
[docs] def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: + """Given a context and a command name, this returns a + :class:`Command` object if it exists or returns `None`. + """ + raise NotImplementedError
+ +
[docs] def list_commands(self, ctx: Context) -> t.List[str]: + """Returns a list of subcommand names in the order they should + appear. + """ + return []
+ +
[docs] def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. Looks + at the names of options, subcommands, and chained + multi-commands. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results = [ + CompletionItem(name, help=command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) + ] + results.extend(super().shell_complete(ctx, incomplete)) + return results
+ + +class Group(MultiCommand): + """A group allows a command to have subcommands attached. This is + the most common way to implement nesting in Click. + + :param name: The name of the group command. + :param commands: A dict mapping names to :class:`Command` objects. + Can also be a list of :class:`Command`, which will use + :attr:`Command.name` to create the dict. + :param attrs: Other command arguments described in + :class:`MultiCommand`, :class:`Command`, and + :class:`BaseCommand`. + + .. versionchanged:: 8.0 + The ``commands`` argument can be a list of command objects. + """ + + #: If set, this is used by the group's :meth:`command` decorator + #: as the default :class:`Command` class. This is useful to make all + #: subcommands use a custom command class. + #: + #: .. versionadded:: 8.0 + command_class: t.Optional[t.Type[Command]] = None + + #: If set, this is used by the group's :meth:`group` decorator + #: as the default :class:`Group` class. This is useful to make all + #: subgroups use a custom group class. + #: + #: If set to the special value :class:`type` (literally + #: ``group_class = type``), this group's class will be used as the + #: default class. This makes a custom group class continue to make + #: custom groups. + #: + #: .. versionadded:: 8.0 + group_class: t.Optional[t.Union[t.Type["Group"], t.Type[type]]] = None + # Literal[type] isn't valid, so use Type[type] + + def __init__( + self, + name: t.Optional[str] = None, + commands: t.Optional[ + t.Union[t.MutableMapping[str, Command], t.Sequence[Command]] + ] = None, + **attrs: t.Any, + ) -> None: + super().__init__(name, **attrs) + + if commands is None: + commands = {} + elif isinstance(commands, abc.Sequence): + commands = {c.name: c for c in commands if c.name is not None} + + #: The registered subcommands by their exported names. + self.commands: t.MutableMapping[str, Command] = commands + + def add_command(self, cmd: Command, name: t.Optional[str] = None) -> None: + """Registers another :class:`Command` with this group. If the name + is not provided, the name of the command is used. + """ + name = name or cmd.name + if name is None: + raise TypeError("Command has no name.") + _check_multicommand(self, name, cmd, register=True) + self.commands[name] = cmd + + @t.overload + def command(self, __func: t.Callable[..., t.Any]) -> Command: + ... + + @t.overload + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Command]: + ... + + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Union[t.Callable[[t.Callable[..., t.Any]], Command], Command]: + """A shortcut decorator for declaring and attaching a command to + the group. This takes the same arguments as :func:`command` and + immediately registers the created command with this group by + calling :meth:`add_command`. + + To customize the command class used, set the + :attr:`command_class` attribute. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + + .. versionchanged:: 8.0 + Added the :attr:`command_class` attribute. + """ + from .decorators import command + + func: t.Optional[t.Callable[..., t.Any]] = None + + if args and callable(args[0]): + assert ( + len(args) == 1 and not kwargs + ), "Use 'command(**kwargs)(callable)' to provide arguments." + (func,) = args + args = () + + if self.command_class and kwargs.get("cls") is None: + kwargs["cls"] = self.command_class + + def decorator(f: t.Callable[..., t.Any]) -> Command: + cmd: Command = command(*args, **kwargs)(f) + self.add_command(cmd) + return cmd + + if func is not None: + return decorator(func) + + return decorator + + @t.overload + def group(self, __func: t.Callable[..., t.Any]) -> "Group": + ... + + @t.overload + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], "Group"]: + ... + + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Union[t.Callable[[t.Callable[..., t.Any]], "Group"], "Group"]: + """A shortcut decorator for declaring and attaching a group to + the group. This takes the same arguments as :func:`group` and + immediately registers the created group with this group by + calling :meth:`add_command`. + + To customize the group class used, set the :attr:`group_class` + attribute. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + + .. versionchanged:: 8.0 + Added the :attr:`group_class` attribute. + """ + from .decorators import group + + func: t.Optional[t.Callable[..., t.Any]] = None + + if args and callable(args[0]): + assert ( + len(args) == 1 and not kwargs + ), "Use 'group(**kwargs)(callable)' to provide arguments." + (func,) = args + args = () + + if self.group_class is not None and kwargs.get("cls") is None: + if self.group_class is type: + kwargs["cls"] = type(self) + else: + kwargs["cls"] = self.group_class + + def decorator(f: t.Callable[..., t.Any]) -> "Group": + cmd: Group = group(*args, **kwargs)(f) + self.add_command(cmd) + return cmd + + if func is not None: + return decorator(func) + + return decorator + + def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: + return self.commands.get(cmd_name) + + def list_commands(self, ctx: Context) -> t.List[str]: + return sorted(self.commands) + + +
[docs]class CommandCollection(MultiCommand): + """A command collection is a multi command that merges multiple multi + commands together into one. This is a straightforward implementation + that accepts a list of different multi commands as sources and + provides all the commands for each of them. + + See :class:`MultiCommand` and :class:`Command` for the description of + ``name`` and ``attrs``. + """ + + def __init__( + self, + name: t.Optional[str] = None, + sources: t.Optional[t.List[MultiCommand]] = None, + **attrs: t.Any, + ) -> None: + super().__init__(name, **attrs) + #: The list of registered multi commands. + self.sources: t.List[MultiCommand] = sources or [] + +
[docs] def add_source(self, multi_cmd: MultiCommand) -> None: + """Adds a new multi command to the chain dispatcher.""" + self.sources.append(multi_cmd)
+ +
[docs] def get_command(self, ctx: Context, cmd_name: str) -> t.Optional[Command]: + for source in self.sources: + rv = source.get_command(ctx, cmd_name) + + if rv is not None: + if self.chain: + _check_multicommand(self, cmd_name, rv) + + return rv + + return None
+ +
[docs] def list_commands(self, ctx: Context) -> t.List[str]: + rv: t.Set[str] = set() + + for source in self.sources: + rv.update(source.list_commands(ctx)) + + return sorted(rv)
+ + +def _check_iter(value: t.Any) -> t.Iterator[t.Any]: + """Check if the value is iterable but not a string. Raises a type + error, or return an iterator over the value. + """ + if isinstance(value, str): + raise TypeError + + return iter(value) + + +
[docs]class Parameter: + r"""A parameter to a command comes in two versions: they are either + :class:`Option`\s or :class:`Argument`\s. Other subclasses are currently + not supported by design as some of the internals for parsing are + intentionally not finalized. + + Some settings are supported by both options and arguments. + + :param param_decls: the parameter declarations for this option or + argument. This is a list of flags or argument + names. + :param type: the type that should be used. Either a :class:`ParamType` + or a Python type. The latter is converted into the former + automatically if supported. + :param required: controls if this is optional or not. + :param default: the default value if omitted. This can also be a callable, + in which case it's invoked when the default is needed + without any arguments. + :param callback: A function to further process or validate the value + after type conversion. It is called as ``f(ctx, param, value)`` + and must return the value. It is called for all sources, + including prompts. + :param nargs: the number of arguments to match. If not ``1`` the return + value is a tuple instead of single value. The default for + nargs is ``1`` (except if the type is a tuple, then it's + the arity of the tuple). If ``nargs=-1``, all remaining + parameters are collected. + :param metavar: how the value is represented in the help page. + :param expose_value: if this is `True` then the value is passed onwards + to the command callback and stored on the context, + otherwise it's skipped. + :param is_eager: eager values are processed before non eager ones. This + should not be set for arguments or it will inverse the + order of processing. + :param envvar: a string or list of strings that are environment variables + that should be checked. + :param shell_complete: A function that returns custom shell + completions. Used instead of the param's type completion if + given. Takes ``ctx, param, incomplete`` and must return a list + of :class:`~click.shell_completion.CompletionItem` or a list of + strings. + + .. versionchanged:: 8.0 + ``process_value`` validates required parameters and bounded + ``nargs``, and invokes the parameter callback before returning + the value. This allows the callback to validate prompts. + ``full_process_value`` is removed. + + .. versionchanged:: 8.0 + ``autocompletion`` is renamed to ``shell_complete`` and has new + semantics described above. The old name is deprecated and will + be removed in 8.1, until then it will be wrapped to match the + new requirements. + + .. versionchanged:: 8.0 + For ``multiple=True, nargs>1``, the default must be a list of + tuples. + + .. versionchanged:: 8.0 + Setting a default is no longer required for ``nargs>1``, it will + default to ``None``. ``multiple=True`` or ``nargs=-1`` will + default to ``()``. + + .. versionchanged:: 7.1 + Empty environment variables are ignored rather than taking the + empty string value. This makes it possible for scripts to clear + variables if they can't unset them. + + .. versionchanged:: 2.0 + Changed signature for parameter callback to also be passed the + parameter. The old callback format will still work, but it will + raise a warning to give you a chance to migrate the code easier. + """ + + param_type_name = "parameter" + + def __init__( + self, + param_decls: t.Optional[t.Sequence[str]] = None, + type: t.Optional[t.Union[types.ParamType, t.Any]] = None, + required: bool = False, + default: t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]] = None, + callback: t.Optional[t.Callable[[Context, "Parameter", t.Any], t.Any]] = None, + nargs: t.Optional[int] = None, + multiple: bool = False, + metavar: t.Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: t.Optional[t.Union[str, t.Sequence[str]]] = None, + shell_complete: t.Optional[ + t.Callable[ + [Context, "Parameter", str], + t.Union[t.List["CompletionItem"], t.List[str]], + ] + ] = None, + ) -> None: + self.name: t.Optional[str] + self.opts: t.List[str] + self.secondary_opts: t.List[str] + self.name, self.opts, self.secondary_opts = self._parse_decls( + param_decls or (), expose_value + ) + self.type: types.ParamType = types.convert_type(type, default) + + # Default nargs to what the type tells us if we have that + # information available. + if nargs is None: + if self.type.is_composite: + nargs = self.type.arity + else: + nargs = 1 + + self.required = required + self.callback = callback + self.nargs = nargs + self.multiple = multiple + self.expose_value = expose_value + self.default = default + self.is_eager = is_eager + self.metavar = metavar + self.envvar = envvar + self._custom_shell_complete = shell_complete + + if __debug__: + if self.type.is_composite and nargs != self.type.arity: + raise ValueError( + f"'nargs' must be {self.type.arity} (or None) for" + f" type {self.type!r}, but it was {nargs}." + ) + + # Skip no default or callable default. + check_default = default if not callable(default) else None + + if check_default is not None: + if multiple: + try: + # Only check the first value against nargs. + check_default = next(_check_iter(check_default), None) + except TypeError: + raise ValueError( + "'default' must be a list when 'multiple' is true." + ) from None + + # Can be None for multiple with empty default. + if nargs != 1 and check_default is not None: + try: + _check_iter(check_default) + except TypeError: + if multiple: + message = ( + "'default' must be a list of lists when 'multiple' is" + " true and 'nargs' != 1." + ) + else: + message = "'default' must be a list when 'nargs' != 1." + + raise ValueError(message) from None + + if nargs > 1 and len(check_default) != nargs: + subject = "item length" if multiple else "length" + raise ValueError( + f"'default' {subject} must match nargs={nargs}." + ) + +
[docs] def to_info_dict(self) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionadded:: 8.0 + """ + return { + "name": self.name, + "param_type_name": self.param_type_name, + "opts": self.opts, + "secondary_opts": self.secondary_opts, + "type": self.type.to_info_dict(), + "required": self.required, + "nargs": self.nargs, + "multiple": self.multiple, + "default": self.default, + "envvar": self.envvar, + }
+ + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + raise NotImplementedError() + + @property + def human_readable_name(self) -> str: + """Returns the human readable name of this parameter. This is the + same as the name for options, but the metavar for arguments. + """ + return self.name # type: ignore + +
[docs] def make_metavar(self) -> str: + if self.metavar is not None: + return self.metavar + + metavar = self.type.get_metavar(self) + + if metavar is None: + metavar = self.type.name.upper() + + if self.nargs != 1: + metavar += "..." + + return metavar
+ + @t.overload + def get_default( + self, ctx: Context, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @t.overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + +
[docs] def get_default( + self, ctx: Context, call: bool = True + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + """Get the default for the parameter. Tries + :meth:`Context.lookup_default` first, then the local default. + + :param ctx: Current context. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0.2 + Type casting is no longer performed when getting a default. + + .. versionchanged:: 8.0.1 + Type casting can fail in resilient parsing mode. Invalid + defaults will not prevent showing help text. + + .. versionchanged:: 8.0 + Looks at ``ctx.default_map`` first. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. + """ + value = ctx.lookup_default(self.name, call=False) # type: ignore + + if value is None: + value = self.default + + if call and callable(value): + value = value() + + return value
+ +
[docs] def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + raise NotImplementedError()
+ +
[docs] def consume_value( + self, ctx: Context, opts: t.Mapping[str, t.Any] + ) -> t.Tuple[t.Any, ParameterSource]: + value = opts.get(self.name) # type: ignore + source = ParameterSource.COMMANDLINE + + if value is None: + value = self.value_from_envvar(ctx) + source = ParameterSource.ENVIRONMENT + + if value is None: + value = ctx.lookup_default(self.name) # type: ignore + source = ParameterSource.DEFAULT_MAP + + if value is None: + value = self.get_default(ctx) + source = ParameterSource.DEFAULT + + return value, source
+ +
[docs] def type_cast_value(self, ctx: Context, value: t.Any) -> t.Any: + """Convert and validate a value against the option's + :attr:`type`, :attr:`multiple`, and :attr:`nargs`. + """ + if value is None: + return () if self.multiple or self.nargs == -1 else None + + def check_iter(value: t.Any) -> t.Iterator[t.Any]: + try: + return _check_iter(value) + except TypeError: + # This should only happen when passing in args manually, + # the parser should construct an iterable when parsing + # the command line. + raise BadParameter( + _("Value must be an iterable."), ctx=ctx, param=self + ) from None + + if self.nargs == 1 or self.type.is_composite: + + def convert(value: t.Any) -> t.Any: + return self.type(value, param=self, ctx=ctx) + + elif self.nargs == -1: + + def convert(value: t.Any) -> t.Any: # t.Tuple[t.Any, ...] + return tuple(self.type(x, self, ctx) for x in check_iter(value)) + + else: # nargs > 1 + + def convert(value: t.Any) -> t.Any: # t.Tuple[t.Any, ...] + value = tuple(check_iter(value)) + + if len(value) != self.nargs: + raise BadParameter( + ngettext( + "Takes {nargs} values but 1 was given.", + "Takes {nargs} values but {len} were given.", + len(value), + ).format(nargs=self.nargs, len=len(value)), + ctx=ctx, + param=self, + ) + + return tuple(self.type(x, self, ctx) for x in value) + + if self.multiple: + return tuple(convert(x) for x in check_iter(value)) + + return convert(value)
+ +
[docs] def value_is_missing(self, value: t.Any) -> bool: + if value is None: + return True + + if (self.nargs != 1 or self.multiple) and value == (): + return True + + return False
+ +
[docs] def process_value(self, ctx: Context, value: t.Any) -> t.Any: + value = self.type_cast_value(ctx, value) + + if self.required and self.value_is_missing(value): + raise MissingParameter(ctx=ctx, param=self) + + if self.callback is not None: + value = self.callback(ctx, self, value) + + return value
+ +
[docs] def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: + if self.envvar is None: + return None + + if isinstance(self.envvar, str): + rv = os.environ.get(self.envvar) + + if rv: + return rv + else: + for envvar in self.envvar: + rv = os.environ.get(envvar) + + if rv: + return rv + + return None
+ +
[docs] def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: + rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) + + if rv is not None and self.nargs != 1: + rv = self.type.split_envvar_value(rv) + + return rv
+ +
[docs] def handle_parse_result( + self, ctx: Context, opts: t.Mapping[str, t.Any], args: t.List[str] + ) -> t.Tuple[t.Any, t.List[str]]: + with augment_usage_errors(ctx, param=self): + value, source = self.consume_value(ctx, opts) + ctx.set_parameter_source(self.name, source) # type: ignore + + try: + value = self.process_value(ctx, value) + except Exception: + if not ctx.resilient_parsing: + raise + + value = None + + if self.expose_value: + ctx.params[self.name] = value # type: ignore + + return value, args
+ +
[docs] def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: + pass
+ +
[docs] def get_usage_pieces(self, ctx: Context) -> t.List[str]: + return []
+ +
[docs] def get_error_hint(self, ctx: Context) -> str: + """Get a stringified version of the param for use in error messages to + indicate which param caused the error. + """ + hint_list = self.opts or [self.human_readable_name] + return " / ".join(f"'{x}'" for x in hint_list)
+ +
[docs] def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]: + """Return a list of completions for the incomplete value. If a + ``shell_complete`` function was given during init, it is used. + Otherwise, the :attr:`type` + :meth:`~click.types.ParamType.shell_complete` function is used. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + if self._custom_shell_complete is not None: + results = self._custom_shell_complete(ctx, self, incomplete) + + if results and isinstance(results[0], str): + from click.shell_completion import CompletionItem + + results = [CompletionItem(c) for c in results] + + return t.cast(t.List["CompletionItem"], results) + + return self.type.shell_complete(ctx, self, incomplete)
+ + +class Option(Parameter): + """Options are usually optional values on the command line and + have some extra features that arguments don't have. + + All other parameters are passed onwards to the parameter constructor. + + :param show_default: Show the default value for this option in its + help text. Values are not shown by default, unless + :attr:`Context.show_default` is ``True``. If this value is a + string, it shows that string in parentheses instead of the + actual value. This is particularly useful for dynamic options. + For single option boolean flags, the default remains hidden if + its value is ``False``. + :param show_envvar: Controls if an environment variable should be + shown on the help page. Normally, environment variables are not + shown. + :param prompt: If set to ``True`` or a non empty string then the + user will be prompted for input. If set to ``True`` the prompt + will be the option name capitalized. + :param confirmation_prompt: Prompt a second time to confirm the + value if it was prompted for. Can be set to a string instead of + ``True`` to customize the message. + :param prompt_required: If set to ``False``, the user will be + prompted for input only when the option was specified as a flag + without a value. + :param hide_input: If this is ``True`` then the input on the prompt + will be hidden from the user. This is useful for password input. + :param is_flag: forces this option to act as a flag. The default is + auto detection. + :param flag_value: which value should be used for this flag if it's + enabled. This is set to a boolean automatically if + the option string contains a slash to mark two options. + :param multiple: if this is set to `True` then the argument is accepted + multiple times and recorded. This is similar to ``nargs`` + in how it works but supports arbitrary number of + arguments. + :param count: this flag makes an option increment an integer. + :param allow_from_autoenv: if this is enabled then the value of this + parameter will be pulled from an environment + variable in case a prefix is defined on the + context. + :param help: the help string. + :param hidden: hide this option from help outputs. + :param attrs: Other command arguments described in :class:`Parameter`. + + .. versionchanged:: 8.1.0 + Help text indentation is cleaned here instead of only in the + ``@option`` decorator. + + .. versionchanged:: 8.1.0 + The ``show_default`` parameter overrides + ``Context.show_default``. + + .. versionchanged:: 8.1.0 + The default of a single option boolean flag is not shown if the + default value is ``False``. + + .. versionchanged:: 8.0.1 + ``type`` is detected from ``flag_value`` if given. + """ + + param_type_name = "option" + + def __init__( + self, + param_decls: t.Optional[t.Sequence[str]] = None, + show_default: t.Union[bool, str, None] = None, + prompt: t.Union[bool, str] = False, + confirmation_prompt: t.Union[bool, str] = False, + prompt_required: bool = True, + hide_input: bool = False, + is_flag: t.Optional[bool] = None, + flag_value: t.Optional[t.Any] = None, + multiple: bool = False, + count: bool = False, + allow_from_autoenv: bool = True, + type: t.Optional[t.Union[types.ParamType, t.Any]] = None, + help: t.Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = False, + **attrs: t.Any, + ) -> None: + if help: + help = inspect.cleandoc(help) + + default_is_missing = "default" not in attrs + super().__init__(param_decls, type=type, multiple=multiple, **attrs) + + if prompt is True: + if self.name is None: + raise TypeError("'name' is required with 'prompt=True'.") + + prompt_text: t.Optional[str] = self.name.replace("_", " ").capitalize() + elif prompt is False: + prompt_text = None + else: + prompt_text = prompt + + self.prompt = prompt_text + self.confirmation_prompt = confirmation_prompt + self.prompt_required = prompt_required + self.hide_input = hide_input + self.hidden = hidden + + # If prompt is enabled but not required, then the option can be + # used as a flag to indicate using prompt or flag_value. + self._flag_needs_value = self.prompt is not None and not self.prompt_required + + if is_flag is None: + if flag_value is not None: + # Implicitly a flag because flag_value was set. + is_flag = True + elif self._flag_needs_value: + # Not a flag, but when used as a flag it shows a prompt. + is_flag = False + else: + # Implicitly a flag because flag options were given. + is_flag = bool(self.secondary_opts) + elif is_flag is False and not self._flag_needs_value: + # Not a flag, and prompt is not enabled, can be used as a + # flag if flag_value is set. + self._flag_needs_value = flag_value is not None + + self.default: t.Union[t.Any, t.Callable[[], t.Any]] + + if is_flag and default_is_missing and not self.required: + if multiple: + self.default = () + else: + self.default = False + + if flag_value is None: + flag_value = not self.default + + self.type: types.ParamType + if is_flag and type is None: + # Re-guess the type from the flag value instead of the + # default. + self.type = types.convert_type(None, flag_value) + + self.is_flag: bool = is_flag + self.is_bool_flag: bool = is_flag and isinstance(self.type, types.BoolParamType) + self.flag_value: t.Any = flag_value + + # Counting + self.count = count + if count: + if type is None: + self.type = types.IntRange(min=0) + if default_is_missing: + self.default = 0 + + self.allow_from_autoenv = allow_from_autoenv + self.help = help + self.show_default = show_default + self.show_choices = show_choices + self.show_envvar = show_envvar + + if __debug__: + if self.nargs == -1: + raise TypeError("nargs=-1 is not supported for options.") + + if self.prompt and self.is_flag and not self.is_bool_flag: + raise TypeError("'prompt' is not valid for non-boolean flag.") + + if not self.is_bool_flag and self.secondary_opts: + raise TypeError("Secondary flag is not valid for non-boolean flag.") + + if self.is_bool_flag and self.hide_input and self.prompt is not None: + raise TypeError( + "'prompt' with 'hide_input' is not valid for boolean flag." + ) + + if self.count: + if self.multiple: + raise TypeError("'count' is not valid with 'multiple'.") + + if self.is_flag: + raise TypeError("'count' is not valid with 'is_flag'.") + + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update( + help=self.help, + prompt=self.prompt, + is_flag=self.is_flag, + flag_value=self.flag_value, + count=self.count, + hidden=self.hidden, + ) + return info_dict + + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + opts = [] + secondary_opts = [] + name = None + possible_names = [] + + for decl in decls: + if decl.isidentifier(): + if name is not None: + raise TypeError(f"Name '{name}' defined twice") + name = decl + else: + split_char = ";" if decl[:1] == "/" else "/" + if split_char in decl: + first, second = decl.split(split_char, 1) + first = first.rstrip() + if first: + possible_names.append(split_opt(first)) + opts.append(first) + second = second.lstrip() + if second: + secondary_opts.append(second.lstrip()) + if first == second: + raise ValueError( + f"Boolean option {decl!r} cannot use the" + " same flag for true/false." + ) + else: + possible_names.append(split_opt(decl)) + opts.append(decl) + + if name is None and possible_names: + possible_names.sort(key=lambda x: -len(x[0])) # group long options first + name = possible_names[0][1].replace("-", "_").lower() + if not name.isidentifier(): + name = None + + if name is None: + if not expose_value: + return None, opts, secondary_opts + raise TypeError("Could not determine name for option") + + if not opts and not secondary_opts: + raise TypeError( + f"No options defined but a name was passed ({name})." + " Did you mean to declare an argument instead? Did" + f" you mean to pass '--{name}'?" + ) + + return name, opts, secondary_opts + + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + if self.multiple: + action = "append" + elif self.count: + action = "count" + else: + action = "store" + + if self.is_flag: + action = f"{action}_const" + + if self.is_bool_flag and self.secondary_opts: + parser.add_option( + obj=self, opts=self.opts, dest=self.name, action=action, const=True + ) + parser.add_option( + obj=self, + opts=self.secondary_opts, + dest=self.name, + action=action, + const=False, + ) + else: + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + const=self.flag_value, + ) + else: + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + nargs=self.nargs, + ) + + def get_help_record(self, ctx: Context) -> t.Optional[t.Tuple[str, str]]: + if self.hidden: + return None + + any_prefix_is_slash = False + + def _write_opts(opts: t.Sequence[str]) -> str: + nonlocal any_prefix_is_slash + + rv, any_slashes = join_options(opts) + + if any_slashes: + any_prefix_is_slash = True + + if not self.is_flag and not self.count: + rv += f" {self.make_metavar()}" + + return rv + + rv = [_write_opts(self.opts)] + + if self.secondary_opts: + rv.append(_write_opts(self.secondary_opts)) + + help = self.help or "" + extra = [] + + if self.show_envvar: + envvar = self.envvar + + if envvar is None: + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + + if envvar is not None: + var_str = ( + envvar + if isinstance(envvar, str) + else ", ".join(str(d) for d in envvar) + ) + extra.append(_("env var: {var}").format(var=var_str)) + + # Temporarily enable resilient parsing to avoid type casting + # failing for the default. Might be possible to extend this to + # help formatting in general. + resilient = ctx.resilient_parsing + ctx.resilient_parsing = True + + try: + default_value = self.get_default(ctx, call=False) + finally: + ctx.resilient_parsing = resilient + + show_default = False + show_default_is_str = False + + if self.show_default is not None: + if isinstance(self.show_default, str): + show_default_is_str = show_default = True + else: + show_default = self.show_default + elif ctx.show_default is not None: + show_default = ctx.show_default + + if show_default_is_str or (show_default and (default_value is not None)): + if show_default_is_str: + default_string = f"({self.show_default})" + elif isinstance(default_value, (list, tuple)): + default_string = ", ".join(str(d) for d in default_value) + elif inspect.isfunction(default_value): + default_string = _("(dynamic)") + elif self.is_bool_flag and self.secondary_opts: + # For boolean flags that have distinct True/False opts, + # use the opt without prefix instead of the value. + default_string = split_opt( + (self.opts if self.default else self.secondary_opts)[0] + )[1] + elif self.is_bool_flag and not self.secondary_opts and not default_value: + default_string = "" + else: + default_string = str(default_value) + + if default_string: + extra.append(_("default: {default}").format(default=default_string)) + + if ( + isinstance(self.type, types._NumberRangeBase) + # skip count with default range type + and not (self.count and self.type.min == 0 and self.type.max is None) + ): + range_str = self.type._describe_range() + + if range_str: + extra.append(range_str) + + if self.required: + extra.append(_("required")) + + if extra: + extra_str = "; ".join(extra) + help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" + + return ("; " if any_prefix_is_slash else " / ").join(rv), help + + @t.overload + def get_default( + self, ctx: Context, call: "te.Literal[True]" = True + ) -> t.Optional[t.Any]: + ... + + @t.overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + ... + + def get_default( + self, ctx: Context, call: bool = True + ) -> t.Optional[t.Union[t.Any, t.Callable[[], t.Any]]]: + # If we're a non boolean flag our default is more complex because + # we need to look at all flags in the same group to figure out + # if we're the default one in which case we return the flag + # value as default. + if self.is_flag and not self.is_bool_flag: + for param in ctx.command.params: + if param.name == self.name and param.default: + return t.cast(Option, param).flag_value + + return None + + return super().get_default(ctx, call=call) + + def prompt_for_value(self, ctx: Context) -> t.Any: + """This is an alternative flow that can be activated in the full + value processing if a value does not exist. It will prompt the + user until a valid value exists and then returns the processed + value as result. + """ + assert self.prompt is not None + + # Calculate the default before prompting anything to be stable. + default = self.get_default(ctx) + + # If this is a prompt for a flag we need to handle this + # differently. + if self.is_bool_flag: + return confirm(self.prompt, default) + + return prompt( + self.prompt, + default=default, + type=self.type, + hide_input=self.hide_input, + show_choices=self.show_choices, + confirmation_prompt=self.confirmation_prompt, + value_proc=lambda x: self.process_value(ctx, x), + ) + + def resolve_envvar_value(self, ctx: Context) -> t.Optional[str]: + rv = super().resolve_envvar_value(ctx) + + if rv is not None: + return rv + + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + rv = os.environ.get(envvar) + + if rv: + return rv + + return None + + def value_from_envvar(self, ctx: Context) -> t.Optional[t.Any]: + rv: t.Optional[t.Any] = self.resolve_envvar_value(ctx) + + if rv is None: + return None + + value_depth = (self.nargs != 1) + bool(self.multiple) + + if value_depth > 0: + rv = self.type.split_envvar_value(rv) + + if self.multiple and self.nargs != 1: + rv = batch(rv, self.nargs) + + return rv + + def consume_value( + self, ctx: Context, opts: t.Mapping[str, "Parameter"] + ) -> t.Tuple[t.Any, ParameterSource]: + value, source = super().consume_value(ctx, opts) + + # The parser will emit a sentinel value if the option can be + # given as a flag without a value. This is different from None + # to distinguish from the flag not being given at all. + if value is _flag_needs_value: + if self.prompt is not None and not ctx.resilient_parsing: + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT + else: + value = self.flag_value + source = ParameterSource.COMMANDLINE + + elif ( + self.multiple + and value is not None + and any(v is _flag_needs_value for v in value) + ): + value = [self.flag_value if v is _flag_needs_value else v for v in value] + source = ParameterSource.COMMANDLINE + + # The value wasn't set, or used the param's default, prompt if + # prompting is enabled. + elif ( + source in {None, ParameterSource.DEFAULT} + and self.prompt is not None + and (self.required or self.prompt_required) + and not ctx.resilient_parsing + ): + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT + + return value, source + + +class Argument(Parameter): + """Arguments are positional parameters to a command. They generally + provide fewer features than options but can have infinite ``nargs`` + and are required by default. + + All parameters are passed onwards to the constructor of :class:`Parameter`. + """ + + param_type_name = "argument" + + def __init__( + self, + param_decls: t.Sequence[str], + required: t.Optional[bool] = None, + **attrs: t.Any, + ) -> None: + if required is None: + if attrs.get("default") is not None: + required = False + else: + required = attrs.get("nargs", 1) > 0 + + if "multiple" in attrs: + raise TypeError("__init__() got an unexpected keyword argument 'multiple'.") + + super().__init__(param_decls, required=required, **attrs) + + if __debug__: + if self.default is not None and self.nargs == -1: + raise TypeError("'default' is not supported for nargs=-1.") + + @property + def human_readable_name(self) -> str: + if self.metavar is not None: + return self.metavar + return self.name.upper() # type: ignore + + def make_metavar(self) -> str: + if self.metavar is not None: + return self.metavar + var = self.type.get_metavar(self) + if not var: + var = self.name.upper() # type: ignore + if not self.required: + var = f"[{var}]" + if self.nargs != 1: + var += "..." + return var + + def _parse_decls( + self, decls: t.Sequence[str], expose_value: bool + ) -> t.Tuple[t.Optional[str], t.List[str], t.List[str]]: + if not decls: + if not expose_value: + return None, [], [] + raise TypeError("Could not determine name for argument") + if len(decls) == 1: + name = arg = decls[0] + name = name.replace("-", "_").lower() + else: + raise TypeError( + "Arguments take exactly one parameter declaration, got" + f" {len(decls)}." + ) + return name, [arg], [] + + def get_usage_pieces(self, ctx: Context) -> t.List[str]: + return [self.make_metavar()] + + def get_error_hint(self, ctx: Context) -> str: + return f"'{self.make_metavar()}'" + + def add_to_parser(self, parser: OptionParser, ctx: Context) -> None: + parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click/decorators.html b/_modules/click/decorators.html new file mode 100644 index 000000000..35bd7b529 --- /dev/null +++ b/_modules/click/decorators.html @@ -0,0 +1,890 @@ + + + + + + + + click.decorators - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.decorators

+import inspect
+import types
+import typing as t
+from functools import update_wrapper
+from gettext import gettext as _
+
+from .core import Argument
+from .core import Command
+from .core import Context
+from .core import Group
+from .core import Option
+from .core import Parameter
+from .globals import get_current_context
+from .utils import echo
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    P = te.ParamSpec("P")
+
+R = t.TypeVar("R")
+T = t.TypeVar("T")
+_AnyCallable = t.Callable[..., t.Any]
+FC = t.TypeVar("FC", bound=t.Union[_AnyCallable, Command])
+
+
+def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
+    """Marks a callback as wanting to receive the current context
+    object as first argument.
+    """
+
+    def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R":
+        return f(get_current_context(), *args, **kwargs)
+
+    return update_wrapper(new_func, f)
+
+
+
[docs]def pass_obj(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]": + """Similar to :func:`pass_context`, but only pass the object on the + context onwards (:attr:`Context.obj`). This is useful if that object + represents the state of a nested system. + """ + + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": + return f(get_current_context().obj, *args, **kwargs) + + return update_wrapper(new_func, f)
+ + +
[docs]def make_pass_decorator( + object_type: t.Type[T], ensure: bool = False +) -> t.Callable[["t.Callable[te.Concatenate[T, P], R]"], "t.Callable[P, R]"]: + """Given an object type this creates a decorator that will work + similar to :func:`pass_obj` but instead of passing the object of the + current context, it will find the innermost context of type + :func:`object_type`. + + This generates a decorator that works roughly like this:: + + from functools import update_wrapper + + def decorator(f): + @pass_context + def new_func(ctx, *args, **kwargs): + obj = ctx.find_object(object_type) + return ctx.invoke(f, obj, *args, **kwargs) + return update_wrapper(new_func, f) + return decorator + + :param object_type: the type of the object to pass. + :param ensure: if set to `True`, a new object will be created and + remembered on the context if it's not there yet. + """ + + def decorator(f: "t.Callable[te.Concatenate[T, P], R]") -> "t.Callable[P, R]": + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": + ctx = get_current_context() + + obj: t.Optional[T] + if ensure: + obj = ctx.ensure_object(object_type) + else: + obj = ctx.find_object(object_type) + + if obj is None: + raise RuntimeError( + "Managed to invoke callback without a context" + f" object of type {object_type.__name__!r}" + " existing." + ) + + return ctx.invoke(f, obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + return decorator # type: ignore[return-value]
+ + +def pass_meta_key( + key: str, *, doc_description: t.Optional[str] = None +) -> "t.Callable[[t.Callable[te.Concatenate[t.Any, P], R]], t.Callable[P, R]]": + """Create a decorator that passes a key from + :attr:`click.Context.meta` as the first argument to the decorated + function. + + :param key: Key in ``Context.meta`` to pass. + :param doc_description: Description of the object being passed, + inserted into the decorator's docstring. Defaults to "the 'key' + key from Context.meta". + + .. versionadded:: 8.0 + """ + + def decorator(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]": + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> R: + ctx = get_current_context() + obj = ctx.meta[key] + return ctx.invoke(f, obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + if doc_description is None: + doc_description = f"the {key!r} key from :attr:`click.Context.meta`" + + decorator.__doc__ = ( + f"Decorator that passes {doc_description} as the first argument" + " to the decorated function." + ) + return decorator # type: ignore[return-value] + + +CmdType = t.TypeVar("CmdType", bound=Command) + + +# variant: no call, directly as decorator for a function. +@t.overload +def command(name: _AnyCallable) -> Command: + ... + + +# variant: with positional name and with positional or keyword cls argument: +# @command(namearg, CommandCls, ...) or @command(namearg, cls=CommandCls, ...) +@t.overload +def command( + name: t.Optional[str], + cls: t.Type[CmdType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], CmdType]: + ... + + +# variant: name omitted, cls _must_ be a keyword argument, @command(cls=CommandCls, ...) +@t.overload +def command( + name: None = None, + *, + cls: t.Type[CmdType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], CmdType]: + ... + + +# variant: with optional string name, no cls argument provided. +@t.overload +def command( + name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any +) -> t.Callable[[_AnyCallable], Command]: + ... + + +def command( + name: t.Union[t.Optional[str], _AnyCallable] = None, + cls: t.Optional[t.Type[CmdType]] = None, + **attrs: t.Any, +) -> t.Union[Command, t.Callable[[_AnyCallable], t.Union[Command, CmdType]]]: + r"""Creates a new :class:`Command` and uses the decorated function as + callback. This will also automatically attach all decorated + :func:`option`\s and :func:`argument`\s as parameters to the command. + + The name of the command defaults to the name of the function with + underscores replaced by dashes. If you want to change that, you can + pass the intended name as the first argument. + + All keyword arguments are forwarded to the underlying command class. + For the ``params`` argument, any decorated params are appended to + the end of the list. + + Once decorated the function turns into a :class:`Command` instance + that can be invoked as a command line utility or be attached to a + command :class:`Group`. + + :param name: the name of the command. This defaults to the function + name with underscores replaced by dashes. + :param cls: the command class to instantiate. This defaults to + :class:`Command`. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + + .. versionchanged:: 8.1 + The ``params`` argument can be used. Decorated params are + appended to the end of the list. + """ + + func: t.Optional[t.Callable[[_AnyCallable], t.Any]] = None + + if callable(name): + func = name + name = None + assert cls is None, "Use 'command(cls=cls)(callable)' to specify a class." + assert not attrs, "Use 'command(**kwargs)(callable)' to provide arguments." + + if cls is None: + cls = t.cast(t.Type[CmdType], Command) + + def decorator(f: _AnyCallable) -> CmdType: + if isinstance(f, Command): + raise TypeError("Attempted to convert a callback into a command twice.") + + attr_params = attrs.pop("params", None) + params = attr_params if attr_params is not None else [] + + try: + decorator_params = f.__click_params__ # type: ignore + except AttributeError: + pass + else: + del f.__click_params__ # type: ignore + params.extend(reversed(decorator_params)) + + if attrs.get("help") is None: + attrs["help"] = f.__doc__ + + if t.TYPE_CHECKING: + assert cls is not None + assert not callable(name) + + cmd = cls( + name=name or f.__name__.lower().replace("_", "-"), + callback=f, + params=params, + **attrs, + ) + cmd.__doc__ = f.__doc__ + return cmd + + if func is not None: + return decorator(func) + + return decorator + + +GrpType = t.TypeVar("GrpType", bound=Group) + + +# variant: no call, directly as decorator for a function. +@t.overload +def group(name: _AnyCallable) -> Group: + ... + + +# variant: with positional name and with positional or keyword cls argument: +# @group(namearg, GroupCls, ...) or @group(namearg, cls=GroupCls, ...) +@t.overload +def group( + name: t.Optional[str], + cls: t.Type[GrpType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], GrpType]: + ... + + +# variant: name omitted, cls _must_ be a keyword argument, @group(cmd=GroupCls, ...) +@t.overload +def group( + name: None = None, + *, + cls: t.Type[GrpType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], GrpType]: + ... + + +# variant: with optional string name, no cls argument provided. +@t.overload +def group( + name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any +) -> t.Callable[[_AnyCallable], Group]: + ... + + +def group( + name: t.Union[str, _AnyCallable, None] = None, + cls: t.Optional[t.Type[GrpType]] = None, + **attrs: t.Any, +) -> t.Union[Group, t.Callable[[_AnyCallable], t.Union[Group, GrpType]]]: + """Creates a new :class:`Group` with a function as callback. This + works otherwise the same as :func:`command` just that the `cls` + parameter is set to :class:`Group`. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + """ + if cls is None: + cls = t.cast(t.Type[GrpType], Group) + + if callable(name): + return command(cls=cls, **attrs)(name) + + return command(name, cls, **attrs) + + +def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None: + if isinstance(f, Command): + f.params.append(param) + else: + if not hasattr(f, "__click_params__"): + f.__click_params__ = [] # type: ignore + + f.__click_params__.append(param) # type: ignore + + +def argument( + *param_decls: str, cls: t.Optional[t.Type[Argument]] = None, **attrs: t.Any +) -> t.Callable[[FC], FC]: + """Attaches an argument to the command. All positional arguments are + passed as parameter declarations to :class:`Argument`; all keyword + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Argument` instance manually + and attaching it to the :attr:`Command.params` list. + + For the default argument class, refer to :class:`Argument` and + :class:`Parameter` for descriptions of parameters. + + :param cls: the argument class to instantiate. This defaults to + :class:`Argument`. + :param param_decls: Passed as positional arguments to the constructor of + ``cls``. + :param attrs: Passed as keyword arguments to the constructor of ``cls``. + """ + if cls is None: + cls = Argument + + def decorator(f: FC) -> FC: + _param_memo(f, cls(param_decls, **attrs)) + return f + + return decorator + + +def option( + *param_decls: str, cls: t.Optional[t.Type[Option]] = None, **attrs: t.Any +) -> t.Callable[[FC], FC]: + """Attaches an option to the command. All positional arguments are + passed as parameter declarations to :class:`Option`; all keyword + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Option` instance manually + and attaching it to the :attr:`Command.params` list. + + For the default option class, refer to :class:`Option` and + :class:`Parameter` for descriptions of parameters. + + :param cls: the option class to instantiate. This defaults to + :class:`Option`. + :param param_decls: Passed as positional arguments to the constructor of + ``cls``. + :param attrs: Passed as keyword arguments to the constructor of ``cls``. + """ + if cls is None: + cls = Option + + def decorator(f: FC) -> FC: + _param_memo(f, cls(param_decls, **attrs)) + return f + + return decorator + + +
[docs]def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--yes`` option which shows a prompt before continuing if + not passed. If the prompt is declined, the program will exit. + + :param param_decls: One or more option names. Defaults to the single + value ``"--yes"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ + + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value: + ctx.abort() + + if not param_decls: + param_decls = ("--yes",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("callback", callback) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("prompt", "Do you want to continue?") + kwargs.setdefault("help", "Confirm the action without prompting.") + return option(*param_decls, **kwargs)
+ + +
[docs]def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--password`` option which prompts for a password, hiding + input and asking to enter the value again for confirmation. + + :param param_decls: One or more option names. Defaults to the single + value ``"--password"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ + if not param_decls: + param_decls = ("--password",) + + kwargs.setdefault("prompt", True) + kwargs.setdefault("confirmation_prompt", True) + kwargs.setdefault("hide_input", True) + return option(*param_decls, **kwargs)
+ + +
[docs]def version_option( + version: t.Optional[str] = None, + *param_decls: str, + package_name: t.Optional[str] = None, + prog_name: t.Optional[str] = None, + message: t.Optional[str] = None, + **kwargs: t.Any, +) -> t.Callable[[FC], FC]: + """Add a ``--version`` option which immediately prints the version + number and exits the program. + + If ``version`` is not provided, Click will try to detect it using + :func:`importlib.metadata.version` to get the version for the + ``package_name``. On Python < 3.8, the ``importlib_metadata`` + backport must be installed. + + If ``package_name`` is not provided, Click will try to detect it by + inspecting the stack frames. This will be used to detect the + version, so it must match the name of the installed package. + + :param version: The version number to show. If not provided, Click + will try to detect it. + :param param_decls: One or more option names. Defaults to the single + value ``"--version"``. + :param package_name: The package name to detect the version from. If + not provided, Click will try to detect it. + :param prog_name: The name of the CLI to show in the message. If not + provided, it will be detected from the command. + :param message: The message to show. The values ``%(prog)s``, + ``%(package)s``, and ``%(version)s`` are available. Defaults to + ``"%(prog)s, version %(version)s"``. + :param kwargs: Extra arguments are passed to :func:`option`. + :raise RuntimeError: ``version`` could not be detected. + + .. versionchanged:: 8.0 + Add the ``package_name`` parameter, and the ``%(package)s`` + value for messages. + + .. versionchanged:: 8.0 + Use :mod:`importlib.metadata` instead of ``pkg_resources``. The + version is detected based on the package name, not the entry + point name. The Python package name must match the installed + package name, or be passed with ``package_name=``. + """ + if message is None: + message = _("%(prog)s, version %(version)s") + + if version is None and package_name is None: + frame = inspect.currentframe() + f_back = frame.f_back if frame is not None else None + f_globals = f_back.f_globals if f_back is not None else None + # break reference cycle + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + del frame + + if f_globals is not None: + package_name = f_globals.get("__name__") + + if package_name == "__main__": + package_name = f_globals.get("__package__") + + if package_name: + package_name = package_name.partition(".")[0] + + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + + nonlocal prog_name + nonlocal version + + if prog_name is None: + prog_name = ctx.find_root().info_name + + if version is None and package_name is not None: + metadata: t.Optional[types.ModuleType] + + try: + from importlib import metadata # type: ignore + except ImportError: + # Python < 3.8 + import importlib_metadata as metadata # type: ignore + + try: + version = metadata.version(package_name) # type: ignore + except metadata.PackageNotFoundError: # type: ignore + raise RuntimeError( + f"{package_name!r} is not installed. Try passing" + " 'package_name' instead." + ) from None + + if version is None: + raise RuntimeError( + f"Could not determine the version for {package_name!r} automatically." + ) + + echo( + message % {"prog": prog_name, "package": package_name, "version": version}, + color=ctx.color, + ) + ctx.exit() + + if not param_decls: + param_decls = ("--version",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", _("Show the version and exit.")) + kwargs["callback"] = callback + return option(*param_decls, **kwargs)
+ + +def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--help`` option which immediately prints the help page + and exits the program. + + This is usually unnecessary, as the ``--help`` option is added to + each command automatically unless ``add_help_option=False`` is + passed. + + :param param_decls: One or more option names. Defaults to the single + value ``"--help"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ + + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + if not param_decls: + param_decls = ("--help",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", _("Show this message and exit.")) + kwargs["callback"] = callback + return option(*param_decls, **kwargs) +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click/exceptions.html b/_modules/click/exceptions.html new file mode 100644 index 000000000..80e88e3ff --- /dev/null +++ b/_modules/click/exceptions.html @@ -0,0 +1,617 @@ + + + + + + + + click.exceptions - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.exceptions

+import typing as t
+from gettext import gettext as _
+from gettext import ngettext
+
+from ._compat import get_text_stderr
+from .utils import echo
+from .utils import format_filename
+
+if t.TYPE_CHECKING:
+    from .core import Command
+    from .core import Context
+    from .core import Parameter
+
+
+def _join_param_hints(
+    param_hint: t.Optional[t.Union[t.Sequence[str], str]]
+) -> t.Optional[str]:
+    if param_hint is not None and not isinstance(param_hint, str):
+        return " / ".join(repr(x) for x in param_hint)
+
+    return param_hint
+
+
+
[docs]class ClickException(Exception): + """An exception that Click can handle and show to the user.""" + + #: The exit code for this exception. + exit_code = 1 + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + +
[docs] def format_message(self) -> str: + return self.message
+ + def __str__(self) -> str: + return self.message + +
[docs] def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None: + if file is None: + file = get_text_stderr() + + echo(_("Error: {message}").format(message=self.format_message()), file=file)
+ + +
[docs]class UsageError(ClickException): + """An internal exception that signals a usage error. This typically + aborts any further handling. + + :param message: the error message to display. + :param ctx: optionally the context that caused this error. Click will + fill in the context automatically in some situations. + """ + + exit_code = 2 + + def __init__(self, message: str, ctx: t.Optional["Context"] = None) -> None: + super().__init__(message) + self.ctx = ctx + self.cmd: t.Optional["Command"] = self.ctx.command if self.ctx else None + +
[docs] def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None: + if file is None: + file = get_text_stderr() + color = None + hint = "" + if ( + self.ctx is not None + and self.ctx.command.get_help_option(self.ctx) is not None + ): + hint = _("Try '{command} {option}' for help.").format( + command=self.ctx.command_path, option=self.ctx.help_option_names[0] + ) + hint = f"{hint}\n" + if self.ctx is not None: + color = self.ctx.color + echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color) + echo( + _("Error: {message}").format(message=self.format_message()), + file=file, + color=color, + )
+ + +
[docs]class BadParameter(UsageError): + """An exception that formats out a standardized error message for a + bad parameter. This is useful when thrown from a callback or type as + Click will attach contextual information to it (for instance, which + parameter it is). + + .. versionadded:: 2.0 + + :param param: the parameter object that caused this error. This can + be left out, and Click will attach this info itself + if possible. + :param param_hint: a string that shows up as parameter name. This + can be used as alternative to `param` in cases + where custom validation should happen. If it is + a string it's used as such, if it's a list then + each item is quoted and separated. + """ + + def __init__( + self, + message: str, + ctx: t.Optional["Context"] = None, + param: t.Optional["Parameter"] = None, + param_hint: t.Optional[str] = None, + ) -> None: + super().__init__(message, ctx) + self.param = param + self.param_hint = param_hint + +
[docs] def format_message(self) -> str: + if self.param_hint is not None: + param_hint = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) # type: ignore + else: + return _("Invalid value: {message}").format(message=self.message) + + return _("Invalid value for {param_hint}: {message}").format( + param_hint=_join_param_hints(param_hint), message=self.message + )
+ + +
[docs]class MissingParameter(BadParameter): + """Raised if click required an option or argument but it was not + provided when invoking the script. + + .. versionadded:: 4.0 + + :param param_type: a string that indicates the type of the parameter. + The default is to inherit the parameter type from + the given `param`. Valid values are ``'parameter'``, + ``'option'`` or ``'argument'``. + """ + + def __init__( + self, + message: t.Optional[str] = None, + ctx: t.Optional["Context"] = None, + param: t.Optional["Parameter"] = None, + param_hint: t.Optional[str] = None, + param_type: t.Optional[str] = None, + ) -> None: + super().__init__(message or "", ctx, param, param_hint) + self.param_type = param_type + +
[docs] def format_message(self) -> str: + if self.param_hint is not None: + param_hint: t.Optional[str] = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) # type: ignore + else: + param_hint = None + + param_hint = _join_param_hints(param_hint) + param_hint = f" {param_hint}" if param_hint else "" + + param_type = self.param_type + if param_type is None and self.param is not None: + param_type = self.param.param_type_name + + msg = self.message + if self.param is not None: + msg_extra = self.param.type.get_missing_message(self.param) + if msg_extra: + if msg: + msg += f". {msg_extra}" + else: + msg = msg_extra + + msg = f" {msg}" if msg else "" + + # Translate param_type for known types. + if param_type == "argument": + missing = _("Missing argument") + elif param_type == "option": + missing = _("Missing option") + elif param_type == "parameter": + missing = _("Missing parameter") + else: + missing = _("Missing {param_type}").format(param_type=param_type) + + return f"{missing}{param_hint}.{msg}"
+ + def __str__(self) -> str: + if not self.message: + param_name = self.param.name if self.param else None + return _("Missing parameter: {param_name}").format(param_name=param_name) + else: + return self.message
+ + +
[docs]class NoSuchOption(UsageError): + """Raised if click attempted to handle an option that does not + exist. + + .. versionadded:: 4.0 + """ + + def __init__( + self, + option_name: str, + message: t.Optional[str] = None, + possibilities: t.Optional[t.Sequence[str]] = None, + ctx: t.Optional["Context"] = None, + ) -> None: + if message is None: + message = _("No such option: {name}").format(name=option_name) + + super().__init__(message, ctx) + self.option_name = option_name + self.possibilities = possibilities + +
[docs] def format_message(self) -> str: + if not self.possibilities: + return self.message + + possibility_str = ", ".join(sorted(self.possibilities)) + suggest = ngettext( + "Did you mean {possibility}?", + "(Possible options: {possibilities})", + len(self.possibilities), + ).format(possibility=possibility_str, possibilities=possibility_str) + return f"{self.message} {suggest}"
+ + +
[docs]class BadOptionUsage(UsageError): + """Raised if an option is generally supplied but the use of the option + was incorrect. This is for instance raised if the number of arguments + for an option is not correct. + + .. versionadded:: 4.0 + + :param option_name: the name of the option being used incorrectly. + """ + + def __init__( + self, option_name: str, message: str, ctx: t.Optional["Context"] = None + ) -> None: + super().__init__(message, ctx) + self.option_name = option_name
+ + +
[docs]class BadArgumentUsage(UsageError): + """Raised if an argument is generally supplied but the use of the argument + was incorrect. This is for instance raised if the number of values + for an argument is not correct. + + .. versionadded:: 6.0 + """
+ + +
[docs]class FileError(ClickException): + """Raised if a file cannot be opened.""" + + def __init__(self, filename: str, hint: t.Optional[str] = None) -> None: + if hint is None: + hint = _("unknown error") + + super().__init__(hint) + self.ui_filename: str = format_filename(filename) + self.filename = filename + +
[docs] def format_message(self) -> str: + return _("Could not open file {filename!r}: {message}").format( + filename=self.ui_filename, message=self.message + )
+ + +
[docs]class Abort(RuntimeError): + """An internal signalling exception that signals Click to abort."""
+ + +class Exit(RuntimeError): + """An exception that indicates that the application should exit with some + status code. + + :param code: the status code to exit with. + """ + + __slots__ = ("exit_code",) + + def __init__(self, code: int = 0) -> None: + self.exit_code: int = code +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click/formatting.html b/_modules/click/formatting.html new file mode 100644 index 000000000..580b2a4a2 --- /dev/null +++ b/_modules/click/formatting.html @@ -0,0 +1,630 @@ + + + + + + + + click.formatting - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.formatting

+import typing as t
+from contextlib import contextmanager
+from gettext import gettext as _
+
+from ._compat import term_len
+from .parser import split_opt
+
+# Can force a width.  This is used by the test system
+FORCED_WIDTH: t.Optional[int] = None
+
+
+def measure_table(rows: t.Iterable[t.Tuple[str, str]]) -> t.Tuple[int, ...]:
+    widths: t.Dict[int, int] = {}
+
+    for row in rows:
+        for idx, col in enumerate(row):
+            widths[idx] = max(widths.get(idx, 0), term_len(col))
+
+    return tuple(y for x, y in sorted(widths.items()))
+
+
+def iter_rows(
+    rows: t.Iterable[t.Tuple[str, str]], col_count: int
+) -> t.Iterator[t.Tuple[str, ...]]:
+    for row in rows:
+        yield row + ("",) * (col_count - len(row))
+
+
+
[docs]def wrap_text( + text: str, + width: int = 78, + initial_indent: str = "", + subsequent_indent: str = "", + preserve_paragraphs: bool = False, +) -> str: + """A helper function that intelligently wraps text. By default, it + assumes that it operates on a single paragraph of text but if the + `preserve_paragraphs` parameter is provided it will intelligently + handle paragraphs (defined by two empty lines). + + If paragraphs are handled, a paragraph can be prefixed with an empty + line containing the ``\\b`` character (``\\x08``) to indicate that + no rewrapping should happen in that block. + + :param text: the text that should be rewrapped. + :param width: the maximum width for the text. + :param initial_indent: the initial indent that should be placed on the + first line as a string. + :param subsequent_indent: the indent string that should be placed on + each consecutive line. + :param preserve_paragraphs: if this flag is set then the wrapping will + intelligently handle paragraphs. + """ + from ._textwrap import TextWrapper + + text = text.expandtabs() + wrapper = TextWrapper( + width, + initial_indent=initial_indent, + subsequent_indent=subsequent_indent, + replace_whitespace=False, + ) + if not preserve_paragraphs: + return wrapper.fill(text) + + p: t.List[t.Tuple[int, bool, str]] = [] + buf: t.List[str] = [] + indent = None + + def _flush_par() -> None: + if not buf: + return + if buf[0].strip() == "\b": + p.append((indent or 0, True, "\n".join(buf[1:]))) + else: + p.append((indent or 0, False, " ".join(buf))) + del buf[:] + + for line in text.splitlines(): + if not line: + _flush_par() + indent = None + else: + if indent is None: + orig_len = term_len(line) + line = line.lstrip() + indent = orig_len - term_len(line) + buf.append(line) + _flush_par() + + rv = [] + for indent, raw, text in p: + with wrapper.extra_indent(" " * indent): + if raw: + rv.append(wrapper.indent_only(text)) + else: + rv.append(wrapper.fill(text)) + + return "\n\n".join(rv)
+ + +class HelpFormatter: + """This class helps with formatting text-based help pages. It's + usually just needed for very special internal cases, but it's also + exposed so that developers can write their own fancy outputs. + + At present, it always writes into memory. + + :param indent_increment: the additional increment for each level. + :param width: the width for the text. This defaults to the terminal + width clamped to a maximum of 78. + """ + + def __init__( + self, + indent_increment: int = 2, + width: t.Optional[int] = None, + max_width: t.Optional[int] = None, + ) -> None: + import shutil + + self.indent_increment = indent_increment + if max_width is None: + max_width = 80 + if width is None: + width = FORCED_WIDTH + if width is None: + width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) + self.width = width + self.current_indent = 0 + self.buffer: t.List[str] = [] + + def write(self, string: str) -> None: + """Writes a unicode string into the internal buffer.""" + self.buffer.append(string) + + def indent(self) -> None: + """Increases the indentation.""" + self.current_indent += self.indent_increment + + def dedent(self) -> None: + """Decreases the indentation.""" + self.current_indent -= self.indent_increment + + def write_usage( + self, prog: str, args: str = "", prefix: t.Optional[str] = None + ) -> None: + """Writes a usage line into the buffer. + + :param prog: the program name. + :param args: whitespace separated list of arguments. + :param prefix: The prefix for the first line. Defaults to + ``"Usage: "``. + """ + if prefix is None: + prefix = f"{_('Usage:')} " + + usage_prefix = f"{prefix:>{self.current_indent}}{prog} " + text_width = self.width - self.current_indent + + if text_width >= (term_len(usage_prefix) + 20): + # The arguments will fit to the right of the prefix. + indent = " " * term_len(usage_prefix) + self.write( + wrap_text( + args, + text_width, + initial_indent=usage_prefix, + subsequent_indent=indent, + ) + ) + else: + # The prefix is too long, put the arguments on the next line. + self.write(usage_prefix) + self.write("\n") + indent = " " * (max(self.current_indent, term_len(prefix)) + 4) + self.write( + wrap_text( + args, text_width, initial_indent=indent, subsequent_indent=indent + ) + ) + + self.write("\n") + + def write_heading(self, heading: str) -> None: + """Writes a heading into the buffer.""" + self.write(f"{'':>{self.current_indent}}{heading}:\n") + + def write_paragraph(self) -> None: + """Writes a paragraph into the buffer.""" + if self.buffer: + self.write("\n") + + def write_text(self, text: str) -> None: + """Writes re-indented text into the buffer. This rewraps and + preserves paragraphs. + """ + indent = " " * self.current_indent + self.write( + wrap_text( + text, + self.width, + initial_indent=indent, + subsequent_indent=indent, + preserve_paragraphs=True, + ) + ) + self.write("\n") + + def write_dl( + self, + rows: t.Sequence[t.Tuple[str, str]], + col_max: int = 30, + col_spacing: int = 2, + ) -> None: + """Writes a definition list into the buffer. This is how options + and commands are usually formatted. + + :param rows: a list of two item tuples for the terms and values. + :param col_max: the maximum width of the first column. + :param col_spacing: the number of spaces between the first and + second column. + """ + rows = list(rows) + widths = measure_table(rows) + if len(widths) != 2: + raise TypeError("Expected two columns for definition list") + + first_col = min(widths[0], col_max) + col_spacing + + for first, second in iter_rows(rows, len(widths)): + self.write(f"{'':>{self.current_indent}}{first}") + if not second: + self.write("\n") + continue + if term_len(first) <= first_col - col_spacing: + self.write(" " * (first_col - term_len(first))) + else: + self.write("\n") + self.write(" " * (first_col + self.current_indent)) + + text_width = max(self.width - first_col - 2, 10) + wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True) + lines = wrapped_text.splitlines() + + if lines: + self.write(f"{lines[0]}\n") + + for line in lines[1:]: + self.write(f"{'':>{first_col + self.current_indent}}{line}\n") + else: + self.write("\n") + + @contextmanager + def section(self, name: str) -> t.Iterator[None]: + """Helpful context manager that writes a paragraph, a heading, + and the indents. + + :param name: the section name that is written as heading. + """ + self.write_paragraph() + self.write_heading(name) + self.indent() + try: + yield + finally: + self.dedent() + + @contextmanager + def indentation(self) -> t.Iterator[None]: + """A context manager that increases the indentation.""" + self.indent() + try: + yield + finally: + self.dedent() + + def getvalue(self) -> str: + """Returns the buffer contents.""" + return "".join(self.buffer) + + +def join_options(options: t.Sequence[str]) -> t.Tuple[str, bool]: + """Given a list of option strings this joins them in the most appropriate + way and returns them in the form ``(formatted_string, + any_prefix_is_slash)`` where the second item in the tuple is a flag that + indicates if any of the option prefixes was a slash. + """ + rv = [] + any_prefix_is_slash = False + + for opt in options: + prefix = split_opt(opt)[0] + + if prefix == "/": + any_prefix_is_slash = True + + rv.append((len(prefix), opt)) + + rv.sort(key=lambda x: x[0]) + return ", ".join(x[1] for x in rv), any_prefix_is_slash +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click/parser.html b/_modules/click/parser.html new file mode 100644 index 000000000..3c78da300 --- /dev/null +++ b/_modules/click/parser.html @@ -0,0 +1,858 @@ + + + + + + + + click.parser - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.parser

+"""
+This module started out as largely a copy paste from the stdlib's
+optparse module with the features removed that we do not need from
+optparse because we implement them in Click on a higher level (for
+instance type handling, help formatting and a lot more).
+
+The plan is to remove more and more from here over time.
+
+The reason this is a different module and not optparse from the stdlib
+is that there are differences in 2.x and 3.x about the error messages
+generated and optparse in the stdlib uses gettext for no good reason
+and might cause us issues.
+
+Click uses parts of optparse written by Gregory P. Ward and maintained
+by the Python Software Foundation. This is limited to code in parser.py.
+
+Copyright 2001-2006 Gregory P. Ward. All rights reserved.
+Copyright 2002-2006 Python Software Foundation. All rights reserved.
+"""
+# This code uses parts of optparse written by Gregory P. Ward and
+# maintained by the Python Software Foundation.
+# Copyright 2001-2006 Gregory P. Ward
+# Copyright 2002-2006 Python Software Foundation
+import typing as t
+from collections import deque
+from gettext import gettext as _
+from gettext import ngettext
+
+from .exceptions import BadArgumentUsage
+from .exceptions import BadOptionUsage
+from .exceptions import NoSuchOption
+from .exceptions import UsageError
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+    from .core import Argument as CoreArgument
+    from .core import Context
+    from .core import Option as CoreOption
+    from .core import Parameter as CoreParameter
+
+V = t.TypeVar("V")
+
+# Sentinel value that indicates an option was passed as a flag without a
+# value but is not a flag option. Option.consume_value uses this to
+# prompt or use the flag_value.
+_flag_needs_value = object()
+
+
+def _unpack_args(
+    args: t.Sequence[str], nargs_spec: t.Sequence[int]
+) -> t.Tuple[t.Sequence[t.Union[str, t.Sequence[t.Optional[str]], None]], t.List[str]]:
+    """Given an iterable of arguments and an iterable of nargs specifications,
+    it returns a tuple with all the unpacked arguments at the first index
+    and all remaining arguments as the second.
+
+    The nargs specification is the number of arguments that should be consumed
+    or `-1` to indicate that this position should eat up all the remainders.
+
+    Missing items are filled with `None`.
+    """
+    args = deque(args)
+    nargs_spec = deque(nargs_spec)
+    rv: t.List[t.Union[str, t.Tuple[t.Optional[str], ...], None]] = []
+    spos: t.Optional[int] = None
+
+    def _fetch(c: "te.Deque[V]") -> t.Optional[V]:
+        try:
+            if spos is None:
+                return c.popleft()
+            else:
+                return c.pop()
+        except IndexError:
+            return None
+
+    while nargs_spec:
+        nargs = _fetch(nargs_spec)
+
+        if nargs is None:
+            continue
+
+        if nargs == 1:
+            rv.append(_fetch(args))
+        elif nargs > 1:
+            x = [_fetch(args) for _ in range(nargs)]
+
+            # If we're reversed, we're pulling in the arguments in reverse,
+            # so we need to turn them around.
+            if spos is not None:
+                x.reverse()
+
+            rv.append(tuple(x))
+        elif nargs < 0:
+            if spos is not None:
+                raise TypeError("Cannot have two nargs < 0")
+
+            spos = len(rv)
+            rv.append(None)
+
+    # spos is the position of the wildcard (star).  If it's not `None`,
+    # we fill it with the remainder.
+    if spos is not None:
+        rv[spos] = tuple(args)
+        args = []
+        rv[spos + 1 :] = reversed(rv[spos + 1 :])
+
+    return tuple(rv), list(args)
+
+
+def split_opt(opt: str) -> t.Tuple[str, str]:
+    first = opt[:1]
+    if first.isalnum():
+        return "", opt
+    if opt[1:2] == first:
+        return opt[:2], opt[2:]
+    return first, opt[1:]
+
+
+def normalize_opt(opt: str, ctx: t.Optional["Context"]) -> str:
+    if ctx is None or ctx.token_normalize_func is None:
+        return opt
+    prefix, opt = split_opt(opt)
+    return f"{prefix}{ctx.token_normalize_func(opt)}"
+
+
+def split_arg_string(string: str) -> t.List[str]:
+    """Split an argument string as with :func:`shlex.split`, but don't
+    fail if the string is incomplete. Ignores a missing closing quote or
+    incomplete escape sequence and uses the partial token as-is.
+
+    .. code-block:: python
+
+        split_arg_string("example 'my file")
+        ["example", "my file"]
+
+        split_arg_string("example my\\")
+        ["example", "my"]
+
+    :param string: String to split.
+    """
+    import shlex
+
+    lex = shlex.shlex(string, posix=True)
+    lex.whitespace_split = True
+    lex.commenters = ""
+    out = []
+
+    try:
+        for token in lex:
+            out.append(token)
+    except ValueError:
+        # Raised when end-of-string is reached in an invalid state. Use
+        # the partial token as-is. The quote or escape character is in
+        # lex.state, not lex.token.
+        out.append(lex.token)
+
+    return out
+
+
+class Option:
+    def __init__(
+        self,
+        obj: "CoreOption",
+        opts: t.Sequence[str],
+        dest: t.Optional[str],
+        action: t.Optional[str] = None,
+        nargs: int = 1,
+        const: t.Optional[t.Any] = None,
+    ):
+        self._short_opts = []
+        self._long_opts = []
+        self.prefixes: t.Set[str] = set()
+
+        for opt in opts:
+            prefix, value = split_opt(opt)
+            if not prefix:
+                raise ValueError(f"Invalid start character for option ({opt})")
+            self.prefixes.add(prefix[0])
+            if len(prefix) == 1 and len(value) == 1:
+                self._short_opts.append(opt)
+            else:
+                self._long_opts.append(opt)
+                self.prefixes.add(prefix)
+
+        if action is None:
+            action = "store"
+
+        self.dest = dest
+        self.action = action
+        self.nargs = nargs
+        self.const = const
+        self.obj = obj
+
+    @property
+    def takes_value(self) -> bool:
+        return self.action in ("store", "append")
+
+    def process(self, value: t.Any, state: "ParsingState") -> None:
+        if self.action == "store":
+            state.opts[self.dest] = value  # type: ignore
+        elif self.action == "store_const":
+            state.opts[self.dest] = self.const  # type: ignore
+        elif self.action == "append":
+            state.opts.setdefault(self.dest, []).append(value)  # type: ignore
+        elif self.action == "append_const":
+            state.opts.setdefault(self.dest, []).append(self.const)  # type: ignore
+        elif self.action == "count":
+            state.opts[self.dest] = state.opts.get(self.dest, 0) + 1  # type: ignore
+        else:
+            raise ValueError(f"unknown action '{self.action}'")
+        state.order.append(self.obj)
+
+
+class Argument:
+    def __init__(self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1):
+        self.dest = dest
+        self.nargs = nargs
+        self.obj = obj
+
+    def process(
+        self,
+        value: t.Union[t.Optional[str], t.Sequence[t.Optional[str]]],
+        state: "ParsingState",
+    ) -> None:
+        if self.nargs > 1:
+            assert value is not None
+            holes = sum(1 for x in value if x is None)
+            if holes == len(value):
+                value = None
+            elif holes != 0:
+                raise BadArgumentUsage(
+                    _("Argument {name!r} takes {nargs} values.").format(
+                        name=self.dest, nargs=self.nargs
+                    )
+                )
+
+        if self.nargs == -1 and self.obj.envvar is not None and value == ():
+            # Replace empty tuple with None so that a value from the
+            # environment may be tried.
+            value = None
+
+        state.opts[self.dest] = value  # type: ignore
+        state.order.append(self.obj)
+
+
+class ParsingState:
+    def __init__(self, rargs: t.List[str]) -> None:
+        self.opts: t.Dict[str, t.Any] = {}
+        self.largs: t.List[str] = []
+        self.rargs = rargs
+        self.order: t.List["CoreParameter"] = []
+
+
+
[docs]class OptionParser: + """The option parser is an internal class that is ultimately used to + parse options and arguments. It's modelled after optparse and brings + a similar but vastly simplified API. It should generally not be used + directly as the high level Click classes wrap it for you. + + It's not nearly as extensible as optparse or argparse as it does not + implement features that are implemented on a higher level (such as + types or defaults). + + :param ctx: optionally the :class:`~click.Context` where this parser + should go with. + """ + + def __init__(self, ctx: t.Optional["Context"] = None) -> None: + #: The :class:`~click.Context` for this parser. This might be + #: `None` for some advanced use cases. + self.ctx = ctx + #: This controls how the parser deals with interspersed arguments. + #: If this is set to `False`, the parser will stop on the first + #: non-option. Click uses this to implement nested subcommands + #: safely. + self.allow_interspersed_args: bool = True + #: This tells the parser how to deal with unknown options. By + #: default it will error out (which is sensible), but there is a + #: second mode where it will ignore it and continue processing + #: after shifting all the unknown options into the resulting args. + self.ignore_unknown_options: bool = False + + if ctx is not None: + self.allow_interspersed_args = ctx.allow_interspersed_args + self.ignore_unknown_options = ctx.ignore_unknown_options + + self._short_opt: t.Dict[str, Option] = {} + self._long_opt: t.Dict[str, Option] = {} + self._opt_prefixes = {"-", "--"} + self._args: t.List[Argument] = [] + +
[docs] def add_option( + self, + obj: "CoreOption", + opts: t.Sequence[str], + dest: t.Optional[str], + action: t.Optional[str] = None, + nargs: int = 1, + const: t.Optional[t.Any] = None, + ) -> None: + """Adds a new option named `dest` to the parser. The destination + is not inferred (unlike with optparse) and needs to be explicitly + provided. Action can be any of ``store``, ``store_const``, + ``append``, ``append_const`` or ``count``. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + opts = [normalize_opt(opt, self.ctx) for opt in opts] + option = Option(obj, opts, dest, action=action, nargs=nargs, const=const) + self._opt_prefixes.update(option.prefixes) + for opt in option._short_opts: + self._short_opt[opt] = option + for opt in option._long_opts: + self._long_opt[opt] = option
+ +
[docs] def add_argument( + self, obj: "CoreArgument", dest: t.Optional[str], nargs: int = 1 + ) -> None: + """Adds a positional argument named `dest` to the parser. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + self._args.append(Argument(obj, dest=dest, nargs=nargs))
+ +
[docs] def parse_args( + self, args: t.List[str] + ) -> t.Tuple[t.Dict[str, t.Any], t.List[str], t.List["CoreParameter"]]: + """Parses positional arguments and returns ``(values, args, order)`` + for the parsed options and arguments as well as the leftover + arguments if there are any. The order is a list of objects as they + appear on the command line. If arguments appear multiple times they + will be memorized multiple times as well. + """ + state = ParsingState(args) + try: + self._process_args_for_options(state) + self._process_args_for_args(state) + except UsageError: + if self.ctx is None or not self.ctx.resilient_parsing: + raise + return state.opts, state.largs, state.order
+ + def _process_args_for_args(self, state: ParsingState) -> None: + pargs, args = _unpack_args( + state.largs + state.rargs, [x.nargs for x in self._args] + ) + + for idx, arg in enumerate(self._args): + arg.process(pargs[idx], state) + + state.largs = args + state.rargs = [] + + def _process_args_for_options(self, state: ParsingState) -> None: + while state.rargs: + arg = state.rargs.pop(0) + arglen = len(arg) + # Double dashes always handled explicitly regardless of what + # prefixes are valid. + if arg == "--": + return + elif arg[:1] in self._opt_prefixes and arglen > 1: + self._process_opts(arg, state) + elif self.allow_interspersed_args: + state.largs.append(arg) + else: + state.rargs.insert(0, arg) + return + + # Say this is the original argument list: + # [arg0, arg1, ..., arg(i-1), arg(i), arg(i+1), ..., arg(N-1)] + # ^ + # (we are about to process arg(i)). + # + # Then rargs is [arg(i), ..., arg(N-1)] and largs is a *subset* of + # [arg0, ..., arg(i-1)] (any options and their arguments will have + # been removed from largs). + # + # The while loop will usually consume 1 or more arguments per pass. + # If it consumes 1 (eg. arg is an option that takes no arguments), + # then after _process_arg() is done the situation is: + # + # largs = subset of [arg0, ..., arg(i)] + # rargs = [arg(i+1), ..., arg(N-1)] + # + # If allow_interspersed_args is false, largs will always be + # *empty* -- still a subset of [arg0, ..., arg(i-1)], but + # not a very interesting subset! + + def _match_long_opt( + self, opt: str, explicit_value: t.Optional[str], state: ParsingState + ) -> None: + if opt not in self._long_opt: + from difflib import get_close_matches + + possibilities = get_close_matches(opt, self._long_opt) + raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx) + + option = self._long_opt[opt] + if option.takes_value: + # At this point it's safe to modify rargs by injecting the + # explicit value, because no exception is raised in this + # branch. This means that the inserted value will be fully + # consumed. + if explicit_value is not None: + state.rargs.insert(0, explicit_value) + + value = self._get_value_from_state(opt, option, state) + + elif explicit_value is not None: + raise BadOptionUsage( + opt, _("Option {name!r} does not take a value.").format(name=opt) + ) + + else: + value = None + + option.process(value, state) + + def _match_short_opt(self, arg: str, state: ParsingState) -> None: + stop = False + i = 1 + prefix = arg[0] + unknown_options = [] + + for ch in arg[1:]: + opt = normalize_opt(f"{prefix}{ch}", self.ctx) + option = self._short_opt.get(opt) + i += 1 + + if not option: + if self.ignore_unknown_options: + unknown_options.append(ch) + continue + raise NoSuchOption(opt, ctx=self.ctx) + if option.takes_value: + # Any characters left in arg? Pretend they're the + # next arg, and stop consuming characters of arg. + if i < len(arg): + state.rargs.insert(0, arg[i:]) + stop = True + + value = self._get_value_from_state(opt, option, state) + + else: + value = None + + option.process(value, state) + + if stop: + break + + # If we got any unknown options we recombine the string of the + # remaining options and re-attach the prefix, then report that + # to the state as new larg. This way there is basic combinatorics + # that can be achieved while still ignoring unknown arguments. + if self.ignore_unknown_options and unknown_options: + state.largs.append(f"{prefix}{''.join(unknown_options)}") + + def _get_value_from_state( + self, option_name: str, option: Option, state: ParsingState + ) -> t.Any: + nargs = option.nargs + + if len(state.rargs) < nargs: + if option.obj._flag_needs_value: + # Option allows omitting the value. + value = _flag_needs_value + else: + raise BadOptionUsage( + option_name, + ngettext( + "Option {name!r} requires an argument.", + "Option {name!r} requires {nargs} arguments.", + nargs, + ).format(name=option_name, nargs=nargs), + ) + elif nargs == 1: + next_rarg = state.rargs[0] + + if ( + option.obj._flag_needs_value + and isinstance(next_rarg, str) + and next_rarg[:1] in self._opt_prefixes + and len(next_rarg) > 1 + ): + # The next arg looks like the start of an option, don't + # use it as the value if omitting the value is allowed. + value = _flag_needs_value + else: + value = state.rargs.pop(0) + else: + value = tuple(state.rargs[:nargs]) + del state.rargs[:nargs] + + return value + + def _process_opts(self, arg: str, state: ParsingState) -> None: + explicit_value = None + # Long option handling happens in two parts. The first part is + # supporting explicitly attached values. In any case, we will try + # to long match the option first. + if "=" in arg: + long_opt, explicit_value = arg.split("=", 1) + else: + long_opt = arg + norm_long_opt = normalize_opt(long_opt, self.ctx) + + # At this point we will match the (assumed) long option through + # the long option matching code. Note that this allows options + # like "-foo" to be matched as long options. + try: + self._match_long_opt(norm_long_opt, explicit_value, state) + except NoSuchOption: + # At this point the long option matching failed, and we need + # to try with short options. However there is a special rule + # which says, that if we have a two character options prefix + # (applies to "--foo" for instance), we do not dispatch to the + # short option code and will instead raise the no option + # error. + if arg[:2] not in self._opt_prefixes: + self._match_short_opt(arg, state) + return + + if not self.ignore_unknown_options: + raise + + state.largs.append(arg)
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click/termui.html b/_modules/click/termui.html new file mode 100644 index 000000000..2a8ce4ca6 --- /dev/null +++ b/_modules/click/termui.html @@ -0,0 +1,1113 @@ + + + + + + + + click.termui - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.termui

+import inspect
+import io
+import itertools
+import sys
+import typing as t
+from gettext import gettext as _
+
+from ._compat import isatty
+from ._compat import strip_ansi
+from .exceptions import Abort
+from .exceptions import UsageError
+from .globals import resolve_color_default
+from .types import Choice
+from .types import convert_type
+from .types import ParamType
+from .utils import echo
+from .utils import LazyFile
+
+if t.TYPE_CHECKING:
+    from ._termui_impl import ProgressBar
+
+V = t.TypeVar("V")
+
+# The prompt functions to use.  The doc tools currently override these
+# functions to customize how they work.
+visible_prompt_func: t.Callable[[str], str] = input
+
+_ansi_colors = {
+    "black": 30,
+    "red": 31,
+    "green": 32,
+    "yellow": 33,
+    "blue": 34,
+    "magenta": 35,
+    "cyan": 36,
+    "white": 37,
+    "reset": 39,
+    "bright_black": 90,
+    "bright_red": 91,
+    "bright_green": 92,
+    "bright_yellow": 93,
+    "bright_blue": 94,
+    "bright_magenta": 95,
+    "bright_cyan": 96,
+    "bright_white": 97,
+}
+_ansi_reset_all = "\033[0m"
+
+
+def hidden_prompt_func(prompt: str) -> str:
+    import getpass
+
+    return getpass.getpass(prompt)
+
+
+def _build_prompt(
+    text: str,
+    suffix: str,
+    show_default: bool = False,
+    default: t.Optional[t.Any] = None,
+    show_choices: bool = True,
+    type: t.Optional[ParamType] = None,
+) -> str:
+    prompt = text
+    if type is not None and show_choices and isinstance(type, Choice):
+        prompt += f" ({', '.join(map(str, type.choices))})"
+    if default is not None and show_default:
+        prompt = f"{prompt} [{_format_default(default)}]"
+    return f"{prompt}{suffix}"
+
+
+def _format_default(default: t.Any) -> t.Any:
+    if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"):
+        return default.name
+
+    return default
+
+
+
[docs]def prompt( + text: str, + default: t.Optional[t.Any] = None, + hide_input: bool = False, + confirmation_prompt: t.Union[bool, str] = False, + type: t.Optional[t.Union[ParamType, t.Any]] = None, + value_proc: t.Optional[t.Callable[[str], t.Any]] = None, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, + show_choices: bool = True, +) -> t.Any: + """Prompts a user for input. This is a convenience function that can + be used to prompt a user for input later. + + If the user aborts the input by sending an interrupt signal, this + function will catch it and raise a :exc:`Abort` exception. + + :param text: the text to show for the prompt. + :param default: the default value to use if no input happens. If this + is not given it will prompt until it's aborted. + :param hide_input: if this is set to true then the input value will + be hidden. + :param confirmation_prompt: Prompt a second time to confirm the + value. Can be set to a string instead of ``True`` to customize + the message. + :param type: the type to use to check the value against. + :param value_proc: if this parameter is provided it's a function that + is invoked instead of the type conversion to + convert a value. + :param prompt_suffix: a suffix that should be added to the prompt. + :param show_default: shows or hides the default value in the prompt. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. + :param show_choices: Show or hide choices if the passed type is a Choice. + For example if type is a Choice of either day or week, + show_choices is true and text is "Group by" then the + prompt will be "Group by (day, week): ". + + .. versionadded:: 8.0 + ``confirmation_prompt`` can be a custom string. + + .. versionadded:: 7.0 + Added the ``show_choices`` parameter. + + .. versionadded:: 6.0 + Added unicode support for cmd.exe on Windows. + + .. versionadded:: 4.0 + Added the `err` parameter. + + """ + + def prompt_func(text: str) -> str: + f = hidden_prompt_func if hide_input else visible_prompt_func + try: + # Write the prompt separately so that we get nice + # coloring through colorama on Windows + echo(text.rstrip(" "), nl=False, err=err) + # Echo a space to stdout to work around an issue where + # readline causes backspace to clear the whole line. + return f(" ") + except (KeyboardInterrupt, EOFError): + # getpass doesn't print a newline if the user aborts input with ^C. + # Allegedly this behavior is inherited from getpass(3). + # A doc bug has been filed at https://bugs.python.org/issue24711 + if hide_input: + echo(None, err=err) + raise Abort() from None + + if value_proc is None: + value_proc = convert_type(type, default) + + prompt = _build_prompt( + text, prompt_suffix, show_default, default, show_choices, type + ) + + if confirmation_prompt: + if confirmation_prompt is True: + confirmation_prompt = _("Repeat for confirmation") + + confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) + + while True: + while True: + value = prompt_func(prompt) + if value: + break + elif default is not None: + value = default + break + try: + result = value_proc(value) + except UsageError as e: + if hide_input: + echo(_("Error: The value you entered was invalid."), err=err) + else: + echo(_("Error: {e.message}").format(e=e), err=err) # noqa: B306 + continue + if not confirmation_prompt: + return result + while True: + value2 = prompt_func(confirmation_prompt) + is_empty = not value and not value2 + if value2 or is_empty: + break + if value == value2: + return result + echo(_("Error: The two entered values do not match."), err=err)
+ + +
[docs]def confirm( + text: str, + default: t.Optional[bool] = False, + abort: bool = False, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, +) -> bool: + """Prompts for confirmation (yes/no question). + + If the user aborts the input by sending a interrupt signal this + function will catch it and raise a :exc:`Abort` exception. + + :param text: the question to ask. + :param default: The default value to use when no input is given. If + ``None``, repeat until input is given. + :param abort: if this is set to `True` a negative answer aborts the + exception by raising :exc:`Abort`. + :param prompt_suffix: a suffix that should be added to the prompt. + :param show_default: shows or hides the default value in the prompt. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. + + .. versionchanged:: 8.0 + Repeat until input is given if ``default`` is ``None``. + + .. versionadded:: 4.0 + Added the ``err`` parameter. + """ + prompt = _build_prompt( + text, + prompt_suffix, + show_default, + "y/n" if default is None else ("Y/n" if default else "y/N"), + ) + + while True: + try: + # Write the prompt separately so that we get nice + # coloring through colorama on Windows + echo(prompt.rstrip(" "), nl=False, err=err) + # Echo a space to stdout to work around an issue where + # readline causes backspace to clear the whole line. + value = visible_prompt_func(" ").lower().strip() + except (KeyboardInterrupt, EOFError): + raise Abort() from None + if value in ("y", "yes"): + rv = True + elif value in ("n", "no"): + rv = False + elif default is not None and value == "": + rv = default + else: + echo(_("Error: invalid input"), err=err) + continue + break + if abort and not rv: + raise Abort() + return rv
+ + +
[docs]def echo_via_pager( + text_or_generator: t.Union[t.Iterable[str], t.Callable[[], t.Iterable[str]], str], + color: t.Optional[bool] = None, +) -> None: + """This function takes a text and shows it via an environment specific + pager on stdout. + + .. versionchanged:: 3.0 + Added the `color` flag. + + :param text_or_generator: the text to page, or alternatively, a + generator emitting the text to page. + :param color: controls if the pager supports ANSI colors or not. The + default is autodetection. + """ + color = resolve_color_default(color) + + if inspect.isgeneratorfunction(text_or_generator): + i = t.cast(t.Callable[[], t.Iterable[str]], text_or_generator)() + elif isinstance(text_or_generator, str): + i = [text_or_generator] + else: + i = iter(t.cast(t.Iterable[str], text_or_generator)) + + # convert every element of i to a text type if necessary + text_generator = (el if isinstance(el, str) else str(el) for el in i) + + from ._termui_impl import pager + + return pager(itertools.chain(text_generator, "\n"), color)
+ + +
[docs]def progressbar( + iterable: t.Optional[t.Iterable[V]] = None, + length: t.Optional[int] = None, + label: t.Optional[str] = None, + show_eta: bool = True, + show_percent: t.Optional[bool] = None, + show_pos: bool = False, + item_show_func: t.Optional[t.Callable[[t.Optional[V]], t.Optional[str]]] = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.Optional[t.TextIO] = None, + color: t.Optional[bool] = None, + update_min_steps: int = 1, +) -> "ProgressBar[V]": + """This function creates an iterable context manager that can be used + to iterate over something while showing a progress bar. It will + either iterate over the `iterable` or `length` items (that are counted + up). While iteration happens, this function will print a rendered + progress bar to the given `file` (defaults to stdout) and will attempt + to calculate remaining time and more. By default, this progress bar + will not be rendered if the file is not a terminal. + + The context manager creates the progress bar. When the context + manager is entered the progress bar is already created. With every + iteration over the progress bar, the iterable passed to the bar is + advanced and the bar is updated. When the context manager exits, + a newline is printed and the progress bar is finalized on screen. + + Note: The progress bar is currently designed for use cases where the + total progress can be expected to take at least several seconds. + Because of this, the ProgressBar class object won't display + progress that is considered too fast, and progress where the time + between steps is less than a second. + + No printing must happen or the progress bar will be unintentionally + destroyed. + + Example usage:: + + with progressbar(items) as bar: + for item in bar: + do_something_with(item) + + Alternatively, if no iterable is specified, one can manually update the + progress bar through the `update()` method instead of directly + iterating over the progress bar. The update method accepts the number + of steps to increment the bar with:: + + with progressbar(length=chunks.total_bytes) as bar: + for chunk in chunks: + process_chunk(chunk) + bar.update(chunks.bytes) + + The ``update()`` method also takes an optional value specifying the + ``current_item`` at the new position. This is useful when used + together with ``item_show_func`` to customize the output for each + manual step:: + + with click.progressbar( + length=total_size, + label='Unzipping archive', + item_show_func=lambda a: a.filename + ) as bar: + for archive in zip_file: + archive.extract() + bar.update(archive.size, archive) + + :param iterable: an iterable to iterate over. If not provided the length + is required. + :param length: the number of items to iterate over. By default the + progressbar will attempt to ask the iterator about its + length, which might or might not work. If an iterable is + also provided this parameter can be used to override the + length. If an iterable is not provided the progress bar + will iterate over a range of that length. + :param label: the label to show next to the progress bar. + :param show_eta: enables or disables the estimated time display. This is + automatically disabled if the length cannot be + determined. + :param show_percent: enables or disables the percentage display. The + default is `True` if the iterable has a length or + `False` if not. + :param show_pos: enables or disables the absolute position display. The + default is `False`. + :param item_show_func: A function called with the current item which + can return a string to show next to the progress bar. If the + function returns ``None`` nothing is shown. The current item can + be ``None``, such as when entering and exiting the bar. + :param fill_char: the character to use to show the filled part of the + progress bar. + :param empty_char: the character to use to show the non-filled part of + the progress bar. + :param bar_template: the format string to use as template for the bar. + The parameters in it are ``label`` for the label, + ``bar`` for the progress bar and ``info`` for the + info section. + :param info_sep: the separator between multiple info items (eta etc.) + :param width: the width of the progress bar in characters, 0 means full + terminal width + :param file: The file to write to. If this is not a terminal then + only the label is printed. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are included anywhere in the progress bar output + which is not the case by default. + :param update_min_steps: Render only when this many updates have + completed. This allows tuning for very fast iterators. + + .. versionchanged:: 8.0 + Output is shown even if execution time is less than 0.5 seconds. + + .. versionchanged:: 8.0 + ``item_show_func`` shows the current item, not the previous one. + + .. versionchanged:: 8.0 + Labels are echoed if the output is not a TTY. Reverts a change + in 7.0 that removed all output. + + .. versionadded:: 8.0 + Added the ``update_min_steps`` parameter. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. Added the ``update`` method to + the object. + + .. versionadded:: 2.0 + """ + from ._termui_impl import ProgressBar + + color = resolve_color_default(color) + return ProgressBar( + iterable=iterable, + length=length, + show_eta=show_eta, + show_percent=show_percent, + show_pos=show_pos, + item_show_func=item_show_func, + fill_char=fill_char, + empty_char=empty_char, + bar_template=bar_template, + info_sep=info_sep, + file=file, + label=label, + width=width, + color=color, + update_min_steps=update_min_steps, + )
+ + +
[docs]def clear() -> None: + """Clears the terminal screen. This will have the effect of clearing + the whole visible space of the terminal and moving the cursor to the + top left. This does not do anything if not connected to a terminal. + + .. versionadded:: 2.0 + """ + if not isatty(sys.stdout): + return + + # ANSI escape \033[2J clears the screen, \033[1;1H moves the cursor + echo("\033[2J\033[1;1H", nl=False)
+ + +def _interpret_color( + color: t.Union[int, t.Tuple[int, int, int], str], offset: int = 0 +) -> str: + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +
[docs]def style( + text: t.Any, + fg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bg: t.Optional[t.Union[int, t.Tuple[int, int, int], str]] = None, + bold: t.Optional[bool] = None, + dim: t.Optional[bool] = None, + underline: t.Optional[bool] = None, + overline: t.Optional[bool] = None, + italic: t.Optional[bool] = None, + blink: t.Optional[bool] = None, + reverse: t.Optional[bool] = None, + strikethrough: t.Optional[bool] = None, + reset: bool = True, +) -> str: + """Styles a text with ANSI styles and returns the new string. By + default the styling is self contained which means that at the end + of the string a reset code is issued. This can be prevented by + passing ``reset=False``. + + Examples:: + + click.echo(click.style('Hello World!', fg='green')) + click.echo(click.style('ATTENTION!', blink=True)) + click.echo(click.style('Some things', reverse=True, fg='cyan')) + click.echo(click.style('More colors', fg=(255, 12, 128), bg=117)) + + Supported color names: + + * ``black`` (might be a gray) + * ``red`` + * ``green`` + * ``yellow`` (might be an orange) + * ``blue`` + * ``magenta`` + * ``cyan`` + * ``white`` (might be light gray) + * ``bright_black`` + * ``bright_red`` + * ``bright_green`` + * ``bright_yellow`` + * ``bright_blue`` + * ``bright_magenta`` + * ``bright_cyan`` + * ``bright_white`` + * ``reset`` (reset the color code only) + + If the terminal supports it, color may also be specified as: + + - An integer in the interval [0, 255]. The terminal must support + 8-bit/256-color mode. + - An RGB tuple of three integers in [0, 255]. The terminal must + support 24-bit/true-color mode. + + See https://en.wikipedia.org/wiki/ANSI_color and + https://gist.github.com/XVilka/8346728 for more information. + + :param text: the string to style with ansi codes. + :param fg: if provided this will become the foreground color. + :param bg: if provided this will become the background color. + :param bold: if provided this will enable or disable bold mode. + :param dim: if provided this will enable or disable dim mode. This is + badly supported. + :param underline: if provided this will enable or disable underline. + :param overline: if provided this will enable or disable overline. + :param italic: if provided this will enable or disable italic. + :param blink: if provided this will enable or disable blinking. + :param reverse: if provided this will enable or disable inverse + rendering (foreground becomes background and the + other way round). + :param strikethrough: if provided this will enable or disable + striking through text. + :param reset: by default a reset-all code is added at the end of the + string which means that styles do not carry over. This + can be disabled to compose styles. + + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. + + .. versionchanged:: 8.0 + Added support for 256 and RGB color codes. + + .. versionchanged:: 8.0 + Added the ``strikethrough``, ``italic``, and ``overline`` + parameters. + + .. versionchanged:: 7.0 + Added support for bright colors. + + .. versionadded:: 2.0 + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except KeyError: + raise TypeError(f"Unknown color {fg!r}") from None + + if bg: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except KeyError: + raise TypeError(f"Unknown color {bg!r}") from None + + if bold is not None: + bits.append(f"\033[{1 if bold else 22}m") + if dim is not None: + bits.append(f"\033[{2 if dim else 22}m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits)
+ + +
[docs]def unstyle(text: str) -> str: + """Removes ANSI styling information from a string. Usually it's not + necessary to use this function as Click's echo function will + automatically remove styling if necessary. + + .. versionadded:: 2.0 + + :param text: the text to remove style information from. + """ + return strip_ansi(text)
+ + +
[docs]def secho( + message: t.Optional[t.Any] = None, + file: t.Optional[t.IO[t.AnyStr]] = None, + nl: bool = True, + err: bool = False, + color: t.Optional[bool] = None, + **styles: t.Any, +) -> None: + """This function combines :func:`echo` and :func:`style` into one + call. As such the following two calls are the same:: + + click.secho('Hello World!', fg='green') + click.echo(click.style('Hello World!', fg='green')) + + All keyword arguments are forwarded to the underlying functions + depending on which one they go with. + + Non-string types will be converted to :class:`str`. However, + :class:`bytes` are passed directly to :meth:`echo` without applying + style. If you want to style bytes that represent text, call + :meth:`bytes.decode` first. + + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. Bytes are + passed through without style applied. + + .. versionadded:: 2.0 + """ + if message is not None and not isinstance(message, (bytes, bytearray)): + message = style(message, **styles) + + return echo(message, file=file, nl=nl, err=err, color=color)
+ + +
[docs]def edit( + text: t.Optional[t.AnyStr] = None, + editor: t.Optional[str] = None, + env: t.Optional[t.Mapping[str, str]] = None, + require_save: bool = True, + extension: str = ".txt", + filename: t.Optional[str] = None, +) -> t.Optional[t.AnyStr]: + r"""Edits the given text in the defined editor. If an editor is given + (should be the full path to the executable but the regular operating + system search path is used for finding the executable) it overrides + the detected editor. Optionally, some environment variables can be + used. If the editor is closed without changes, `None` is returned. In + case a file is edited directly the return value is always `None` and + `require_save` and `extension` are ignored. + + If the editor cannot be opened a :exc:`UsageError` is raised. + + Note for Windows: to simplify cross-platform usage, the newlines are + automatically converted from POSIX to Windows and vice versa. As such, + the message here will have ``\n`` as newline markers. + + :param text: the text to edit. + :param editor: optionally the editor to use. Defaults to automatic + detection. + :param env: environment variables to forward to the editor. + :param require_save: if this is true, then not saving in the editor + will make the return value become `None`. + :param extension: the extension to tell the editor about. This defaults + to `.txt` but changing this might change syntax + highlighting. + :param filename: if provided it will edit this file instead of the + provided text contents. It will not use a temporary + file as an indirection in that case. + """ + from ._termui_impl import Editor + + ed = Editor(editor=editor, env=env, require_save=require_save, extension=extension) + + if filename is None: + return ed.edit(text) + + ed.edit_file(filename) + return None
+ + +
[docs]def launch(url: str, wait: bool = False, locate: bool = False) -> int: + """This function launches the given URL (or filename) in the default + viewer application for this file type. If this is an executable, it + might launch the executable in a new session. The return value is + the exit code of the launched application. Usually, ``0`` indicates + success. + + Examples:: + + click.launch('https://click.palletsprojects.com/') + click.launch('/my/downloaded/file', locate=True) + + .. versionadded:: 2.0 + + :param url: URL or filename of the thing to launch. + :param wait: Wait for the program to exit before returning. This + only works if the launched program blocks. In particular, + ``xdg-open`` on Linux does not block. + :param locate: if this is set to `True` then instead of launching the + application associated with the URL it will attempt to + launch a file manager with the file located. This + might have weird effects if the URL does not point to + the filesystem. + """ + from ._termui_impl import open_url + + return open_url(url, wait=wait, locate=locate)
+ + +# If this is provided, getchar() calls into this instead. This is used +# for unittesting purposes. +_getchar: t.Optional[t.Callable[[bool], str]] = None + + +
[docs]def getchar(echo: bool = False) -> str: + """Fetches a single character from the terminal and returns it. This + will always return a unicode character and under certain rare + circumstances this might return more than one character. The + situations which more than one character is returned is when for + whatever reason multiple characters end up in the terminal buffer or + standard input was not actually a terminal. + + Note that this will always read from the terminal, even if something + is piped into the standard input. + + Note for Windows: in rare cases when typing non-ASCII characters, this + function might wait for a second character and then return both at once. + This is because certain Unicode characters look like special-key markers. + + .. versionadded:: 2.0 + + :param echo: if set to `True`, the character read will also show up on + the terminal. The default is to not show it. + """ + global _getchar + + if _getchar is None: + from ._termui_impl import getchar as f + + _getchar = f + + return _getchar(echo)
+ + +def raw_terminal() -> t.ContextManager[int]: + from ._termui_impl import raw_terminal as f + + return f() + + +
[docs]def pause(info: t.Optional[str] = None, err: bool = False) -> None: + """This command stops execution and waits for the user to press any + key to continue. This is similar to the Windows batch "pause" + command. If the program is not run through a terminal, this command + will instead do nothing. + + .. versionadded:: 2.0 + + .. versionadded:: 4.0 + Added the `err` parameter. + + :param info: The message to print before pausing. Defaults to + ``"Press any key to continue..."``. + :param err: if set to message goes to ``stderr`` instead of + ``stdout``, the same as with echo. + """ + if not isatty(sys.stdin) or not isatty(sys.stdout): + return + + if info is None: + info = _("Press any key to continue...") + + try: + if info: + echo(info, nl=False, err=err) + try: + getchar() + except (KeyboardInterrupt, EOFError): + pass + finally: + if info: + echo(err=err)
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click/types.html b/_modules/click/types.html new file mode 100644 index 000000000..a958ddbec --- /dev/null +++ b/_modules/click/types.html @@ -0,0 +1,1418 @@ + + + + + + + + click.types - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.types

+import os
+import stat
+import sys
+import typing as t
+from datetime import datetime
+from gettext import gettext as _
+from gettext import ngettext
+
+from ._compat import _get_argv_encoding
+from ._compat import open_stream
+from .exceptions import BadParameter
+from .utils import format_filename
+from .utils import LazyFile
+from .utils import safecall
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+    from .core import Context
+    from .core import Parameter
+    from .shell_completion import CompletionItem
+
+
+
[docs]class ParamType: + """Represents the type of a parameter. Validates and converts values + from the command line or Python into the correct type. + + To implement a custom type, subclass and implement at least the + following: + + - The :attr:`name` class attribute must be set. + - Calling an instance of the type with ``None`` must return + ``None``. This is already implemented by default. + - :meth:`convert` must convert string values to the correct type. + - :meth:`convert` must accept values that are already the correct + type. + - It must be able to convert a value if the ``ctx`` and ``param`` + arguments are ``None``. This can occur when converting prompt + input. + """ + + is_composite: t.ClassVar[bool] = False + arity: t.ClassVar[int] = 1 + + #: the descriptive name of this type + name: str + + #: if a list of this type is expected and the value is pulled from a + #: string environment variable, this is what splits it up. `None` + #: means any whitespace. For all parameters the general rule is that + #: whitespace splits them up. The exception are paths and files which + #: are split by ``os.path.pathsep`` by default (":" on Unix and ";" on + #: Windows). + envvar_list_splitter: t.ClassVar[t.Optional[str]] = None + +
[docs] def to_info_dict(self) -> t.Dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionadded:: 8.0 + """ + # The class name without the "ParamType" suffix. + param_type = type(self).__name__.partition("ParamType")[0] + param_type = param_type.partition("ParameterType")[0] + + # Custom subclasses might not remember to set a name. + if hasattr(self, "name"): + name = self.name + else: + name = param_type + + return {"param_type": param_type, "name": name}
+ + def __call__( + self, + value: t.Any, + param: t.Optional["Parameter"] = None, + ctx: t.Optional["Context"] = None, + ) -> t.Any: + if value is not None: + return self.convert(value, param, ctx) + +
[docs] def get_metavar(self, param: "Parameter") -> t.Optional[str]: + """Returns the metavar default for this param if it provides one."""
+ +
[docs] def get_missing_message(self, param: "Parameter") -> t.Optional[str]: + """Optionally might return extra information about a missing + parameter. + + .. versionadded:: 2.0 + """
+ +
[docs] def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + """Convert the value to the correct type. This is not called if + the value is ``None`` (the missing value). + + This must accept string values from the command line, as well as + values that are already the correct type. It may also convert + other compatible types. + + The ``param`` and ``ctx`` arguments may be ``None`` in certain + situations, such as when converting prompt input. + + If the value cannot be converted, call :meth:`fail` with a + descriptive message. + + :param value: The value to convert. + :param param: The parameter that is using this type to convert + its value. May be ``None``. + :param ctx: The current context that arrived at this value. May + be ``None``. + """ + return value
+ +
[docs] def split_envvar_value(self, rv: str) -> t.Sequence[str]: + """Given a value from an environment variable this splits it up + into small chunks depending on the defined envvar list splitter. + + If the splitter is set to `None`, which means that whitespace splits, + then leading and trailing whitespace is ignored. Otherwise, leading + and trailing splitters usually lead to empty items being included. + """ + return (rv or "").split(self.envvar_list_splitter)
+ +
[docs] def fail( + self, + message: str, + param: t.Optional["Parameter"] = None, + ctx: t.Optional["Context"] = None, + ) -> "t.NoReturn": + """Helper method to fail with an invalid value message.""" + raise BadParameter(message, ctx=ctx, param=param)
+ +
[docs] def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Return a list of + :class:`~click.shell_completion.CompletionItem` objects for the + incomplete value. Most types do not provide completions, but + some do, and this allows custom types to provide custom + completions as well. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + return []
+ + +class CompositeParamType(ParamType): + is_composite = True + + @property + def arity(self) -> int: # type: ignore + raise NotImplementedError() + + +class FuncParamType(ParamType): + def __init__(self, func: t.Callable[[t.Any], t.Any]) -> None: + self.name: str = func.__name__ + self.func = func + + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["func"] = self.func + return info_dict + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + try: + return self.func(value) + except ValueError: + try: + value = str(value) + except UnicodeError: + value = value.decode("utf-8", "replace") + + self.fail(value, param, ctx) + + +class UnprocessedParamType(ParamType): + name = "text" + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + return value + + def __repr__(self) -> str: + return "UNPROCESSED" + + +class StringParamType(ParamType): + name = "text" + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + if isinstance(value, bytes): + enc = _get_argv_encoding() + try: + value = value.decode(enc) + except UnicodeError: + fs_enc = sys.getfilesystemencoding() + if fs_enc != enc: + try: + value = value.decode(fs_enc) + except UnicodeError: + value = value.decode("utf-8", "replace") + else: + value = value.decode("utf-8", "replace") + return value + return str(value) + + def __repr__(self) -> str: + return "STRING" + + +
[docs]class Choice(ParamType): + """The choice type allows a value to be checked against a fixed set + of supported values. All of these values have to be strings. + + You should only pass a list or tuple of choices. Other iterables + (like generators) may lead to surprising results. + + The resulting value will always be one of the originally passed choices + regardless of ``case_sensitive`` or any ``ctx.token_normalize_func`` + being specified. + + See :ref:`choice-opts` for an example. + + :param case_sensitive: Set to false to make choices case + insensitive. Defaults to true. + """ + + name = "choice" + + def __init__(self, choices: t.Sequence[str], case_sensitive: bool = True) -> None: + self.choices = choices + self.case_sensitive = case_sensitive + +
[docs] def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["choices"] = self.choices + info_dict["case_sensitive"] = self.case_sensitive + return info_dict
+ +
[docs] def get_metavar(self, param: "Parameter") -> str: + choices_str = "|".join(self.choices) + + # Use curly braces to indicate a required argument. + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + + # Use square braces to indicate an option or optional argument. + return f"[{choices_str}]"
+ +
[docs] def get_missing_message(self, param: "Parameter") -> str: + return _("Choose from:\n\t{choices}").format(choices=",\n\t".join(self.choices))
+ +
[docs] def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + # Match through normalization and case sensitivity + # first do token_normalize_func, then lowercase + # preserve original `value` to produce an accurate message in + # `self.fail` + normed_value = value + normed_choices = {choice: choice for choice in self.choices} + + if ctx is not None and ctx.token_normalize_func is not None: + normed_value = ctx.token_normalize_func(value) + normed_choices = { + ctx.token_normalize_func(normed_choice): original + for normed_choice, original in normed_choices.items() + } + + if not self.case_sensitive: + normed_value = normed_value.casefold() + normed_choices = { + normed_choice.casefold(): original + for normed_choice, original in normed_choices.items() + } + + if normed_value in normed_choices: + return normed_choices[normed_value] + + choices_str = ", ".join(map(repr, self.choices)) + self.fail( + ngettext( + "{value!r} is not {choice}.", + "{value!r} is not one of {choices}.", + len(self.choices), + ).format(value=value, choice=choices_str, choices=choices_str), + param, + ctx, + )
+ + def __repr__(self) -> str: + return f"Choice({list(self.choices)})" + +
[docs] def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Complete choices that start with the incomplete value. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + str_choices = map(str, self.choices) + + if self.case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + + return [CompletionItem(c) for c in matched]
+ + +
[docs]class DateTime(ParamType): + """The DateTime type converts date strings into `datetime` objects. + + The format strings which are checked are configurable, but default to some + common (non-timezone aware) ISO 8601 formats. + + When specifying *DateTime* formats, you should only pass a list or a tuple. + Other iterables, like generators, may lead to surprising results. + + The format strings are processed using ``datetime.strptime``, and this + consequently defines the format strings which are allowed. + + Parsing is tried using each format, in order, and the first format which + parses successfully is used. + + :param formats: A list or tuple of date format strings, in the order in + which they should be tried. Defaults to + ``'%Y-%m-%d'``, ``'%Y-%m-%dT%H:%M:%S'``, + ``'%Y-%m-%d %H:%M:%S'``. + """ + + name = "datetime" + + def __init__(self, formats: t.Optional[t.Sequence[str]] = None): + self.formats: t.Sequence[str] = formats or [ + "%Y-%m-%d", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ] + +
[docs] def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["formats"] = self.formats + return info_dict
+ +
[docs] def get_metavar(self, param: "Parameter") -> str: + return f"[{'|'.join(self.formats)}]"
+ + def _try_to_convert_date(self, value: t.Any, format: str) -> t.Optional[datetime]: + try: + return datetime.strptime(value, format) + except ValueError: + return None + +
[docs] def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + if isinstance(value, datetime): + return value + + for format in self.formats: + converted = self._try_to_convert_date(value, format) + + if converted is not None: + return converted + + formats_str = ", ".join(map(repr, self.formats)) + self.fail( + ngettext( + "{value!r} does not match the format {format}.", + "{value!r} does not match the formats {formats}.", + len(self.formats), + ).format(value=value, format=formats_str, formats=formats_str), + param, + ctx, + )
+ + def __repr__(self) -> str: + return "DateTime"
+ + +class _NumberParamTypeBase(ParamType): + _number_class: t.ClassVar[t.Type[t.Any]] + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + try: + return self._number_class(value) + except ValueError: + self.fail( + _("{value!r} is not a valid {number_type}.").format( + value=value, number_type=self.name + ), + param, + ctx, + ) + + +class _NumberRangeBase(_NumberParamTypeBase): + def __init__( + self, + min: t.Optional[float] = None, + max: t.Optional[float] = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + self.min = min + self.max = max + self.min_open = min_open + self.max_open = max_open + self.clamp = clamp + + def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update( + min=self.min, + max=self.max, + min_open=self.min_open, + max_open=self.max_open, + clamp=self.clamp, + ) + return info_dict + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + import operator + + rv = super().convert(value, param, ctx) + lt_min: bool = self.min is not None and ( + operator.le if self.min_open else operator.lt + )(rv, self.min) + gt_max: bool = self.max is not None and ( + operator.ge if self.max_open else operator.gt + )(rv, self.max) + + if self.clamp: + if lt_min: + return self._clamp(self.min, 1, self.min_open) # type: ignore + + if gt_max: + return self._clamp(self.max, -1, self.max_open) # type: ignore + + if lt_min or gt_max: + self.fail( + _("{value} is not in the range {range}.").format( + value=rv, range=self._describe_range() + ), + param, + ctx, + ) + + return rv + + def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: + """Find the valid value to clamp to bound in the given + direction. + + :param bound: The boundary value. + :param dir: 1 or -1 indicating the direction to move. + :param open: If true, the range does not include the bound. + """ + raise NotImplementedError + + def _describe_range(self) -> str: + """Describe the range for use in help text.""" + if self.min is None: + op = "<" if self.max_open else "<=" + return f"x{op}{self.max}" + + if self.max is None: + op = ">" if self.min_open else ">=" + return f"x{op}{self.min}" + + lop = "<" if self.min_open else "<=" + rop = "<" if self.max_open else "<=" + return f"{self.min}{lop}x{rop}{self.max}" + + def __repr__(self) -> str: + clamp = " clamped" if self.clamp else "" + return f"<{type(self).__name__} {self._describe_range()}{clamp}>" + + +class IntParamType(_NumberParamTypeBase): + name = "integer" + _number_class = int + + def __repr__(self) -> str: + return "INT" + + +
[docs]class IntRange(_NumberRangeBase, IntParamType): + """Restrict an :data:`click.INT` value to a range of accepted + values. See :ref:`ranges`. + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. + """ + + name = "integer range" + + def _clamp( # type: ignore + self, bound: int, dir: "te.Literal[1, -1]", open: bool + ) -> int: + if not open: + return bound + + return bound + dir
+ + +class FloatParamType(_NumberParamTypeBase): + name = "float" + _number_class = float + + def __repr__(self) -> str: + return "FLOAT" + + +
[docs]class FloatRange(_NumberRangeBase, FloatParamType): + """Restrict a :data:`click.FLOAT` value to a range of accepted + values. See :ref:`ranges`. + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. This is not supported if either + boundary is marked ``open``. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. + """ + + name = "float range" + + def __init__( + self, + min: t.Optional[float] = None, + max: t.Optional[float] = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + super().__init__( + min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp + ) + + if (min_open or max_open) and clamp: + raise TypeError("Clamping is not supported for open bounds.") + + def _clamp(self, bound: float, dir: "te.Literal[1, -1]", open: bool) -> float: + if not open: + return bound + + # Could use Python 3.9's math.nextafter here, but clamping an + # open float range doesn't seem to be particularly useful. It's + # left up to the user to write a callback to do it if needed. + raise RuntimeError("Clamping is not supported for open bounds.")
+ + +class BoolParamType(ParamType): + name = "boolean" + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + if value in {False, True}: + return bool(value) + + norm = value.strip().lower() + + if norm in {"1", "true", "t", "yes", "y", "on"}: + return True + + if norm in {"0", "false", "f", "no", "n", "off"}: + return False + + self.fail( + _("{value!r} is not a valid boolean.").format(value=value), param, ctx + ) + + def __repr__(self) -> str: + return "BOOL" + + +class UUIDParameterType(ParamType): + name = "uuid" + + def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + import uuid + + if isinstance(value, uuid.UUID): + return value + + value = value.strip() + + try: + return uuid.UUID(value) + except ValueError: + self.fail( + _("{value!r} is not a valid UUID.").format(value=value), param, ctx + ) + + def __repr__(self) -> str: + return "UUID" + + +
[docs]class File(ParamType): + """Declares a parameter to be a file for reading or writing. The file + is automatically closed once the context tears down (after the command + finished working). + + Files can be opened for reading or writing. The special value ``-`` + indicates stdin or stdout depending on the mode. + + By default, the file is opened for reading text data, but it can also be + opened in binary mode or for writing. The encoding parameter can be used + to force a specific encoding. + + The `lazy` flag controls if the file should be opened immediately or upon + first IO. The default is to be non-lazy for standard input and output + streams as well as files opened for reading, `lazy` otherwise. When opening a + file lazily for reading, it is still opened temporarily for validation, but + will not be held open until first IO. lazy is mainly useful when opening + for writing to avoid creating the file until it is needed. + + Starting with Click 2.0, files can also be opened atomically in which + case all writes go into a separate file in the same folder and upon + completion the file will be moved over to the original location. This + is useful if a file regularly read by other users is modified. + + See :ref:`file-args` for more information. + """ + + name = "filename" + envvar_list_splitter: t.ClassVar[str] = os.path.pathsep + + def __init__( + self, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + lazy: t.Optional[bool] = None, + atomic: bool = False, + ) -> None: + self.mode = mode + self.encoding = encoding + self.errors = errors + self.lazy = lazy + self.atomic = atomic + +
[docs] def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update(mode=self.mode, encoding=self.encoding) + return info_dict
+ +
[docs] def resolve_lazy_flag(self, value: "t.Union[str, os.PathLike[str]]") -> bool: + if self.lazy is not None: + return self.lazy + if os.fspath(value) == "-": + return False + elif "w" in self.mode: + return True + return False
+ +
[docs] def convert( + self, + value: t.Union[str, "os.PathLike[str]", t.IO[t.Any]], + param: t.Optional["Parameter"], + ctx: t.Optional["Context"], + ) -> t.IO[t.Any]: + if _is_file_like(value): + return value + + value = t.cast("t.Union[str, os.PathLike[str]]", value) + + try: + lazy = self.resolve_lazy_flag(value) + + if lazy: + lf = LazyFile( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + + if ctx is not None: + ctx.call_on_close(lf.close_intelligently) + + return t.cast(t.IO[t.Any], lf) + + f, should_close = open_stream( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + + # If a context is provided, we automatically close the file + # at the end of the context execution (or flush out). If a + # context does not exist, it's the caller's responsibility to + # properly close the file. This for instance happens when the + # type is used with prompts. + if ctx is not None: + if should_close: + ctx.call_on_close(safecall(f.close)) + else: + ctx.call_on_close(safecall(f.flush)) + + return f + except OSError as e: # noqa: B014 + self.fail(f"'{format_filename(value)}': {e.strerror}", param, ctx)
+ +
[docs] def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Return a special completion marker that tells the completion + system to use the shell to provide file path completions. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + return [CompletionItem(incomplete, type="file")]
+ + +def _is_file_like(value: t.Any) -> "te.TypeGuard[t.IO[t.Any]]": + return hasattr(value, "read") or hasattr(value, "write") + + +
[docs]class Path(ParamType): + """The ``Path`` type is similar to the :class:`File` type, but + returns the filename instead of an open file. Various checks can be + enabled to validate the type of file and permissions. + + :param exists: The file or directory needs to exist for the value to + be valid. If this is not set to ``True``, and the file does not + exist, then all further checks are silently skipped. + :param file_okay: Allow a file as a value. + :param dir_okay: Allow a directory as a value. + :param readable: if true, a readable check is performed. + :param writable: if true, a writable check is performed. + :param executable: if true, an executable check is performed. + :param resolve_path: Make the value absolute and resolve any + symlinks. A ``~`` is not expanded, as this is supposed to be + done by the shell only. + :param allow_dash: Allow a single dash as a value, which indicates + a standard stream (but does not open it). Use + :func:`~click.open_file` to handle opening this value. + :param path_type: Convert the incoming path value to this type. If + ``None``, keep Python's default, which is ``str``. Useful to + convert to :class:`pathlib.Path`. + + .. versionchanged:: 8.1 + Added the ``executable`` parameter. + + .. versionchanged:: 8.0 + Allow passing ``path_type=pathlib.Path``. + + .. versionchanged:: 6.0 + Added the ``allow_dash`` parameter. + """ + + envvar_list_splitter: t.ClassVar[str] = os.path.pathsep + + def __init__( + self, + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: t.Optional[t.Type[t.Any]] = None, + executable: bool = False, + ): + self.exists = exists + self.file_okay = file_okay + self.dir_okay = dir_okay + self.readable = readable + self.writable = writable + self.executable = executable + self.resolve_path = resolve_path + self.allow_dash = allow_dash + self.type = path_type + + if self.file_okay and not self.dir_okay: + self.name: str = _("file") + elif self.dir_okay and not self.file_okay: + self.name = _("directory") + else: + self.name = _("path") + +
[docs] def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict.update( + exists=self.exists, + file_okay=self.file_okay, + dir_okay=self.dir_okay, + writable=self.writable, + readable=self.readable, + allow_dash=self.allow_dash, + ) + return info_dict
+ +
[docs] def coerce_path_result( + self, value: "t.Union[str, os.PathLike[str]]" + ) -> "t.Union[str, bytes, os.PathLike[str]]": + if self.type is not None and not isinstance(value, self.type): + if self.type is str: + return os.fsdecode(value) + elif self.type is bytes: + return os.fsencode(value) + else: + return t.cast("os.PathLike[str]", self.type(value)) + + return value
+ +
[docs] def convert( + self, + value: "t.Union[str, os.PathLike[str]]", + param: t.Optional["Parameter"], + ctx: t.Optional["Context"], + ) -> "t.Union[str, bytes, os.PathLike[str]]": + rv = value + + is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") + + if not is_dash: + if self.resolve_path: + # os.path.realpath doesn't resolve symlinks on Windows + # until Python 3.8. Use pathlib for now. + import pathlib + + rv = os.fsdecode(pathlib.Path(rv).resolve()) + + try: + st = os.stat(rv) + except OSError: + if not self.exists: + return self.coerce_path_result(rv) + self.fail( + _("{name} {filename!r} does not exist.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if not self.file_okay and stat.S_ISREG(st.st_mode): + self.fail( + _("{name} {filename!r} is a file.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + if not self.dir_okay and stat.S_ISDIR(st.st_mode): + self.fail( + _("{name} '{filename}' is a directory.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if self.readable and not os.access(rv, os.R_OK): + self.fail( + _("{name} {filename!r} is not readable.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if self.writable and not os.access(rv, os.W_OK): + self.fail( + _("{name} {filename!r} is not writable.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if self.executable and not os.access(value, os.X_OK): + self.fail( + _("{name} {filename!r} is not executable.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + return self.coerce_path_result(rv)
+ +
[docs] def shell_complete( + self, ctx: "Context", param: "Parameter", incomplete: str + ) -> t.List["CompletionItem"]: + """Return a special completion marker that tells the completion + system to use the shell to provide path completions for only + directories or any paths. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + type = "dir" if self.dir_okay and not self.file_okay else "file" + return [CompletionItem(incomplete, type=type)]
+ + +
[docs]class Tuple(CompositeParamType): + """The default behavior of Click is to apply a type on a value directly. + This works well in most cases, except for when `nargs` is set to a fixed + count and different types should be used for different items. In this + case the :class:`Tuple` type can be used. This type can only be used + if `nargs` is set to a fixed number. + + For more information see :ref:`tuple-type`. + + This can be selected by using a Python tuple literal as a type. + + :param types: a list of types that should be used for the tuple items. + """ + + def __init__(self, types: t.Sequence[t.Union[t.Type[t.Any], ParamType]]) -> None: + self.types: t.Sequence[ParamType] = [convert_type(ty) for ty in types] + +
[docs] def to_info_dict(self) -> t.Dict[str, t.Any]: + info_dict = super().to_info_dict() + info_dict["types"] = [t.to_info_dict() for t in self.types] + return info_dict
+ + @property + def name(self) -> str: # type: ignore + return f"<{' '.join(ty.name for ty in self.types)}>" + + @property + def arity(self) -> int: # type: ignore + return len(self.types) + +
[docs] def convert( + self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] + ) -> t.Any: + len_type = len(self.types) + len_value = len(value) + + if len_value != len_type: + self.fail( + ngettext( + "{len_type} values are required, but {len_value} was given.", + "{len_type} values are required, but {len_value} were given.", + len_value, + ).format(len_type=len_type, len_value=len_value), + param=param, + ctx=ctx, + ) + + return tuple(ty(x, param, ctx) for ty, x in zip(self.types, value))
+ + +def convert_type(ty: t.Optional[t.Any], default: t.Optional[t.Any] = None) -> ParamType: + """Find the most appropriate :class:`ParamType` for the given Python + type. If the type isn't provided, it can be inferred from a default + value. + """ + guessed_type = False + + if ty is None and default is not None: + if isinstance(default, (tuple, list)): + # If the default is empty, ty will remain None and will + # return STRING. + if default: + item = default[0] + + # A tuple of tuples needs to detect the inner types. + # Can't call convert recursively because that would + # incorrectly unwind the tuple to a single type. + if isinstance(item, (tuple, list)): + ty = tuple(map(type, item)) + else: + ty = type(item) + else: + ty = type(default) + + guessed_type = True + + if isinstance(ty, tuple): + return Tuple(ty) + + if isinstance(ty, ParamType): + return ty + + if ty is str or ty is None: + return STRING + + if ty is int: + return INT + + if ty is float: + return FLOAT + + if ty is bool: + return BOOL + + if guessed_type: + return STRING + + if __debug__: + try: + if issubclass(ty, ParamType): + raise AssertionError( + f"Attempted to use an uninstantiated parameter type ({ty})." + ) + except TypeError: + # ty is an instance (correct), so issubclass fails. + pass + + return FuncParamType(ty) + + +#: A dummy parameter type that just does nothing. From a user's +#: perspective this appears to just be the same as `STRING` but +#: internally no string conversion takes place if the input was bytes. +#: This is usually useful when working with file paths as they can +#: appear in bytes and unicode. +#: +#: For path related uses the :class:`Path` type is a better choice but +#: there are situations where an unprocessed type is useful which is why +#: it is is provided. +#: +#: .. versionadded:: 4.0 +UNPROCESSED = UnprocessedParamType() + +#: A unicode string parameter type which is the implicit default. This +#: can also be selected by using ``str`` as type. +STRING = StringParamType() + +#: An integer parameter. This can also be selected by using ``int`` as +#: type. +INT = IntParamType() + +#: A floating point value parameter. This can also be selected by using +#: ``float`` as type. +FLOAT = FloatParamType() + +#: A boolean parameter. This is the default for boolean flags. This can +#: also be selected by using ``bool`` as a type. +BOOL = BoolParamType() + +#: A UUID parameter. +UUID = UUIDParameterType() +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click/utils.html b/_modules/click/utils.html new file mode 100644 index 000000000..4a0e62185 --- /dev/null +++ b/_modules/click/utils.html @@ -0,0 +1,953 @@ + + + + + + + + click.utils - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click.utils

+import os
+import re
+import sys
+import typing as t
+from functools import update_wrapper
+from types import ModuleType
+from types import TracebackType
+
+from ._compat import _default_text_stderr
+from ._compat import _default_text_stdout
+from ._compat import _find_binary_writer
+from ._compat import auto_wrap_for_ansi
+from ._compat import binary_streams
+from ._compat import open_stream
+from ._compat import should_strip_ansi
+from ._compat import strip_ansi
+from ._compat import text_streams
+from ._compat import WIN
+from .globals import resolve_color_default
+
+if t.TYPE_CHECKING:
+    import typing_extensions as te
+
+    P = te.ParamSpec("P")
+
+R = t.TypeVar("R")
+
+
+def _posixify(name: str) -> str:
+    return "-".join(name.split()).lower()
+
+
+def safecall(func: "t.Callable[P, R]") -> "t.Callable[P, t.Optional[R]]":
+    """Wraps a function so that it swallows exceptions."""
+
+    def wrapper(*args: "P.args", **kwargs: "P.kwargs") -> t.Optional[R]:
+        try:
+            return func(*args, **kwargs)
+        except Exception:
+            pass
+        return None
+
+    return update_wrapper(wrapper, func)
+
+
+def make_str(value: t.Any) -> str:
+    """Converts a value into a valid string."""
+    if isinstance(value, bytes):
+        try:
+            return value.decode(sys.getfilesystemencoding())
+        except UnicodeError:
+            return value.decode("utf-8", "replace")
+    return str(value)
+
+
+def make_default_short_help(help: str, max_length: int = 45) -> str:
+    """Returns a condensed version of help string."""
+    # Consider only the first paragraph.
+    paragraph_end = help.find("\n\n")
+
+    if paragraph_end != -1:
+        help = help[:paragraph_end]
+
+    # Collapse newlines, tabs, and spaces.
+    words = help.split()
+
+    if not words:
+        return ""
+
+    # The first paragraph started with a "no rewrap" marker, ignore it.
+    if words[0] == "\b":
+        words = words[1:]
+
+    total_length = 0
+    last_index = len(words) - 1
+
+    for i, word in enumerate(words):
+        total_length += len(word) + (i > 0)
+
+        if total_length > max_length:  # too long, truncate
+            break
+
+        if word[-1] == ".":  # sentence end, truncate without "..."
+            return " ".join(words[: i + 1])
+
+        if total_length == max_length and i != last_index:
+            break  # not at sentence end, truncate with "..."
+    else:
+        return " ".join(words)  # no truncation needed
+
+    # Account for the length of the suffix.
+    total_length += len("...")
+
+    # remove words until the length is short enough
+    while i > 0:
+        total_length -= len(words[i]) + (i > 0)
+
+        if total_length <= max_length:
+            break
+
+        i -= 1
+
+    return " ".join(words[:i]) + "..."
+
+
+class LazyFile:
+    """A lazy file works like a regular file but it does not fully open
+    the file but it does perform some basic checks early to see if the
+    filename parameter does make sense.  This is useful for safely opening
+    files for writing.
+    """
+
+    def __init__(
+        self,
+        filename: t.Union[str, "os.PathLike[str]"],
+        mode: str = "r",
+        encoding: t.Optional[str] = None,
+        errors: t.Optional[str] = "strict",
+        atomic: bool = False,
+    ):
+        self.name: str = os.fspath(filename)
+        self.mode = mode
+        self.encoding = encoding
+        self.errors = errors
+        self.atomic = atomic
+        self._f: t.Optional[t.IO[t.Any]]
+        self.should_close: bool
+
+        if self.name == "-":
+            self._f, self.should_close = open_stream(filename, mode, encoding, errors)
+        else:
+            if "r" in mode:
+                # Open and close the file in case we're opening it for
+                # reading so that we can catch at least some errors in
+                # some cases early.
+                open(filename, mode).close()
+            self._f = None
+            self.should_close = True
+
+    def __getattr__(self, name: str) -> t.Any:
+        return getattr(self.open(), name)
+
+    def __repr__(self) -> str:
+        if self._f is not None:
+            return repr(self._f)
+        return f"<unopened file '{format_filename(self.name)}' {self.mode}>"
+
+    def open(self) -> t.IO[t.Any]:
+        """Opens the file if it's not yet open.  This call might fail with
+        a :exc:`FileError`.  Not handling this error will produce an error
+        that Click shows.
+        """
+        if self._f is not None:
+            return self._f
+        try:
+            rv, self.should_close = open_stream(
+                self.name, self.mode, self.encoding, self.errors, atomic=self.atomic
+            )
+        except OSError as e:  # noqa: E402
+            from .exceptions import FileError
+
+            raise FileError(self.name, hint=e.strerror) from e
+        self._f = rv
+        return rv
+
+    def close(self) -> None:
+        """Closes the underlying file, no matter what."""
+        if self._f is not None:
+            self._f.close()
+
+    def close_intelligently(self) -> None:
+        """This function only closes the file if it was opened by the lazy
+        file wrapper.  For instance this will never close stdin.
+        """
+        if self.should_close:
+            self.close()
+
+    def __enter__(self) -> "LazyFile":
+        return self
+
+    def __exit__(
+        self,
+        exc_type: t.Optional[t.Type[BaseException]],
+        exc_value: t.Optional[BaseException],
+        tb: t.Optional[TracebackType],
+    ) -> None:
+        self.close_intelligently()
+
+    def __iter__(self) -> t.Iterator[t.AnyStr]:
+        self.open()
+        return iter(self._f)  # type: ignore
+
+
+class KeepOpenFile:
+    def __init__(self, file: t.IO[t.Any]) -> None:
+        self._file: t.IO[t.Any] = file
+
+    def __getattr__(self, name: str) -> t.Any:
+        return getattr(self._file, name)
+
+    def __enter__(self) -> "KeepOpenFile":
+        return self
+
+    def __exit__(
+        self,
+        exc_type: t.Optional[t.Type[BaseException]],
+        exc_value: t.Optional[BaseException],
+        tb: t.Optional[TracebackType],
+    ) -> None:
+        pass
+
+    def __repr__(self) -> str:
+        return repr(self._file)
+
+    def __iter__(self) -> t.Iterator[t.AnyStr]:
+        return iter(self._file)
+
+
+
[docs]def echo( + message: t.Optional[t.Any] = None, + file: t.Optional[t.IO[t.Any]] = None, + nl: bool = True, + err: bool = False, + color: t.Optional[bool] = None, +) -> None: + """Print a message and newline to stdout or a file. This should be + used instead of :func:`print` because it provides better support + for different data, files, and environments. + + Compared to :func:`print`, this does the following: + + - Ensures that the output encoding is not misconfigured on Linux. + - Supports Unicode in the Windows console. + - Supports writing to binary outputs, and supports writing bytes + to text outputs. + - Supports colors and styles on Windows. + - Removes ANSI color and style codes if the output does not look + like an interactive terminal. + - Always flushes the output. + + :param message: The string or bytes to output. Other objects are + converted to strings. + :param file: The file to write to. Defaults to ``stdout``. + :param err: Write to ``stderr`` instead of ``stdout``. + :param nl: Print a newline after the message. Enabled by default. + :param color: Force showing or hiding colors and other styles. By + default Click will remove color if the output does not look like + an interactive terminal. + + .. versionchanged:: 6.0 + Support Unicode output on the Windows console. Click does not + modify ``sys.stdout``, so ``sys.stdout.write()`` and ``print()`` + will still not support Unicode. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. + + .. versionadded:: 3.0 + Added the ``err`` parameter. + + .. versionchanged:: 2.0 + Support colors on Windows if colorama is installed. + """ + if file is None: + if err: + file = _default_text_stderr() + else: + file = _default_text_stdout() + + # There are no standard streams attached to write to. For example, + # pythonw on Windows. + if file is None: + return + + # Convert non bytes/text into the native string type. + if message is not None and not isinstance(message, (str, bytes, bytearray)): + out: t.Optional[t.Union[str, bytes]] = str(message) + else: + out = message + + if nl: + out = out or "" + if isinstance(out, str): + out += "\n" + else: + out += b"\n" + + if not out: + file.flush() + return + + # If there is a message and the value looks like bytes, we manually + # need to find the binary stream and write the message in there. + # This is done separately so that most stream types will work as you + # would expect. Eg: you can write to StringIO for other cases. + if isinstance(out, (bytes, bytearray)): + binary_file = _find_binary_writer(file) + + if binary_file is not None: + file.flush() + binary_file.write(out) + binary_file.flush() + return + + # ANSI style code support. For no message or bytes, nothing happens. + # When outputting to a file instead of a terminal, strip codes. + else: + color = resolve_color_default(color) + + if should_strip_ansi(file, color): + out = strip_ansi(out) + elif WIN: + if auto_wrap_for_ansi is not None: + file = auto_wrap_for_ansi(file) # type: ignore + elif not color: + out = strip_ansi(out) + + file.write(out) # type: ignore + file.flush()
+ + +
[docs]def get_binary_stream(name: "te.Literal['stdin', 'stdout', 'stderr']") -> t.BinaryIO: + """Returns a system stream for byte processing. + + :param name: the name of the stream to open. Valid names are ``'stdin'``, + ``'stdout'`` and ``'stderr'`` + """ + opener = binary_streams.get(name) + if opener is None: + raise TypeError(f"Unknown standard stream '{name}'") + return opener()
+ + +
[docs]def get_text_stream( + name: "te.Literal['stdin', 'stdout', 'stderr']", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", +) -> t.TextIO: + """Returns a system stream for text processing. This usually returns + a wrapped stream around a binary stream returned from + :func:`get_binary_stream` but it also can take shortcuts for already + correctly configured streams. + + :param name: the name of the stream to open. Valid names are ``'stdin'``, + ``'stdout'`` and ``'stderr'`` + :param encoding: overrides the detected default encoding. + :param errors: overrides the default error mode. + """ + opener = text_streams.get(name) + if opener is None: + raise TypeError(f"Unknown standard stream '{name}'") + return opener(encoding, errors)
+ + +
[docs]def open_file( + filename: str, + mode: str = "r", + encoding: t.Optional[str] = None, + errors: t.Optional[str] = "strict", + lazy: bool = False, + atomic: bool = False, +) -> t.IO[t.Any]: + """Open a file, with extra behavior to handle ``'-'`` to indicate + a standard stream, lazy open on write, and atomic write. Similar to + the behavior of the :class:`~click.File` param type. + + If ``'-'`` is given to open ``stdout`` or ``stdin``, the stream is + wrapped so that using it in a context manager will not close it. + This makes it possible to use the function without accidentally + closing a standard stream: + + .. code-block:: python + + with open_file(filename) as f: + ... + + :param filename: The name of the file to open, or ``'-'`` for + ``stdin``/``stdout``. + :param mode: The mode in which to open the file. + :param encoding: The encoding to decode or encode a file opened in + text mode. + :param errors: The error handling mode. + :param lazy: Wait to open the file until it is accessed. For read + mode, the file is temporarily opened to raise access errors + early, then closed until it is read again. + :param atomic: Write to a temporary file and replace the given file + on close. + + .. versionadded:: 3.0 + """ + if lazy: + return t.cast( + t.IO[t.Any], LazyFile(filename, mode, encoding, errors, atomic=atomic) + ) + + f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic) + + if not should_close: + f = t.cast(t.IO[t.Any], KeepOpenFile(f)) + + return f
+ + +
[docs]def format_filename( + filename: "t.Union[str, bytes, os.PathLike[str], os.PathLike[bytes]]", + shorten: bool = False, +) -> str: + """Format a filename as a string for display. Ensures the filename can be + displayed by replacing any invalid bytes or surrogate escapes in the name + with the replacement character ``�``. + + Invalid bytes or surrogate escapes will raise an error when written to a + stream with ``errors="strict". This will typically happen with ``stdout`` + when the locale is something like ``en_GB.UTF-8``. + + Many scenarios *are* safe to write surrogates though, due to PEP 538 and + PEP 540, including: + + - Writing to ``stderr``, which uses ``errors="backslashreplace"``. + - The system has ``LANG=C.UTF-8``, ``C``, or ``POSIX``. Python opens + stdout and stderr with ``errors="surrogateescape"``. + - None of ``LANG/LC_*`` are set. Python assumes ``LANG=C.UTF-8``. + - Python is started in UTF-8 mode with ``PYTHONUTF8=1`` or ``-X utf8``. + Python opens stdout and stderr with ``errors="surrogateescape"``. + + :param filename: formats a filename for UI display. This will also convert + the filename into unicode without failing. + :param shorten: this optionally shortens the filename to strip of the + path that leads up to it. + """ + if shorten: + filename = os.path.basename(filename) + else: + filename = os.fspath(filename) + + if isinstance(filename, bytes): + filename = filename.decode(sys.getfilesystemencoding(), "replace") + else: + filename = filename.encode("utf-8", "surrogateescape").decode( + "utf-8", "replace" + ) + + return filename
+ + +
[docs]def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str: + r"""Returns the config folder for the application. The default behavior + is to return whatever is most appropriate for the operating system. + + To give you an idea, for an app called ``"Foo Bar"``, something like + the following folders could be returned: + + Mac OS X: + ``~/Library/Application Support/Foo Bar`` + Mac OS X (POSIX): + ``~/.foo-bar`` + Unix: + ``~/.config/foo-bar`` + Unix (POSIX): + ``~/.foo-bar`` + Windows (roaming): + ``C:\Users\<user>\AppData\Roaming\Foo Bar`` + Windows (not roaming): + ``C:\Users\<user>\AppData\Local\Foo Bar`` + + .. versionadded:: 2.0 + + :param app_name: the application name. This should be properly capitalized + and can contain whitespace. + :param roaming: controls if the folder should be roaming or not on Windows. + Has no effect otherwise. + :param force_posix: if this is set to `True` then on any POSIX system the + folder will be stored in the home folder with a leading + dot instead of the XDG config home or darwin's + application support folder. + """ + if WIN: + key = "APPDATA" if roaming else "LOCALAPPDATA" + folder = os.environ.get(key) + if folder is None: + folder = os.path.expanduser("~") + return os.path.join(folder, app_name) + if force_posix: + return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}")) + if sys.platform == "darwin": + return os.path.join( + os.path.expanduser("~/Library/Application Support"), app_name + ) + return os.path.join( + os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), + _posixify(app_name), + )
+ + +class PacifyFlushWrapper: + """This wrapper is used to catch and suppress BrokenPipeErrors resulting + from ``.flush()`` being called on broken pipe during the shutdown/final-GC + of the Python interpreter. Notably ``.flush()`` is always called on + ``sys.stdout`` and ``sys.stderr``. So as to have minimal impact on any + other cleanup code, and the case where the underlying file is not a broken + pipe, all calls and attributes are proxied. + """ + + def __init__(self, wrapped: t.IO[t.Any]) -> None: + self.wrapped = wrapped + + def flush(self) -> None: + try: + self.wrapped.flush() + except OSError as e: + import errno + + if e.errno != errno.EPIPE: + raise + + def __getattr__(self, attr: str) -> t.Any: + return getattr(self.wrapped, attr) + + +def _detect_program_name( + path: t.Optional[str] = None, _main: t.Optional[ModuleType] = None +) -> str: + """Determine the command used to run the program, for use in help + text. If a file or entry point was executed, the file name is + returned. If ``python -m`` was used to execute a module or package, + ``python -m name`` is returned. + + This doesn't try to be too precise, the goal is to give a concise + name for help text. Files are only shown as their name without the + path. ``python`` is only shown for modules, and the full path to + ``sys.executable`` is not shown. + + :param path: The Python file being executed. Python puts this in + ``sys.argv[0]``, which is used by default. + :param _main: The ``__main__`` module. This should only be passed + during internal testing. + + .. versionadded:: 8.0 + Based on command args detection in the Werkzeug reloader. + + :meta private: + """ + if _main is None: + _main = sys.modules["__main__"] + + if not path: + path = sys.argv[0] + + # The value of __package__ indicates how Python was called. It may + # not exist if a setuptools script is installed as an egg. It may be + # set incorrectly for entry points created with pip on Windows. + # It is set to "" inside a Shiv or PEX zipapp. + if getattr(_main, "__package__", None) in {None, ""} or ( + os.name == "nt" + and _main.__package__ == "" + and not os.path.exists(path) + and os.path.exists(f"{path}.exe") + ): + # Executed a file, like "python app.py". + return os.path.basename(path) + + # Executed a module, like "python -m example". + # Rewritten by Python from "-m script" to "/path/to/script.py". + # Need to look at main module to determine how it was executed. + py_module = t.cast(str, _main.__package__) + name = os.path.splitext(os.path.basename(path))[0] + + # A submodule like "example.cli". + if name != "__main__": + py_module = f"{py_module}.{name}" + + return f"python -m {py_module.lstrip('.')}" + + +def _expand_args( + args: t.Iterable[str], + *, + user: bool = True, + env: bool = True, + glob_recursive: bool = True, +) -> t.List[str]: + """Simulate Unix shell expansion with Python functions. + + See :func:`glob.glob`, :func:`os.path.expanduser`, and + :func:`os.path.expandvars`. + + This is intended for use on Windows, where the shell does not do any + expansion. It may not exactly match what a Unix shell would do. + + :param args: List of command line arguments to expand. + :param user: Expand user home directory. + :param env: Expand environment variables. + :param glob_recursive: ``**`` matches directories recursively. + + .. versionchanged:: 8.1 + Invalid glob patterns are treated as empty expansions rather + than raising an error. + + .. versionadded:: 8.0 + + :meta private: + """ + from glob import glob + + out = [] + + for arg in args: + if user: + arg = os.path.expanduser(arg) + + if env: + arg = os.path.expandvars(arg) + + try: + matches = glob(arg, recursive=glob_recursive) + except re.error: + matches = [] + + if not matches: + out.append(arg) + else: + out.extend(matches) + + return out +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/colorize.html b/_modules/click_extra/colorize.html new file mode 100644 index 000000000..0d6a86ca2 --- /dev/null +++ b/_modules/click_extra/colorize.html @@ -0,0 +1,1172 @@ + + + + + + + + click_extra.colorize - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.colorize

+# 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.
+"""Helpers and utilities to apply ANSI coloring to terminal content."""
+
+from __future__ import annotations
+
+import dataclasses
+import os
+import re
+from collections.abc import Iterable
+from configparser import RawConfigParser
+from dataclasses import dataclass
+from gettext import gettext as _
+from operator import getitem
+from typing import Callable, Sequence, cast
+
+import click
+import cloup
+import regex as re3
+from boltons.strutils import complement_int_list, int_ranges_from_int_list
+from cloup._util import identity
+from cloup.styling import Color, IStyle
+
+from . import (
+    Choice,
+    Context,
+    HelpFormatter,
+    Option,
+    Parameter,
+    ParameterSource,
+    Style,
+    cache,
+    echo,
+    get_current_context,
+)
+from .parameters import ExtraOption
+
+
+
[docs]@dataclass(frozen=True) +class HelpExtraTheme(cloup.HelpTheme): + """Extends ``cloup.HelpTheme`` with ``logging.levels`` and extra properties.""" + + critical: IStyle = identity + error: IStyle = identity + warning: IStyle = identity + info: IStyle = identity + debug: IStyle = identity + """Log levels from Python's logging module.""" + + option: IStyle = identity + subcommand: IStyle = identity + choice: IStyle = identity + metavar: IStyle = identity + bracket: IStyle = identity + envvar: IStyle = identity + default: IStyle = identity + deprecated: IStyle = identity + search: IStyle = identity + success: IStyle = identity + """Click Extra new coloring properties.""" + + subheading: IStyle = identity + """Non-canonical Click Extra properties. + + .. note:: + Subheading is used for sub-sections, like `in the help of mail-deduplicate + <https://github.com/kdeldycke/mail-deduplicate/blob/0764287/mail_deduplicate/deduplicate.py#L445>`_. + + .. todo:: + Maybe this shouldn't be in Click Extra because it is a legacy inheritance from + one of my other project. + """ + +
[docs] def with_( # type: ignore[override] + self, + **kwargs: dict[str, IStyle | None], + ) -> HelpExtraTheme: + """Derives a new theme from the current one, with some styles overridden. + + Returns the same instance if the provided styles are the same as the current. + """ + # Check for unrecognized arguments. + unrecognized_args = set(kwargs).difference(self.__dataclass_fields__) + if unrecognized_args: + msg = f"Got unexpected keyword argument(s): {', '.join(unrecognized_args)}" + raise TypeError(msg) + + # List of styles that are different from the base theme. + new_styles = { + field_id: new_style + for field_id, new_style in kwargs.items() + if new_style != getattr(self, field_id) + } + if new_styles: + return dataclasses.replace(self, **new_styles) # type: ignore[arg-type] + + # No new styles, return the same instance. + return self
+ +
[docs] @staticmethod + def dark() -> HelpExtraTheme: + """A theme assuming a dark terminal background color.""" + return HelpExtraTheme( + invoked_command=Style(fg=Color.bright_white), + heading=Style(fg=Color.bright_blue, bold=True, underline=True), + constraint=Style(fg=Color.magenta), + # Neutralize Cloup's col1, as it interferes with our finer option styling + # which takes care of separators. + col1=identity, + # Style aliases like options and subcommands. + alias=Style(fg=Color.cyan), + # Style aliases punctuation like options, but dimmed. + alias_secondary=Style(fg=Color.cyan, dim=True), + ### Log styles. + critical=Style(fg=Color.red, bold=True), + error=Style(fg=Color.red), + warning=Style(fg=Color.yellow), + info=identity, # INFO level is the default, so no style applied. + debug=Style(fg=Color.blue), + ### Click Extra styles. + option=Style(fg=Color.cyan), + # Style subcommands like options and aliases. + subcommand=Style(fg=Color.cyan), + choice=Style(fg=Color.magenta), + metavar=Style(fg=Color.cyan, dim=True), + bracket=Style(dim=True), + envvar=Style(fg=Color.yellow, dim=True), + default=Style(fg=Color.green, dim=True, italic=True), + deprecated=Style(fg=Color.bright_yellow, bold=True), + search=Style(fg=Color.green, bold=True), + success=Style(fg=Color.green), + ### Non-canonical Click Extra styles. + subheading=Style(fg=Color.blue), + )
+ +
[docs] @staticmethod + def light() -> HelpExtraTheme: + """A theme assuming a light terminal background color. + + .. todo:: + Tweak colors to make them more readable. + """ + return HelpExtraTheme.dark()
+ + +# Populate our global theme with all default styles. +default_theme = HelpExtraTheme.dark() + + +# No color theme. +nocolor_theme = HelpExtraTheme() + + +OK = default_theme.success("✓") +KO = default_theme.error("✘") +"""Pre-rendered UI-elements.""" + +color_env_vars = { + # Colors. + "COLOR": True, + "COLORS": True, + "CLICOLOR": True, + "CLICOLORS": True, + "FORCE_COLOR": True, + "FORCE_COLORS": True, + "CLICOLOR_FORCE": True, + "CLICOLORS_FORCE": True, + # No colors. + "NOCOLOR": False, + "NOCOLORS": False, + "NO_COLOR": False, + "NO_COLORS": False, +} +"""List of environment variables recognized as flags to switch color rendering on or +off. + +The key is the name of the variable and the boolean value the value to pass to +``--color`` option flag when encountered. + +Source: https://github.com/pallets/click/issues/558 +""" + + +
[docs]class ColorOption(ExtraOption): + """A pre-configured option that is adding a ``--color``/``--no-color`` (aliased by + ``--ansi``/``--no-ansi``) option to keep or strip colors and ANSI codes from CLI + output. + + This option is eager by default to allow for other eager options (like + ``--version``) to be rendered colorless. + + .. todo:: + + Should we switch to ``--color=<auto|never|always>`` `as GNU tools does + <https://news.ycombinator.com/item?id=36102377>`_? + + Also see `how the isatty property defaults with this option + <https://news.ycombinator.com/item?id=36100865>`_, and `how it can be + implemented in Python <https://bixense.com/clicolors/>`_. + + .. todo:: + + Support the `TERM environment variable convention + <https://news.ycombinator.com/item?id=36101712>`_? + """ + +
[docs] @staticmethod + def disable_colors( + ctx: Context, + param: Parameter, + value: bool, + ) -> None: + """Callback disabling all coloring utilities. + + Re-inspect the environment for existence of colorization flags to re-interpret + the provided value. + """ + # Collect all colorize flags in environment variables we recognize. + colorize_from_env = set() + for var, default in color_env_vars.items(): + if var in os.environ: + # Presence of the variable in the environment without a value encodes + # for an activation, hence the default to True. + var_value = os.environ.get(var, "true") + # `os.environ` is a dict whose all values are strings. Here we normalize + # these string into booleans. If we can't, we fallback to True, in the + # same spirit as above. + var_boolean = RawConfigParser.BOOLEAN_STATES.get( + var_value.lower(), + True, + ) + colorize_from_env.add(default ^ (not var_boolean)) + + # Re-interpret the provided value against the recognized environment variables. + if colorize_from_env: + # The environment can only override the provided value if it comes from + # the default value or the config file. + env_takes_precedence = ( + ctx.get_parameter_source("color") == ParameterSource.DEFAULT + ) + if env_takes_precedence: + # One env var is enough to activate colorization. + value = True in colorize_from_env + + # There is an undocumented color flag in context: + # https://github.com/pallets/click/blob/65eceb0/src/click/globals.py#L56-L69 + ctx.color = value + + if not value: + + def restore_original_styling(): + """Reset color flag in context.""" + ctx = get_current_context() + ctx.color = None + + ctx.call_on_close(restore_original_styling)
+ + def __init__( + self, + param_decls: Sequence[str] | None = None, + is_flag=True, + default=True, + is_eager=True, + expose_value=False, + help=_("Strip out all colors and all ANSI codes from output."), + **kwargs, + ) -> None: + if not param_decls: + param_decls = ("--color/--no-color", "--ansi/--no-ansi") + + kwargs.setdefault("callback", self.disable_colors) + + super().__init__( + param_decls=param_decls, + is_flag=is_flag, + default=default, + is_eager=is_eager, + expose_value=expose_value, + help=help, + **kwargs, + )
+ + +
[docs]class HelpOption(ExtraOption): + """Like Click's @help_option but made into a reusable class-based option. + + .. note:: + Keep implementation in sync with upstream for drop-in replacement + compatibility. + + .. todo:: + Reuse Click's ``HelpOption`` once this PR is merged: + https://github.com/pallets/click/pull/2563 + """ + + def __init__( + self, + param_decls: Sequence[str] | None = None, + is_flag=True, + expose_value=False, + is_eager=True, + help=_("Show this message and exit."), + **kwargs, + ) -> None: + """Same defaults as Click's @help_option but with ``-h`` short option. + + See: https://github.com/pallets/click/blob/d9af5cf/src/click/decorators.py#L563C23-L563C34 + """ + if not param_decls: + param_decls = ("--help", "-h") + + kwargs.setdefault("callback", self.print_help) + + super().__init__( + param_decls=param_decls, + is_flag=is_flag, + expose_value=expose_value, + is_eager=is_eager, + help=help, + **kwargs, + ) + +
[docs] @staticmethod + def print_help(ctx: Context, param: Parameter, value: bool) -> None: + """Prints help text and exits. + + Exact same behavior as `Click's original @help_option callback + <https://github.com/pallets/click/blob/d9af5cf/src/click/decorators.py#L555-L560>`_, + but forces the closing of the context before exiting. + """ + if value and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit()
+ + +
[docs]class ExtraHelpColorsMixin: # (Command)?? + """Adds extra-keywords highlighting to Click commands. + + This mixin for ``click.Command``-like classes intercepts the top-level helper- + generation method to initialize the formatter with dynamic settings. This is + implemented at this stage so we have access to the global context. + """ + + def _collect_keywords( + self, + ctx: Context, + ) -> tuple[ + set[str], + set[str], + set[str], + set[str], + set[str], + set[str], + set[str], + set[str], + set[str], + ]: + """Parse click context to collect option names, choices and metavar keywords. + + This is Click Extra-specific and is not part of the upstream ``click.Command`` + API. + """ + cli_names: set[str] = set() + subcommands: set[str] = set() + command_aliases: set[str] = set() + options: set[str] = set() + long_options: set[str] = set() + short_options: set[str] = set() + choices: set[str] = set() + metavars: set[str] = set() + envvars: set[str] = set() + defaults: set[str] = set() + + # Includes CLI base name and its commands. + cli_names.add(ctx.command_path) + command = ctx.command + + # Will fetch command's metavar (i.e. the "[OPTIONS]" after the CLI name in + # "Usage:") and dig into subcommands to get subcommand_metavar: + # ("COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..."). + metavars.update(command.collect_usage_pieces(ctx)) + + # Get subcommands and their aliases. + if isinstance(command, click.MultiCommand): + subcommands.update(command.list_commands(ctx)) + for sub_id in subcommands: + sub_cmd = command.get_command(ctx, sub_id) + command_aliases.update(getattr(sub_cmd, "aliases", [])) + + # Add user defined help options. + options.update(ctx.help_option_names) + + # Collect all options, choices, metavars, envvars and default values. + for param in command.params: + options.update(param.opts) + options.update(param.secondary_opts) + + if isinstance(param.type, Choice): + choices.update(param.type.choices) + + metavars.add(param.make_metavar()) + + if param.envvar: + if isinstance(param.envvar, str): + envvars.add(param.envvar) + else: + envvars.update(param.envvar) + + if isinstance(param, click.Option): + default_string = ExtraOption.get_help_default(param, ctx) + if default_string: + defaults.add(default_string) + + # Split between shorts and long options + for option_name in options: + # Short options are no longer than 2 characters like "-D", "/d", "/?", + # "+w", "-w", "f_", "_f", ... + # XXX We cannot reuse the _short_opts and _long_opts attributes from + # https://github.com/pallets/click/blob/b0538df/src/click/parser.py#L173-L182 + # because their values are not passed when the context is updated like + # ctx._opt_prefixes is at: + # https://github.com/pallets/click/blob/b0538df/src/click/core.py#L1408 . + # So we rely on simple heuristics to guess the option category. + if len(option_name) <= 2: + short_options.add(option_name) + # Any other is considered a long options. Like: "--debug", "--c", "-otest", + # "---debug", "-vvvv, "++foo", "/debug", "from_", "_from", ... + else: + long_options.add(option_name) + + return ( + cli_names, + subcommands, + command_aliases, + long_options, + short_options, + choices, + metavars, + envvars, + defaults, + ) + +
[docs] def get_help_option(self, ctx: Context) -> Option | None: + """Returns our custom help option object instead of Click's default one.""" + # Let Click generate the default help option or not. + help_option = super().get_help_option(ctx) # type: ignore[misc] + # If Click decided to not add a default help option, we don't either. + if not help_option: + return None + # Return our own help option. + return HelpOption(param_decls=help_option.opts)
+ +
[docs] def get_help(self, ctx: Context) -> str: + """Replace default formatter by our own.""" + ctx.formatter_class = HelpExtraFormatter + return super().get_help(ctx) # type: ignore[no-any-return,misc]
+ +
[docs] def format_help(self, ctx: Context, formatter: HelpExtraFormatter) -> None: + """Feed our custom formatter instance with the keywords to highlight.""" + ( + formatter.cli_names, + formatter.subcommands, + formatter.command_aliases, + formatter.long_options, + formatter.short_options, + formatter.choices, + formatter.metavars, + formatter.envvars, + formatter.defaults, + ) = self._collect_keywords(ctx) + super().format_help(ctx, formatter) # type: ignore[misc]
+ + +
[docs]def escape_for_help_screen(text: str) -> str: + """Prepares a string to be used in a regular expression for matches in help screen. + + Applies `re.escape <https://docs.python.org/3/library/re.html#re.escape>`_, then + accounts for long strings being wrapped on multiple lines and padded with spaces to + fit the columnar layout. + + It allows for: + - additional number of optional blank characters (line-returns, spaces, tabs, ...) + after a dash, as the help renderer is free to wrap strings after a dash. + - a space to be replaced by any number of blank characters. + """ + return re.escape(text).replace("-", "-\\s*").replace("\\ ", "\\s+")
+ + +
[docs]class HelpExtraFormatter(HelpFormatter): + """Extends Cloup's custom HelpFormatter to highlights options, choices, metavars and + default values. + + This is being discussed for upstream integration at: + + - https://github.com/janluke/cloup/issues/97 + - https://github.com/click-contrib/click-help-colors/issues/17 + - https://github.com/janluke/cloup/issues/95 + """ + + theme: HelpExtraTheme + + def __init__(self, *args, **kwargs) -> None: + """Forces theme to our default. + + Also transform Cloup's standard ``HelpTheme`` to our own ``HelpExtraTheme``. + """ + theme = kwargs.get("theme", default_theme) + if not isinstance(theme, HelpExtraTheme): + theme = default_theme.with_(**theme._asdict()) + kwargs["theme"] = theme + super().__init__(*args, **kwargs) + + # Lists of extra keywords to highlight. + cli_names: set[str] = set() + subcommands: set[str] = set() + command_aliases: set[str] = set() + long_options: set[str] = set() + short_options: set[str] = set() + choices: set[str] = set() + metavars: set[str] = set() + envvars: set[str] = set() + defaults: set[str] = set() + + # TODO: Highlight extra keywords <stdout> or <stderr> + + # TODO: add collection of regexps as pre-compiled constants, so we can + # inspect them and get some performances improvements. + + style_aliases = { + # Layout elements of the square brackets trailing each option. + "bracket_1": "bracket", + "envvar_label": "bracket", + "label_sep_1": "bracket", + "default_label": "bracket", + "label_sep_2": "bracket", + "range": "bracket", + "label_sep_3": "bracket", + "required_label": "bracket", + "bracket_2": "bracket", + # Long and short options are options. + "long_option": "option", + "short_option": "option", + } + """Map regex's group IDs to styles. + + Most of the time, the style name is the same as the group ID. But some regular + expression implementations requires us to work around group IDs limitations, like + ``bracket_1`` and ``bracket_2``. In which case we use this mapping to apply back + the canonical style to that regex-specific group ID. + """ + +
[docs] @cache + def get_style_id(self, group_id: str) -> str: + """Get the style ID to apply to a group. + + Return the style which has the same ID as the group, unless it is defined in + the ``style_aliases`` mapping above. + """ + return self.style_aliases.get(group_id, group_id)
+ +
[docs] @cache + def colorize_group(self, str_to_style: str, group_id: str) -> str: + """Colorize a string according to the style of the group ID.""" + style = cast("IStyle", getattr(self.theme, self.get_style_id(group_id))) + return style(str_to_style)
+ +
[docs] def colorize(self, match: re.Match) -> str: + """Colorize all groups with IDs in the provided matching result. + + All groups without IDs are left as-is. + + All groups are processed in the order they appear in the ``match`` object. + Then all groups are concatenated to form the final string that is returned. + + .. caution:: + Implementation is a bit funky here because there is no way to iterate over + both unnamed and named groups, in the order they appear in the regex, while + keeping track of the group ID. + + So we have to iterate over the list of matching strings and pick up the + corresponding group ID along the way, from the ``match.groupdict()`` + dictionary. This also means we assume that the ``match.groupdict()`` is + returning an ordered dictionary. Which is supposed to be true as of Python + 3.7. + """ + # Get a snapshot of all named groups. + named_matches = list(match.groupdict().items()) + + txt = "" + # Iterate over all groups, named or not. + for group_string in match.groups(): + # Is the next available named group is matching current group string? + if named_matches and group_string == named_matches[0][1]: + # We just found a named group. Consume it from the list of named groups + # to prevent it from being processed twice. + group_id, group_string = named_matches.pop(0) + if group_string is not None: + # Colorize the group with a style matching its ID. + txt += self.colorize_group(group_string, group_id) + else: + # No named group matching this string. Leave it as-is. + txt += group_string + + # Double-check we processed all named groups. + if len(named_matches) != 0: + msg = ( + "The matching result contains named groups that were not processed. " + "There is an edge-case in the design of regular expressions." + ) + raise ValueError(msg) + + return txt
+ +
[docs] def highlight_extra_keywords(self, help_text: str) -> str: + """Highlight extra keywords in help screens based on the theme. + + It is based on regular expressions. While this is not a bullet-proof method, it + is good enough. After all, help screens are not consumed by machine but are + designed for humans. + + .. danger:: + All the regular expressions below are designed to match its original string + into a sequence of contiguous groups. + + This means each part of the matching result must be encapsulated in a group. + And subgroups are not allowed (unless their are explicitly set as + non-matching with ``(?:...)`` prefix). + + Groups with a name must have a corresponding style. + """ + # Highlight " (Deprecated)" label, as set by either Click or Cloup: + # https://github.com/pallets/click/blob/8.0.0rc1/tests/test_commands.py#L322 + # https://github.com/janluke/cloup/blob/v2.1.0/cloup/formatting/_formatter.py#L190 + help_text = re.sub( + rf""" + (\s) # Any blank char. + (?P<deprecated>{re.escape("(Deprecated)")}) # The flag string. + """, + self.colorize, + help_text, + flags=re.VERBOSE, + ) + + # Highlight subcommands. + for subcommand in self.subcommands: + help_text = re.sub( + rf""" + (\ \ ) # 2 spaces (i.e. section indentation). + (?P<subcommand>{re.escape(subcommand)}) + (\s) # Any blank char. + """, + self.colorize, + help_text, + flags=re.VERBOSE, + ) + + # Highlight environment variables and defaults in trailing square brackets. + help_text = re.sub( + r""" + (\ \ ) # 2 spaces (column spacing or description spacing). + (?P<bracket_1>\[) # Square brackets opening. + + (?: # Non-capturing group. + (?P<envvar_label> + env\s+var: # Starting content within the brackets. + \s+ # Any number of blank chars. + ) + (?P<envvar>.+?) # Greedy-matching of any string and line returns. + )? # The envvar group is optional. + + (?P<label_sep_1> + ; # Separator between labels. + \s+ # Any number of blank chars. + )? + + (?: # Non-capturing group. + (?P<default_label> + default: # Starting content within the brackets. + \s+ # Any number of blank chars. + ) + (?P<default>.+?) # Greedy-matching of any string and line returns. + )? # The default group is optional. + + (?P<label_sep_2> + ; # Separator between labels. + \s+ # Any number of blank chars. + )? + + (?: # Non-capturing group. + (?P<range> + (?: + \S+ + (?:<|<=) # Lower bound operators. + )? # Operator preceding x is optional. + x + (?:<|<=|>|>=) # Any range operator. + \S+ + ) + )? # The range group is optional. + + (?P<label_sep_3> + ; # Separator between labels. + \s+ # Any number of blank chars. + )? + + (?: # Non-capturing group. + (?P<required_label> + required # Required label. + ) + )? # The required group is optional. + + (?P<bracket_2>\]) # Square brackets closing. + """, + self.colorize, + help_text, + flags=re.VERBOSE | re.DOTALL, + ) + + # Highlight CLI names and commands. + for cli_name in self.cli_names: + help_text = re.sub( + rf""" + (\s) # Any blank char. + (?P<invoked_command>{re.escape(cli_name)}) # The CLI name. + (\s) # Any blank char. + """, + self.colorize, + help_text, + flags=re.VERBOSE, + ) + + # Highlight sections. + # XXX Duplicates Cloup's job, with the only subtlety of not highlighting the + # trailing semicolon. + # + # help_text = re.sub( + # r""" + # ^ # Beginning of a line preceded by a newline. + # (?P<heading>\S[\S+ ]+) # The section title. + # (:) # A semicolon. + # """, + # self.colorize, + # help_text, + # flags=re.VERBOSE | re.MULTILINE, + # ) + + # Highlight long options first, then short options. + for matching_keywords, style_group_id in ( + (sorted(self.long_options, reverse=True), "long_option"), + (sorted(self.short_options), "short_option"), + ): + for keyword in matching_keywords: + help_text = re.sub( + rf""" + ( + # Not a: word character, or a repeated option's leading symbol. + [^\w{re.escape(keyword[0])}] + ) + (?P<{style_group_id}>{escape_for_help_screen(keyword)}) + (\W) + """, + self.colorize, + help_text, + flags=re.VERBOSE, + ) + + # Highlight other keywords, which are expected to be separated by any + # character but word characters. + for matching_keywords, style_group_id in ( + (sorted(self.choices, reverse=True), "choice"), + (sorted(self.metavars, reverse=True), "metavar"), + ): + for keyword in matching_keywords: + help_text = re.sub( + rf""" + (\W) # Any character which is not a word character. + (?P<{style_group_id}>{escape_for_help_screen(keyword)}) + (\W) # Any character which is not a word character. + """, + self.colorize, + help_text, + flags=re.VERBOSE, + ) + + return help_text
+ +
[docs] def getvalue(self) -> str: + """Wrap original `Click.HelpFormatter.getvalue()` to force extra-colorization on + rendering.""" + help_text = super().getvalue() + return self.highlight_extra_keywords(help_text)
+ + +
[docs]def highlight( + string: str, + substrings: Iterable[str], + styling_method: Callable, + ignore_case: bool = False, +) -> str: + """Highlights parts of the ``string`` that matches ``substrings``. + + Takes care of overlapping parts within the ``string``. + """ + # Ranges of character indices flagged for highlighting. + ranges = set() + + for part in set(substrings): + # Search for occurrences of query parts in original string. + flags = re3.IGNORECASE if ignore_case else 0 + ranges |= { + f"{match.start()}-{match.end() - 1}" + for match in re3.finditer(part, string, flags=flags, overlapped=True) + } + + # Reduce ranges, compute complement ranges, transform them to list of integers. + range_arg = ",".join(ranges) + highlight_ranges = int_ranges_from_int_list(range_arg) + untouched_ranges = int_ranges_from_int_list( + complement_int_list(range_arg, range_end=len(string)), + ) + + # Apply style to range of characters flagged as matching. + styled_str = "" + for i, j in sorted(highlight_ranges + untouched_ranges): + segment = getitem(string, slice(i, j + 1)) + if (i, j) in highlight_ranges: + segment = styling_method(segment) + styled_str += str(segment) + + return styled_str
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/commands.html b/_modules/click_extra/commands.html new file mode 100644 index 000000000..fd18f3953 --- /dev/null +++ b/_modules/click_extra/commands.html @@ -0,0 +1,725 @@ + + + + + + + + click_extra.commands - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

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
+
+from typing import TYPE_CHECKING, Any
+
+import click
+import cloup
+
+from . import Command, Group, Option
+from .colorize import ColorOption, ExtraHelpColorsMixin, HelpExtraFormatter, HelpOption
+from .config import ConfigOption
+from .logging import VerbosityOption
+from .parameters import (
+    ExtraOption,
+    ShowParamsOption,
+    all_envvars,
+    normalize_envvar,
+    search_params,
+)
+from .timer import TimerOption
+from .version import ExtraVersionOption
+
+if TYPE_CHECKING:
+    from typing import NoReturn
+
+from click.exceptions import Exit
+
+
+
[docs]def patched_exit(self, code: int = 0) -> NoReturn: + """Exits the application with a given exit code. + + Forces the context to close before exiting, so callbacks attached to parameters + will be called to clean up their state. This is not important in normal CLI + execution as the Python process will just be destroyed. But it will lead to leaky + states in unitttests. + + .. seealso:: + This fix has been `proposed upstream to Click + <https://github.com/pallets/click/pull/2680>`_. + """ + self.close() + raise Exit(code)
+ + +cloup.Context.exit = patched_exit # type: ignore[method-assign] +"""Monkey-patch ``cloup.Context.exit``.""" + + +
[docs]class ExtraContext(cloup.Context): + """Like ``cloup._context.Context``, but with the ability to populate the context's + ``meta`` property at instantiation. + + Also inherits ``color`` property from parent context. And sets it to `True` for + parentless contexts at instantiatiom, so we can always have colorized output. + + .. todo:: + Propose addition of ``meta`` keyword upstream to Click. + """ + + formatter_class = HelpExtraFormatter # type: ignore[assignment] + """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`` property default to `True` if not provided by user and + this context has no parent. + """ + super().__init__(*args, **kwargs) + + # Update the context's meta property with the one provided by user. + if meta: + self._meta.update(meta) + + # Transfer user color setting to our internally managed value. + self._color: bool | None = kwargs.get("color", None) + + # A Context created from scratch, i.e. without a parent, and whose color + # setting is set to auto-detect (i.e. is None), will defaults to forced + # colorized output. + if not self.parent and self._color is None: + self._color = True + + @property + def color(self) -> bool | None: + """Overrides ``Context.color`` to allow inheritance from parent context. + + Returns the color setting of the parent context if it exists and the color is + not set on the current context. + """ + if self._color is None and self.parent: + return self.parent.color + return self._color + + @color.setter + def color(self, value: bool | None) -> None: + """Set the color value of the current context.""" + self._color = value + + @color.deleter + def color(self) -> None: + """Reset the color value so it defaults to inheritance from parent's.""" + self._color = None
+ + +
[docs]def default_extra_params() -> list[Option]: + """Default additional options added to ``extra_command`` and ``extra_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 so all other options can be timed. + #. ``-C``, ``--config CONFIG_PATH`` + .. attention:: + ``--config`` is at the top so it can have a direct influence on the default + behavior and value of the other options. + #. ``--color``, ``--ansi`` / ``--no-color``, ``--no-ansi`` + #. ``--show-params`` + #. ``-v``, ``--verbosity LEVEL`` + #. ``--version`` + #. ``-h``, ``--help`` + + .. 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>`_. + + .. 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. + """ + return [ + TimerOption(), + ColorOption(), + ConfigOption(), + ShowParamsOption(), + VerbosityOption(), + ExtraVersionOption(), + HelpOption(), + ]
+ + +
[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>`_. + + By default, these `Click context settings + <https://click.palletsprojects.com/en/8.1.x/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/8.1.x/documentation/#help-parameter-customization>`_. + + - ``show_default = True`` (*Click feature*) + + `Show all default values + <https://click.palletsprojects.com/en/8.1.x/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": ("--help", "-h"), + "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"] = normalize_envvar(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 = all_envvars(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(self, *args, **kwargs): + """Pre-invocation step that is instantiating the context, then call ``invoke()`` + within it. + + During context instantiation, each option's callbacks are called. Beware that + these might break the execution flow (like ``--help`` or ``--version``). + """ + return super().main(*args, **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.BaseCommand.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, 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/8.1.x/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/8.1.x/api/#click.Group.group_class + """
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/config.html b/_modules/click_extra/config.html new file mode 100644 index 000000000..f3bbfcf97 --- /dev/null +++ b/_modules/click_extra/config.html @@ -0,0 +1,771 @@ + + + + + + + + click_extra.config - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.config

+# 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.
+"""Utilities to load parameters and options from a configuration file."""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from configparser import ConfigParser, ExtendedInterpolation
+from enum import Enum
+from gettext import gettext as _
+from pathlib import Path
+from typing import Any, Iterable, Sequence
+from unittest.mock import patch
+
+if sys.version_info >= (3, 11):
+    import tomllib
+else:
+    import tomli as tomllib  # type: ignore[import-not-found]
+
+import commentjson as json
+import requests
+import xmltodict
+import yaml
+from boltons.iterutils import flatten, remap
+from boltons.pathutils import shrinkuser
+from boltons.urlutils import URL
+from mergedeep import merge
+from wcmatch.glob import (
+    BRACE,
+    DOTGLOB,
+    FOLLOW,
+    GLOBSTAR,
+    GLOBTILDE,
+    IGNORECASE,
+    NODIR,
+    iglob,
+)
+
+from . import (
+    STRING,
+    Context,
+    Parameter,
+    ParameterSource,
+    echo,
+    get_app_dir,
+    get_current_context,
+)
+from .parameters import ExtraOption, ParamStructure
+from .platforms import is_windows
+
+
+
[docs]class Formats(Enum): + """Supported configuration formats and the list of their default extensions. + + The default order set the priority by which each format is searched for the default + configuration file. + """ + + TOML = ("toml",) + YAML = ("yaml", "yml") + JSON = ("json",) + INI = ("ini",) + XML = ("xml",)
+ + +
[docs]class ConfigOption(ExtraOption, ParamStructure): + """A pre-configured option adding ``--config``/``-C`` option.""" + + formats: Sequence[Formats] + + roaming: bool + force_posix: bool + + strict: bool + + def __init__( + self, + param_decls: Sequence[str] | None = None, + metavar="CONFIG_PATH", + type=STRING, + help=_( + "Location of the configuration file. Supports glob pattern of local " + "path and remote URL.", + ), + is_eager=True, + expose_value=False, + formats=tuple(Formats), + roaming=True, + force_posix=False, + excluded_params=None, + strict=False, + **kwargs, + ) -> None: + """Takes as input a glob pattern or an URL. + + Glob patterns must follow the syntax of `wcmatch.glob + <https://facelessuser.github.io/wcmatch/glob/#syntax>`_. + + - ``is_eager`` is active by default so the config option's ``callback`` + gets the opportunity to set the ``default_map`` values before the + other options use them. + + - ``formats`` is the ordered list of formats that the configuration + file will be tried to be read with. Can be a single one. + + - ``roaming`` and ``force_posix`` are `fed to click.get_app_dir() + <https://click.palletsprojects.com/en/8.1.x/api/#click.get_app_dir>`_ + to setup the default configuration folder. + + - ``excluded_params`` is a list of options to ignore by the + configuration parser. Defaults to + ``ParamStructure.DEFAULT_EXCLUDED_PARAMS``. + + - ``strict`` + - If ``True``, raise an error if the configuration file contain + unrecognized content. + - If ``False``, silently ignore unsupported configuration option. + """ + if not param_decls: + param_decls = ("--config", "-C") + + # Make sure formats ends up as an iterable. + if isinstance(formats, Formats): + formats = (formats,) + self.formats = formats + + # Setup the configuration default folder. + self.roaming = roaming + self.force_posix = force_posix + kwargs.setdefault("default", self.default_pattern) + + if excluded_params is not None: + self.excluded_params = excluded_params + + self.strict = strict + + kwargs.setdefault("callback", self.load_conf) + + super().__init__( + param_decls=param_decls, + metavar=metavar, + type=type, + help=help, + is_eager=is_eager, + expose_value=expose_value, + **kwargs, + ) + +
[docs] def default_pattern(self) -> str: + """Returns the default pattern used to search for the configuration file. + + Defaults to ``/<app_dir>/*.{toml,yaml,yml,json,ini,xml}``. Where + ``<app_dir>`` is produced by the `clickget_app_dir() method + <https://click.palletsprojects.com/en/8.1.x/api/#click.get_app_dir>`_. + The result depends on OS and is influenced by the ``roaming`` and + ``force_posix`` properties of this instance. + + In that folder, we're looking for any file matching the extensions + derived from the ``self.formats`` property: + + - a simple ``*.ext`` pattern if only one format is set + - an expanded ``*.{ext1,ext2,...}`` pattern if multiple formats are set + """ + ctx = get_current_context() + cli_name = ctx.find_root().info_name + if not cli_name: + raise ValueError + app_dir = Path( + get_app_dir(cli_name, roaming=self.roaming, force_posix=self.force_posix), + ).resolve() + # Build the extension matching pattern. + extensions = flatten(f.value for f in self.formats) + if len(extensions) == 1: + ext_pattern = extensions[0] + else: + # Use brace notation for multiple extension matching. + ext_pattern = f"{{{','.join(extensions)}}}" + return f"{app_dir}{os.path.sep}*.{ext_pattern}"
+ +
[docs] def get_help_record(self, ctx: Context) -> tuple[str, str] | None: + """Replaces the default value by the pretty version of the configuration + matching pattern.""" + # Pre-compute pretty_path to bypass infinite recursive loop on get_default. + pretty_path = shrinkuser(Path(self.get_default(ctx))) # type: ignore[arg-type] + with patch.object(ConfigOption, "get_default") as mock_method: + mock_method.return_value = pretty_path + return super().get_help_record(ctx)
+ +
[docs] def search_and_read_conf(self, pattern: str) -> Iterable[tuple[Path | URL, str]]: + """Search on local file system or remote URL files matching the provided + pattern. + + ``pattern`` is considered an URL only if it is parseable as such and starts + with ``http://`` or ``https://``. + + Returns an iterator of the normalized configuration location and its textual + content, for each file/URL matching the pattern. + """ + logger = logging.getLogger("click_extra") + + # Check if the pattern is an URL. + location = URL(pattern) + location.normalize() + if location and location.scheme in ("http", "https"): + logger.debug(f"Download configuration from URL: {location}") + with requests.get(location) as response: + if response.ok: + yield location, response.text + return + logger.warning(f"Can't download {location}: {response.reason}") + + logger.debug("Pattern is not an URL: search local file system.") + # wcmatch expect patterns to be written with Unix-like syntax by default, even + # on Windows. See more details at: + # https://facelessuser.github.io/wcmatch/glob/#windows-separators + # https://github.com/facelessuser/wcmatch/issues/194 + if is_windows(): + pattern = pattern.replace("\\", "/") + for file in iglob( + pattern, + flags=NODIR | GLOBSTAR | DOTGLOB | GLOBTILDE | BRACE | FOLLOW | IGNORECASE, + ): + file_path = Path(file).resolve() + logger.debug(f"Configuration file found at {file_path}") + yield file_path, file_path.read_text()
+ +
[docs] def parse_conf(self, conf_text: str) -> dict[str, Any] | None: + """Try to parse the provided content with each format in the order provided by + the user. + + A successful parsing in any format is supposed to return a ``dict``. Any other + result, including any raised exception, is considered a failure and the next + format is tried. + """ + logger = logging.getLogger("click_extra") + + user_conf = None + for conf_format in self.formats: + logger.debug(f"Parse configuration as {conf_format.name}...") + + try: + if conf_format == Formats.TOML: + user_conf = tomllib.loads(conf_text) + + elif conf_format == Formats.YAML: + user_conf = yaml.full_load(conf_text) + + elif conf_format == Formats.JSON: + user_conf = json.loads(conf_text) + + elif conf_format == Formats.INI: + user_conf = self.load_ini_config(conf_text) + + elif conf_format == Formats.XML: + user_conf = xmltodict.parse(conf_text) + + except Exception as ex: + logger.debug(ex) + continue + + if isinstance(user_conf, dict): + return user_conf + else: + logger.debug(f"{conf_format.name} parsing failed.") + + return None
+ +
[docs] def read_and_parse_conf( + self, + pattern: str, + ) -> tuple[Path | URL, dict[str, Any]] | tuple[None, None]: + """Search for a configuration file matching the provided pattern. + + Returns the location and parsed content of the first valid configuration file + that is not blank, or `(None, None)` if no file was found. + """ + for conf_path, conf_text in self.search_and_read_conf(pattern): + user_conf = self.parse_conf(conf_text) + if user_conf is not None: + return conf_path, user_conf + return None, None
+ +
[docs] def load_ini_config(self, content: str) -> dict[str, Any]: + """Utility method to parse INI configuration file. + + Internal convention is to use a dot (``.``, as set by ``self.SEP``) in + section IDs as a separator between levels. This is a workaround + the limitation of ``INI`` format which doesn't allow for sub-sections. + + Returns a ready-to-use data structure. + """ + ini_config = ConfigParser(interpolation=ExtendedInterpolation()) + ini_config.read_string(content) + + conf: dict[str, Any] = {} + for section_id in ini_config.sections(): + # Extract all options of the section. + sub_conf = {} + for option_id in ini_config.options(section_id): + target_type = self.get_tree_value( + self.params_types, + section_id, + option_id, + ) + + value: Any + + if target_type in (None, str): + value = ini_config.get(section_id, option_id) + + elif target_type is int: + value = ini_config.getint(section_id, option_id) + + elif target_type is float: + value = ini_config.getfloat(section_id, option_id) + + elif target_type is bool: + value = ini_config.getboolean(section_id, option_id) + + # Types not natively supported by INI format are loaded as + # JSON-serialized strings. + elif target_type in (list, tuple, set, frozenset, dict): + value = json.loads(ini_config.get(section_id, option_id)) + + else: + msg = ( + f"Conversion of {target_type} type for " + f"[{section_id}]:{option_id} INI config option." + ) + raise ValueError(msg) + + sub_conf[option_id] = value + + # Place collected options at the right level of the dict tree. + merge(conf, self.init_tree_dict(*section_id.split(self.SEP), leaf=sub_conf)) + + return conf
+ +
[docs] def recursive_update(self, a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]: + """Like standard ``dict.update()``, but recursive so sub-dict gets updated. + + Ignore elements present in ``b`` but not in ``a``. + """ + for k, v in b.items(): + if isinstance(v, dict) and isinstance(a.get(k), dict): + a[k] = self.recursive_update(a[k], v) + # Ignore elements unregistered in the template structure. + elif k in a: + a[k] = b[k] + elif self.strict: + msg = f"Parameter {k!r} is not allowed in configuration file." + raise ValueError(msg) + return a
+ +
[docs] def merge_default_map(self, ctx: Context, user_conf: dict) -> None: + """Save the user configuration into the context's ``default_map``. + + Merge the user configuration into the pre-computed template structure, which + will filter out all unrecognized options not supported by the command. Then + cleans up blank values and update the context's ``default_map``. + """ + filtered_conf = self.recursive_update(self.params_template, user_conf) + + def visit(path, key, value): + """Skip `None` values and empty `dict`.""" + if value is None: + return False + if isinstance(value, dict) and not len(value): + return False + return True + + # Clean-up the conf by removing all blank values left-over by the template + # structure. + clean_conf = remap(filtered_conf, visit=visit) + + # Update the default_map. + if ctx.default_map is None: + ctx.default_map = {} + ctx.default_map.update(clean_conf.get(ctx.find_root().command.name, {}))
+ +
[docs] def load_conf(self, ctx: Context, param: Parameter, path_pattern: str) -> None: + """Fetch parameters values from configuration file and sets them as defaults. + + User configuration is merged to the `context's default_map + <https://click.palletsprojects.com/en/8.1.x/commands/#overriding-defaults>`_, + `like Click does <https://click.palletsprojects.com/en/8.1.x/commands/#context-defaults>`_. + + By relying on Click's default_map, we make sure that precedence is respected. + And direct CLI parameters, environment variables or interactive prompts takes + precedence over any values from the config file. + """ + logger = logging.getLogger("click_extra") + + explicit_conf = ctx.get_parameter_source("config") in ( + ParameterSource.COMMANDLINE, + ParameterSource.ENVIRONMENT, + ParameterSource.PROMPT, + ) + + message = f"Load configuration matching {path_pattern}" + # Force printing of configuration location if the user explicitly set it. + if explicit_conf: + # We have can't simply use logger.info() here as the defaults have not been + # loaded yet and the logger is stuck to its default WARNING level. + echo(message, err=True) + else: + logger.debug(message) + + # Read configuration file. + conf_path, user_conf = self.read_and_parse_conf(path_pattern) + ctx.meta["click_extra.conf_source"] = conf_path + ctx.meta["click_extra.conf_full"] = user_conf + + # Exit the CLI if no user-provided config file was found. + if user_conf is None: + message = "No configuration file found." + if explicit_conf: + logger.critical(message) + ctx.exit(2) + else: + logger.debug(message) + + else: + logger.debug(f"Parsed user configuration: {user_conf}") + logger.debug(f"Initial defaults: {ctx.default_map}") + self.merge_default_map(ctx, user_conf) + logger.debug(f"New defaults: {ctx.default_map}")
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/decorators.html b/_modules/click_extra/decorators.html new file mode 100644 index 000000000..eaec97879 --- /dev/null +++ b/_modules/click_extra/decorators.html @@ -0,0 +1,437 @@ + + + + + + + + click_extra.decorators - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

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 functools import wraps
+
+import cloup
+
+from .colorize import ColorOption, HelpOption
+from .commands import ExtraCommand, ExtraGroup, default_extra_params
+from .config import ConfigOption
+from .logging import VerbosityOption
+from .parameters import ShowParamsOption
+from .tabulate import TableFormatOption
+from .telemetry import TelemetryOption
+from .timer import TimerOption
+from .version import ExtraVersionOption
+
+
+
[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
+ + +
[docs]def decorator_factory(dec, **new_defaults): + """Clone decorator with a set of new defaults. + + Used to create our own collection of decorators for our custom options, based on + Cloup's. + """ + + @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. + """ + # Use a copy of the defaults to avoid modifying the original dict. + new_kwargs = new_defaults.copy() + new_kwargs.update(kwargs) + + # If the params argument is a callable, we need to call it to get the actual + # list of options. + params_func = new_kwargs.get("params") + if callable(params_func): + new_kwargs["params"] = params_func() + + # Return the original decorator with the new defaults. + return dec(*args, **new_kwargs) + + return decorator
+ + +# Redefine cloup decorators to allow them to be used with or without parenthesis. +command = decorator_factory(dec=cloup.command) +group = decorator_factory(dec=cloup.group) + +# Extra-prefixed decorators augments the default Cloup and Click ones. +extra_command = decorator_factory( + dec=cloup.command, + cls=ExtraCommand, + params=default_extra_params, +) +extra_group = decorator_factory( + dec=cloup.group, + cls=ExtraGroup, + params=default_extra_params, +) +extra_version_option = decorator_factory(dec=cloup.option, cls=ExtraVersionOption) + +# New option decorators provided by Click Extra. +color_option = decorator_factory(dec=cloup.option, cls=ColorOption) +config_option = decorator_factory(dec=cloup.option, cls=ConfigOption) +help_option = decorator_factory(dec=cloup.option, cls=HelpOption) +show_params_option = decorator_factory(dec=cloup.option, cls=ShowParamsOption) +table_format_option = decorator_factory(dec=cloup.option, cls=TableFormatOption) +telemetry_option = decorator_factory(dec=cloup.option, cls=TelemetryOption) +timer_option = decorator_factory(dec=cloup.option, cls=TimerOption) +verbosity_option = decorator_factory(dec=cloup.option, cls=VerbosityOption) +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/docs_update.html b/_modules/click_extra/docs_update.html new file mode 100644 index 000000000..cc236d4b7 --- /dev/null +++ b/_modules/click_extra/docs_update.html @@ -0,0 +1,514 @@ + + + + + + + + click_extra.docs_update - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.docs_update

+# 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.
+"""Automation to keep click-extra documentation up-to-date.
+
+.. tip::
+
+    When the module is called directly, it will update all documentation files in-place:
+
+    .. code-block:: shell-session
+
+        $ run python -m click_extra.docs_update
+
+    See how it is `used in .github/workflows/docs.yaml workflow
+    <https://github.com/kdeldycke/click-extra/blob/a978bd0/.github/workflows/docs.yaml#L35-L37>`_.
+"""
+
+from __future__ import annotations
+
+import html
+import sys
+from pathlib import Path
+from textwrap import indent
+
+from .platforms import ALL_GROUPS, EXTRA_GROUPS, NON_OVERLAPPING_GROUPS, Group
+from .pygments import lexer_map
+from .tabulate import tabulate
+
+
+
[docs]def replace_content( + filepath: Path, + start_tag: str, + end_tag: str, + new_content: str, +) -> None: + """Replace in the provided file the content surrounded by the provided tags.""" + filepath = filepath.resolve() + assert filepath.exists(), f"File {filepath} does not exist." + assert filepath.is_file(), f"File {filepath} is not a file." + + orig_content = filepath.read_text() + + # Extract pre- and post-content surrounding the tags. + pre_content, table_start = orig_content.split(start_tag, 1) + _, post_content = table_start.split(end_tag, 1) + + # Reconstruct the content with our updated table. + filepath.write_text( + f"{pre_content}{start_tag}{new_content}{end_tag}{post_content}", + )
+ + +
[docs]def generate_lexer_table() -> str: + """Generate a Markdown table mapping original Pygments' lexers to their new ANSI + variants implemented by Click Extra.""" + table = [] + for orig_lexer, ansi_lexer in sorted( + lexer_map.items(), + key=lambda i: i[0].__qualname__, + ): + table.append( + [ + f"[`{orig_lexer.__qualname__}`](https://pygments.org/docs/lexers/#" + f"{orig_lexer.__module__}.{orig_lexer.__qualname__})", + f"{', '.join(f'`{a}`' for a in sorted(orig_lexer.aliases))}", + f"{', '.join(f'`{a}`' for a in sorted(ansi_lexer.aliases))}", + ], + ) + return tabulate.tabulate( + table, + headers=[ + "Original Lexer", + "Original IDs", + "ANSI variants", + ], + tablefmt="github", + colalign=["left", "left", "left"], + disable_numparse=True, + )
+ + +
[docs]def generate_platforms_graph( + graph_id: str, + description: str, + groups: frozenset[Group], +) -> str: + """Generates an `Euler diagram <https://xkcd.com/2721/>`_ of platform and their + grouping. + + Euler diagrams are + `not supported by mermaid yet <https://github.com/mermaid-js/mermaid/issues/2583>`_ + so we fallback on a flowchart without arrows. + + Returns a ready to use and properly indented MyST block. + """ + INDENT = " " * 4 + subgraphs = set() + + # Create one subgraph per group. + for group in sorted(groups, key=lambda g: g.id): + nodes = set() + for platform in group: + # Make the node ID unique for overlapping groups. + nodes.add( + f"{group.id}_{platform.id}" + f"(<code>{platform.id}</code><br/><em>{html.escape(platform.name)}</em>)", + ) + subgraphs.add( + f"subgraph <code>click_extra.platforms.{group.id.upper()}</code>" + "<br/>" + f"<em>{group.name}</em>" + "\n" + indent("\n".join(sorted(nodes)), INDENT) + "\nend", + ) + + # Wrap the Mermaid code into a MyST block. + return "\n".join( + ( + # Use attributes blocks extension to add a title. + f'{{caption="`click_extra.platforms.{graph_id}` - {description}"}}', + "```mermaid", + ":zoom:", + "flowchart", + indent("\n".join(sorted(subgraphs)), INDENT), + "```", + ), + )
+ + +
[docs]def update_docs() -> None: + """Update documentation with dynamic content.""" + project_root = Path(__file__).parent.parent + + # Update the lexer table in Sphinx's documentation. + replace_content( + project_root.joinpath("docs/pygments.md"), + "<!-- lexer-table-start -->\n\n", + "\n\n<!-- lexer-table-end -->", + generate_lexer_table(), + ) + + # TODO: Replace this hard-coded dict by allowing Group dataclass to group + # other groups. + all_groups = ( + { + "id": "NON_OVERLAPPING_GROUPS", + "description": "Non-overlapping groups.", + "groups": NON_OVERLAPPING_GROUPS, + }, + { + "id": "EXTRA_GROUPS", + "description": "Overlapping groups, defined for convenience.", + "groups": EXTRA_GROUPS, + }, + ) + assert frozenset(g for groups in all_groups for g in groups["groups"]) == ALL_GROUPS + + # Update the platform diagram in Sphinx's documentation. + platform_doc = project_root.joinpath("docs/platforms.md") + for top_groups in all_groups: + replace_content( + platform_doc, + f"<!-- {top_groups['id']}-graph-start -->\n\n", + f"\n\n<!-- {top_groups['id']}-graph-end -->", + generate_platforms_graph( + top_groups["id"], # type: ignore[arg-type] + top_groups["description"], # type: ignore[arg-type] + top_groups["groups"], # type: ignore[arg-type] + ), + )
+ + +if __name__ == "__main__": + sys.exit(update_docs()) # type: ignore[func-returns-value] +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/logging.html b/_modules/click_extra/logging.html new file mode 100644 index 000000000..3ff668139 --- /dev/null +++ b/_modules/click_extra/logging.html @@ -0,0 +1,652 @@ + + + + + + + + click_extra.logging - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.logging

+# 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.
+"""Logging utilities."""
+
+from __future__ import annotations
+
+import logging
+import sys
+from gettext import gettext as _
+from logging import (
+    WARNING,
+    Formatter,
+    Handler,
+    Logger,
+    LogRecord,
+    _levelToName,
+)
+from typing import TYPE_CHECKING, Literal, TypeVar
+
+import click
+
+from . import Choice, Context, Parameter
+from .colorize import default_theme
+from .parameters import ExtraOption
+
+if TYPE_CHECKING:
+    from collections.abc import Generator, Iterable, Sequence
+
+_original_get_logger = logging.getLogger
+
+
+def _patched_get_logger(name: str | None = None) -> Logger:
+    """Patch ``logging.getLogger`` to return the right root logger object.
+
+    .. warning::
+        This is a bugfix for Python 3.8 and earlier for which the ``root`` logger
+        cannot be fetched with its plain ``root`` name.
+
+        See:
+        - `cpython#81923 <https://github.com/python/cpython/issues/81923>`_
+        - `cpython@cb65b3a <https://github.com/python/cpython/commit/cb65b3a>`_
+    """
+    if name == "root":
+        name = None
+    return _original_get_logger(name)
+
+
+if sys.version_info < (3, 9):
+    logging.getLogger = _patched_get_logger
+
+
+LOG_LEVELS: dict[str, int] = {
+    name: value
+    for value, name in sorted(_levelToName.items(), reverse=True)
+    if name != "NOTSET"
+}
+"""Mapping of canonical log level names to their IDs.
+
+Sorted from lowest to highest verbosity.
+
+Are ignored:
+
+- ``NOTSET``, which is considered internal
+- ``WARN``, which `is obsolete
+  <https://docs.python.org/3/library/logging.html?highlight=warn#logging.Logger.warning>`_
+- ``FATAL``, which `shouldn't be used <https://github.com/python/cpython/issues/85013>`_
+  and `replaced by CRITICAL
+  <https://github.com/python/cpython/blob/0df7c3a/Lib/logging/__init__.py#L1538-L1541>`_
+"""
+
+
+DEFAULT_LEVEL: int = WARNING
+DEFAULT_LEVEL_NAME: str = _levelToName[DEFAULT_LEVEL]
+"""``WARNING`` is the default level we expect any loggers to starts their lives at.
+
+``WARNING`` has been chosen as it is `the level at which the default Python's global
+root logger is set up
+<https://github.com/python/cpython/blob/0df7c3a/Lib/logging/__init__.py#L1945>`_.
+
+This value is also used as the default level of the ``--verbosity`` option below.
+"""
+
+
+TFormatter = TypeVar("TFormatter", bound=Formatter)
+THandler = TypeVar("THandler", bound=Handler)
+"""Custom types to be used in type hints below."""
+
+
+
[docs]class ExtraLogHandler(Handler): + """A handler to output logs to console's ``<stderr>``.""" + +
[docs] def emit(self, record: LogRecord) -> None: + """Use ``click.echo`` to print to ``<stderr>`` and supports colors.""" + try: + msg = self.format(record) + click.echo(msg, err=True) + + # If exception occurs format it to the stream. + except Exception: + self.handleError(record)
+ + +
[docs]class ExtraLogFormatter(Formatter): +
[docs] def formatMessage(self, record: LogRecord) -> str: + """Colorize the record's log level name before calling the strandard + formatter.""" + level = record.levelname.lower() + level_style = getattr(default_theme, level, None) + if level_style: + record.levelname = level_style(level) + return super().formatMessage(record)
+ + +
[docs]def extra_basic_config( + logger_name: str | None = None, + format: str | None = "{levelname}: {message}", + datefmt: str | None = None, + style: Literal["%", "{", "$"] = "{", + level: int | None = None, + handlers: Iterable[Handler] | None = None, + force: bool = True, + handler_class: type[THandler] = ExtraLogHandler, # type: ignore[assignment] + formatter_class: type[TFormatter] = ExtraLogFormatter, # type: ignore[assignment] +) -> Logger: + """Setup and configure a logger. + + Reimplements `logging.basicConfig + <https://docs.python.org/3/library/logging.html?highlight=basicconfig#logging.basicConfig>`_, + but with sane defaults and more parameters. + + :param logger_name: ID of the logger to setup. If ``None``, Python's ``root`` + logger will be used. + :param format: Use the specified format string for the handler. + Defaults to ``levelname`` and ``message`` separated by a colon. + :param datefmt: Use the specified date/time format, as accepted by + :func:`time.strftime`. + :param style: If *format* is specified, use this style for the format string. One + of ``%``, ``{`` or ``$`` for :ref:`printf-style <old-string-formatting>`, + :meth:`str.format` or :class:`string.Template` respectively. Defaults to ``{``. + :param level: Set the logger level to the specified :ref:`level <levels>`. + :param handlers: A list of ``logging.Handler`` instances to attach to the logger. + If not provided, a new handler of the class set by the ``handler_class`` + parameter will be created. Any handler in the list which does not have a + formatter assigned will be assigned the formatter created in this function. + :param force: Remove and close any existing handlers attached to the logger + before carrying out the configuration as specified by the other arguments. + Default to ``True`` so we always starts from a clean state each time we + configure a logger. This is a life-saver in unittests in which loggers pollutes + output. + :param handler_class: Handler class to be used to create a new handler if none + provided. Defaults to :py:class:`ExtraLogHandler`. + :param formatter_class: Class of the formatter that will be setup on each handler + if none found. Defaults to :py:class:`ExtraLogFormatter`. + + .. todo:: + Add more parameters for even greater configurability of the logger, by + re-implementing those supported by ``logging.basicConfig``. + """ + # Fetch the logger or create a new one. + logger = logging.getLogger(logger_name) + + # Remove and close any existing handlers. Copy of: + # https://github.com/python/cpython/blob/2b5dbd1/Lib/logging/__init__.py#L2028-L2031 + if force: + for h in logger.handlers[:]: + logger.removeHandler(h) + h.close() + + # If no handlers provided, create a new one with the default handler class. + if not handlers: + handlers = (handler_class(),) + + # Set up the formatter with a default message format. + formatter = formatter_class( + fmt=format, + datefmt=datefmt, + style=style, + ) + + # Attach handlers to the loggers. + for h in handlers: + if h.formatter is None: + h.setFormatter(formatter) + logger.addHandler(h) + + if level is not None: + logger.setLevel(level) + + return logger
+ + +
[docs]class VerbosityOption(ExtraOption): + """A pre-configured ``--verbosity``/``-v`` option. + + Sets the level of the provided logger. + + The selected verbosity level name is made available in the context in + ``ctx.meta["click_extra.verbosity"]``. + + .. important:: + + The internal ``click_extra`` logger level will be aligned to the value set via + this option. + """ + + logger_name: str + """The ID of the logger to set the level to. + + This will be provided to + `logging.getLogger <https://docs.python.org/3/library/logging.html?highlight=getlogger#logging.getLogger>`_ + method to fetch the logger object, and as such, can be a dot-separated string to + build hierarchical loggers. + """ + + @property + def all_loggers(self) -> Generator[Logger, None, None]: + """Returns the list of logger IDs affected by the verbosity option. + + Will returns Click Extra's internal logger first, then the option's custom + logger. + """ + for name in ("click_extra", self.logger_name): + yield logging.getLogger(name) + +
[docs] def reset_loggers(self) -> None: + """Forces all loggers managed by the option to be reset to the default level. + + Reset loggers in reverse order to ensure the internal logger is reset last. + + .. danger:: + Resseting loggers is extremely important for unittests. Because they're + global, loggers have tendency to leak and pollute their state between + multiple test calls. + """ + for logger in list(self.all_loggers)[::-1]: + logging.getLogger("click_extra").debug( + f"Reset {logger} to {DEFAULT_LEVEL_NAME}.", + ) + logger.setLevel(DEFAULT_LEVEL)
+ +
[docs] def set_levels(self, ctx: Context, param: Parameter, value: str) -> None: + """Set level of all loggers configured on the option. + + Save the verbosity level name in the context. + + Also prints the chosen value as a debug message via the internal + ``click_extra`` logger. + """ + ctx.meta["click_extra.verbosity"] = value + + for logger in self.all_loggers: + logger.setLevel(LOG_LEVELS[value]) + logging.getLogger("click_extra").debug(f"Set {logger} to {value}.") + + ctx.call_on_close(self.reset_loggers)
+ + def __init__( + self, + param_decls: Sequence[str] | None = None, + default_logger: Logger | str | None = None, + default: str = DEFAULT_LEVEL_NAME, + metavar="LEVEL", + type=Choice(LOG_LEVELS, case_sensitive=False), # type: ignore[arg-type] + expose_value=False, + help=_("Either {log_levels}.").format(log_levels=", ".join(LOG_LEVELS)), + is_eager=True, + **kwargs, + ) -> None: + """Set up the verbosity option. + + :param default_logger: If an instance of ``logging.Logger`` is provided, that's + the instance to which we will set the level set via the option. If the + parameter is a string, we will fetch it with `logging.getLogger + <https://docs.python.org/3/library/logging.html?highlight=getlogger#logging.getLogger>`_. + If not provided or ``None``, the `default Python root logger + <https://github.com/python/cpython/blob/2b5dbd1/Lib/logging/__init__.py#L1945>`_ + is used. + + .. todo:: + Write more documentation to detail in which case the user is responsible + for setting up the logger, and when ``extra_basic_config`` is used. + """ + if not param_decls: + param_decls = ("--verbosity", "-v") + + # Use the provided logger instance as-is. + if isinstance(default_logger, Logger): + logger = default_logger + # If a string is provided, use it as the logger name. + elif isinstance(default_logger, str): + logger = logging.getLogger(default_logger) + # ``None`` will produce a default root logger. + else: + logger = extra_basic_config(default_logger) + + # Store the logger name for later use. + self.logger_name = logger.name + + kwargs.setdefault("callback", self.set_levels) + + super().__init__( + param_decls=param_decls, + default=default, + metavar=metavar, + type=type, + expose_value=expose_value, + help=help, + is_eager=is_eager, + **kwargs, + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/parameters.html b/_modules/click_extra/parameters.html new file mode 100644 index 000000000..8760900c5 --- /dev/null +++ b/_modules/click_extra/parameters.html @@ -0,0 +1,1074 @@ + + + + + + + + click_extra.parameters - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.parameters

+# 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``.
+
+Also implements environment variable utilities.
+"""
+
+from __future__ import annotations
+
+import inspect
+import logging
+import re
+from collections.abc import Iterable, MutableMapping, Sequence
+from contextlib import nullcontext
+from functools import cached_property, reduce
+from gettext import gettext as _
+from operator import getitem, methodcaller
+from typing import (
+    Any,
+    Callable,
+    ContextManager,
+    Iterator,
+    cast,
+)
+from unittest.mock import patch
+
+import click
+from boltons.iterutils import unique
+from mergedeep import merge
+from tabulate import tabulate
+
+from . import (
+    Command,
+    Option,
+    Parameter,
+    ParamType,
+    Style,
+    echo,
+    get_current_context,
+)
+
+
+
[docs]def auto_envvar( + param: click.Parameter, + ctx: click.Context | dict[str, Any], +) -> str | None: + """Compute the auto-generated environment variable of an option or argument. + + Returns the auto envvar as it is exactly computed within Click's internals, i.e. + ``click.core.Parameter.resolve_envvar_value()`` and + ``click.core.Option.resolve_envvar_value()``. + """ + # Skip parameters that have their auto-envvar explicitly disabled. + if not getattr(param, "allow_from_autoenv", None): + return None + + if isinstance(ctx, click.Context): + prefix = ctx.auto_envvar_prefix + else: + prefix = ctx.get("auto_envvar_prefix") + if not prefix or not param.name: + return None + + # Mimics Click's internals. + return f"{prefix}_{param.name.upper()}"
+ + +
[docs]def extend_envvars( + envvars_1: str | Sequence[str] | None, + envvars_2: str | Sequence[str] | None, +) -> tuple[str, ...]: + """Utility to build environment variables value to be fed to options. + + Variable names are deduplicated while preserving their initial order. + + Returns a tuple of environment variable strings. The result is ready to be used as + the ``envvar`` parameter for options or arguments. + """ + # Make the fist argument into a list of string. + envvars = [] + if envvars_1: + envvars = [envvars_1] if isinstance(envvars_1, str) else list(envvars_1) + + # Merge the second argument into the list. + if envvars_2: + if isinstance(envvars_2, str): + envvars.append(envvars_2) + else: + envvars.extend(envvars_2) + + # Deduplicate the list and cast it into an immutable tuple. + return tuple(unique(envvars))
+ + +
[docs]def normalize_envvar(envvar: str) -> str: + """Utility to normalize an environment variable name. + + The normalization process separates all contiguous alphanumeric string segments, + eliminate empty strings, join them with an underscore and uppercase the result. + """ + return "_".join(p for p in re.split(r"[^a-zA-Z0-9]+", envvar) if p).upper()
+ + +
[docs]def all_envvars( + param: click.Parameter, + ctx: click.Context | dict[str, Any], + normalize: bool = False, +) -> tuple[str, ...]: + """Returns the deduplicated, ordered list of environment variables for an option or + argument, including the auto-generated one. + + The auto-generated environment variable is added at the end of the list, so that + user-defined envvars takes precedence. This respects the current implementation + of ``click.core.Option.resolve_envvar_value()``. + + If ``normalize`` is `True`, the returned value is normalized. By default it is + `False` to perfectly reproduce the `current behavior of Click, which is subject to + discussions <https://github.com/pallets/click/issues/2483>`_. + """ + auto_envvar_id = auto_envvar(param, ctx) + envvars = extend_envvars(param.envvar, auto_envvar_id) + + if normalize: + envvars = tuple(normalize_envvar(var) for var in envvars) + + return envvars
+ + +
[docs]def search_params( + params: Iterable[click.Parameter], + klass: type[click.Parameter], + 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 unique: if ``True``, raise an error if more than one parameter of the + provided ``klass`` is found. + """ + param_list = [p for p in params if isinstance(p, klass)] + if not param_list: + return None + if unique: + if len(param_list) != 1: + msg = ( + f"More than one {klass.__name__} parameters found on command: " + f"{param_list}" + ) + raise RuntimeError(msg) + return param_list.pop() + return param_list
+ + +
[docs]class ExtraOption(Option): + """All new options implemented by ``click-extra`` inherits this class. + + Does nothing in particular for now but provides a way to identify Click Extra's own + options with certainty. + + Also contains Option-specific code that should be contributed upstream to Click. + """ + +
[docs] @staticmethod + def get_help_default(option: click.Option, ctx: click.Context) -> str | None: + """Produce the string to be displayed in the help as option's default. + + .. caution:: + This is a `copy of Click's default value rendering of the default + <https://github.com/pallets/click/blob/b0538df/src/click/core.py#L2754-L2792>`_ + + This code **should be keep in sync with Click's implementation**. + + .. attention:: + This doesn't work with our own ``--config`` option because we are also + monkey-patching `ConfigOption.get_help_record() + <https://kdeldycke.github.io/click-extra/config.html#click_extra.config.ConfigOption.get_help_record>`_ + to display the dynamic default value. + + So the results of this method call is: + + .. code-block:: text + + <bound method ConfigOption.default_pattern of <ConfigOption config>> + + instead of the expected: + + .. code-block:: text + + ~/(...)/multiple_envvars.py/*.{toml,yaml,yml,json,ini,xml} + + .. todo:: + A better solution has been proposed upstream to Click: + - https://github.com/pallets/click/issues/2516 + - https://github.com/pallets/click/pull/2517 + """ + # Temporarily enable resilient parsing to avoid type casting + # failing for the default. Might be possible to extend this to + # help formatting in general. + resilient = ctx.resilient_parsing + ctx.resilient_parsing = True + + try: + default_value = option.get_default(ctx, call=False) + finally: + ctx.resilient_parsing = resilient + + show_default = False + show_default_is_str = False + + if option.show_default is not None: + if isinstance(option.show_default, str): + show_default_is_str = show_default = True + else: + show_default = option.show_default + elif ctx.show_default is not None: + show_default = ctx.show_default + + if show_default_is_str or (show_default and (default_value is not None)): + if show_default_is_str: + default_string = f"({option.show_default})" + elif isinstance(default_value, (list, tuple)): + default_string = ", ".join(str(d) for d in default_value) + elif inspect.isfunction(default_value): + default_string = _("(dynamic)") + elif option.is_bool_flag and option.secondary_opts: + # For boolean flags that have distinct True/False opts, + # use the opt without prefix instead of the value. + default_string = click.parser.split_opt( + (option.opts if option.default else option.secondary_opts)[0], + )[1] + elif ( + option.is_bool_flag and not option.secondary_opts and not default_value + ): + default_string = "" + else: + default_string = str(default_value) + + if default_string: + return default_string + + return None
+ + +
[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 ``.``. + + .. todo:: + Evaluates the possibility of replacing all key-based access to the tree-like + structure by a `Box <https://github.com/cdgriffith/Box>`_ object, as it + provides lots of utilities to merge its content. + """ + + SEP: str = "." + """Use a dot ``.`` as a separator between levels of the tree-like parameter + structure.""" + + DEFAULT_EXCLUDED_PARAMS: Iterable[str] = ( + "config", + "help", + "show_params", + "version", + ) + """List of root parameters to exclude from configuration by default: + + - ``-C``/``--config`` option, which cannot be used to recursively load another + configuration file. + - ``--help``, as it makes no sense to have the configurable file always + forces a CLI to show the help and exit. + - ``--show-params`` flag, which is like ``--help`` and stops the CLI execution. + - ``--version``, which is not a configurable option *per-se*. + """ + + def __init__( + self, + *args, + excluded_params: Iterable[str] | None = None, + **kwargs, + ) -> None: + """Allow a list of paramerers to be blocked from the parameter structure. + + If ``excluded_params`` is not provided, let the dynamic and cached + ``self.excluded_params`` property to compute the default value on first use. + """ + if excluded_params is not None: + self.excluded_params = excluded_params + + super().__init__(*args, **kwargs) + +
[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 | None: + """Get in the ``tree_dict`` the value located at the ``path``.""" + try: + return reduce(getitem, path, tree_dict) + except KeyError: + return None
+ + def _flatten_tree_dict_gen( + self, tree_dict: MutableMapping, parent_key: str | None = None + ) -> Iterable[tuple[str, Any]]: + """`Source of this snippet + <https://www.freecodecamp.org/news/how-to-flatten-a-dictionary-in-python-in-4-different-ways/>`_. + """ + for k, v in tree_dict.items(): + new_key = f"{parent_key}{self.SEP}{k}" if parent_key else k + if isinstance(v, MutableMapping): + yield from self.flatten_tree_dict(v, new_key).items() + else: + yield new_key, v + +
[docs] def flatten_tree_dict( + self, + tree_dict: MutableMapping, + parent_key: str | None = None, + ) -> dict[str, Any]: + """Recursively traverse the tree-like ``dict`` and produce a flat ``dict`` whose + keys are path and values are the leaf's content.""" + return dict(self._flatten_tree_dict_gen(tree_dict, parent_key))
+ + def _recurse_cmd( + self, + cmd: Command, + top_level_params: Iterable[str], + parent_keys: tuple[str, ...], + ) -> Iterator[tuple[tuple[str, ...], Parameter]]: + """Recursive generator to walk through all subcommands and their parameters.""" + if hasattr(cmd, "commands"): + for subcmd_id, subcmd in cmd.commands.items(): + if subcmd_id in top_level_params: + msg = ( + f"{cmd.name}{self.SEP}{subcmd_id} subcommand conflicts with " + f"{top_level_params} top-level parameters" + ) + raise ValueError(msg) + + for p in subcmd.params: + yield ((*parent_keys, subcmd_id, p.name)), p + + yield from self._recurse_cmd( + subcmd, + top_level_params, + ((*parent_keys, subcmd.name)), + ) + +
[docs] def walk_params(self) -> Iterator[tuple[tuple[str, ...], Parameter]]: + """Generates an unfiltered list of all CLI parameters. + + Everything is included, from top-level groups to subcommands, and from options + to arguments. + + Returns a 2-elements tuple: + - the first being a tuple of keys leading to the parameter + - the second being the parameter object itself + """ + ctx = get_current_context() + cli = ctx.find_root().command + assert cli.name is not None + + # Keep track of top-level CLI parameter IDs to check conflict with command + # IDs later. + top_level_params = set() + + # Global, top-level options shared by all subcommands. + for p in cli.params: + assert p.name is not None + top_level_params.add(p.name) + yield (cli.name, p.name), p + + # Subcommand-specific options. + yield from self._recurse_cmd(cli, top_level_params, (cli.name,))
+ + TYPE_MAP: 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] def get_param_type(self, param: Parameter) -> type[str | int | float | bool | list]: + """Get the Python type of a Click parameter. + + See the list of + `custom types provided by Click <https://click.palletsprojects.com/en/8.1.x/api/#types>`_. + """ + if param.multiple or param.nargs != 1: + return list + + if hasattr(param, "is_bool_flag") and param.is_bool_flag: + return bool + + # Try to directly map the Click type to a Python type. + py_type = self.TYPE_MAP.get(param.type.__class__) + if py_type is not None: + return py_type + + # Try to indirectly map the type by looking at inheritance. + for click_type, py_type in self.TYPE_MAP.items(): + matching = set() + if isinstance(param.type, click_type): + matching.add(py_type) + if matching: + if len(matching) > 1: + msg = ( + f"Multiple Python types found for {param.type!r} parameter: " + f"{matching}" + ) + raise ValueError(msg) + return matching.pop() + + # Custom parameters are expected to convert from strings, as that's the default + # type of command lines. + # See: https://click.palletsprojects.com/en/8.1.x/api/#click.ParamType + if isinstance(param.type, ParamType): + return str + + msg = f"Can't guess the appropriate Python type of {param!r} parameter." # type:ignore[unreachable] + raise ValueError(msg)
+ + @cached_property + def excluded_params(self) -> Iterable[str]: + """List of parameter IDs to exclude from the parameter structure. + + Elements of this list are expected to be the fully-qualified ID of the + parameter, i.e. the dot-separated ID that is prefixed by the CLI name. + + .. caution:: + It is only called once to produce the list of default parameters to + exclude, if the user did not provided its own list to the constructor. + + It was not implemented in the constructor but made as a property, to allow + for a just-in-time call to the current context. Without this trick we could + not have fetched the CLI name. + """ + ctx = get_current_context() + cli = ctx.find_root().command + return [f"{cli.name}{self.SEP}{p}" for p in self.DEFAULT_EXCLUDED_PARAMS] + +
[docs] def build_param_trees(self) -> None: + """Build all parameters tree structure in one go and cache them. + + This removes parameters whose fully-qualified IDs are in the ``excluded_params`` + blocklist. + """ + template: dict[str, Any] = {} + types: dict[str, Any] = {} + objects: dict[str, Any] = {} + + for keys, param in self.walk_params(): + if self.SEP.join(keys) in self.excluded_params: + continue + merge(template, self.init_tree_dict(*keys)) + merge(types, self.init_tree_dict(*keys, leaf=self.get_param_type(param))) + merge(objects, self.init_tree_dict(*keys, leaf=param)) + + self.params_template = template + self.params_types = types + self.params_objects = objects
+ + @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. + """ + self.build_param_trees() + return self.params_template + + @cached_property + def params_types(self) -> dict[str, Any]: + """Returns a tree-like dictionary whose keys shadows the CLI options and + subcommands and values are their expected Python type. + + Perfect to parse configuration files and user-provided parameters. + """ + self.build_param_trees() + return self.params_types + + @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. + """ + self.build_param_trees() + return self.params_objects
+ + +
[docs]class ShowParamsOption(ExtraOption, ParamStructure): + """A pre-configured option adding a ``--show-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. + """ + + TABLE_HEADERS = ( + "ID", + "Class", + "Spec.", + "Param type", + "Python type", + "Hidden", + "Exposed", + "Allowed in conf?", + "Env. vars.", + "Default", + "Value", + "Source", + ) + """Hard-coded list of 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 = ("--show-params",) + + kwargs.setdefault("callback", self.print_params) + + self.excluded_params = () + """Deactivates the blocking of any parameter.""" + + 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: + """Introspects current CLI and list its parameters and metadata. + + .. important:: + Click doesn't keep a list of all parsed arguments and their origin. + So we need to emulate here what's happening during CLI invocation. + + Unfortunately we cannot even do that because the raw, pre-parsed arguments + are not available anywhere within Click's internals. + + Our workaround consist in leveraging our custom + ``ExtraCommand``/``ExtraGroup`` classes, in which we are attaching + a ``click_extra.raw_args`` metadata entry to the context. + """ + # Imported here to avoid circular imports. + from .colorize import KO, OK, default_theme + from .config import ConfigOption + + # Exit early if the callback was processed but the option wasn't set. + if not value: + return + + logger = logging.getLogger("click_extra") + + get_param_value: Callable[[Any], Any] + + if "click_extra.raw_args" in ctx.meta: + raw_args = ctx.meta.get("click_extra.raw_args", []) + logger.debug(f"click_extra.raw_args: {raw_args}") + + # Mimics click.core.Command.parse_args() so we can produce the list of + # parsed options values. + parser = ctx.command.make_parser(ctx) + opts, _, _ = parser.parse_args(args=raw_args) + + # We call directly consume_value() instead of handle_parse_result() to + # prevent an embedded call to process_value(), as the later triggers the + # callback (and might terminate CLI execution). + param_value, source = param.consume_value(ctx, opts) + + get_param_value = methodcaller("consume_value", ctx, opts) + + else: + logger.debug(f"click_extra.raw_args not in {ctx.meta}") + logger.warning( + f"Cannot extract parameters values: " + f"{ctx.command} does not inherits from ExtraCommand.", + ) + + def vanilla_getter(p): + param_value = None + source = ctx.get_parameter_source(p.name) + return param_value, source + + get_param_value = vanilla_getter + + # Inspect the CLI to search for any --config option. + config_option = cast( + "ConfigOption", + search_params(ctx.command.params, ConfigOption), + ) + + table: list[ + tuple[ + str | None, + str | None, + str | None, + str | None, + str | None, + str | None, + str | None, + str | None, + str | None, + str | None, + str | None, + str | None, + ] + ] = [] + for path, python_type in self.flatten_tree_dict(self.params_types).items(): + # Get the parameter instance. + tree_keys = path.split(self.SEP) + instance = cast( + "click.Parameter", + self.get_tree_value(self.params_objects, *tree_keys), + ) + assert instance.name == tree_keys[-1] + + param_value, source = get_param_value(instance) + param_class = instance.__class__ + + # Collect param's spec and hidden status. + hidden = None + param_spec = None + # Hidden property is only supported by Option, not Argument. + # TODO: Allow arguments to produce their spec. + if hasattr(instance, "hidden"): + hidden = OK if instance.hidden is True else KO + + # No-op context manager without any effects. + hidden_param_bypass: ContextManager = nullcontext() + # If the parameter is hidden, we need to temporarily disable this flag + # to let Click produce a help record. + # See: https://github.com/kdeldycke/click-extra/issues/689 + # 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. + if instance.hidden: + hidden_param_bypass = patch.object(instance, "hidden", False) + with hidden_param_bypass: + help_record = instance.get_help_record(ctx) + if help_record: + param_spec = help_record[0] + + # Check if the parameter is allowed in the configuration file. + allowed_in_conf = None + if config_option: + allowed_in_conf = KO if path in config_option.excluded_params else OK + + line = ( + default_theme.invoked_command(path), + f"{param_class.__module__}.{param_class.__qualname__}", + param_spec, + f"{instance.type.__module__}.{instance.type.__class__.__name__}", + python_type.__name__, + hidden, + OK if instance.expose_value is True else KO, + allowed_in_conf, + ", ".join(map(default_theme.envvar, all_envvars(instance, ctx))), + default_theme.default(str(instance.get_default(ctx))), + param_value, + source._name_ if source else None, + ) + table.append(line) + + def sort_by_depth(line): + """Sort parameters by depth first, then IDs, so that top-level parameters + are kept to the top.""" + param_path = line[0] + tree_keys = param_path.split(self.SEP) + return len(tree_keys), param_path + + header_style = Style(bold=True) + header_labels = tuple(map(header_style, self.TABLE_HEADERS)) + + output = tabulate( + sorted(table, key=sort_by_depth), + headers=header_labels, + tablefmt="rounded_outline", + disable_numparse=True, + ) + echo(output, color=ctx.color) + + ctx.exit()
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/platforms.html b/_modules/click_extra/platforms.html new file mode 100644 index 000000000..9bcb70e5e --- /dev/null +++ b/_modules/click_extra/platforms.html @@ -0,0 +1,932 @@ + + + + + + + + click_extra.platforms - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.platforms

+# 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.
+"""Helpers and utilities to identify platforms.
+
+Everything here can be aggressively cached and frozen, as it's only compute
+platform-dependent values.
+
+.. seealso::
+
+    A nice alternative would be to use the excellent `distro
+    <https://github.com/python-distro/distro>`_ package, but it `does not yet support
+    detection of macOS and Windows
+    <https://github.com/python-distro/distro/issues/177>`_.
+"""
+
+from __future__ import annotations
+
+import platform
+import sys
+from dataclasses import dataclass, field
+from itertools import combinations
+from typing import Iterable, Iterator
+
+from . import cache
+
+""" Below is the collection of heuristics used to identify each platform.
+
+All these heuristics can be hard-cached as the underlying system is not suppose to
+change between code execution.
+"""
+
+
+
[docs]@cache +def is_aix() -> bool: + """Return `True` only if current platform is of the AIX family.""" + return sys.platform.startswith("aix")
+ + +
[docs]@cache +def is_cygwin() -> bool: + """Return `True` only if current platform is of the Cygwin family.""" + return sys.platform.startswith("cygwin")
+ + +
[docs]@cache +def is_freebsd() -> bool: + """Return `True` only if current platform is of the FreeBSD family.""" + return sys.platform.startswith(("freebsd", "midnightbsd"))
+ + +
[docs]@cache +def is_hurd() -> bool: + """Return `True` only if current platform is of the GNU/Hurd family.""" + return sys.platform.startswith("GNU")
+ + +
[docs]@cache +def is_linux() -> bool: + """Return `True` only if current platform is of the Linux family. + + Excludes WSL1 and WSL2 from this check to + `avoid false positives <https://github.com/kdeldycke/meta-package-manager/issues/944>`_. + """ + return sys.platform.startswith("linux") and not is_wsl1() and not is_wsl2()
+ + +
[docs]@cache +def is_macos() -> bool: + """Return `True` only if current platform is of the macOS family.""" + return platform.platform(terse=True).startswith(("macOS", "Darwin"))
+ + +
[docs]@cache +def is_netbsd() -> bool: + """Return `True` only if current platform is of the NetBSD family.""" + return sys.platform.startswith("netbsd")
+ + +
[docs]@cache +def is_openbsd() -> bool: + """Return `True` only if current platform is of the OpenBSD family.""" + return sys.platform.startswith("openbsd")
+ + +
[docs]@cache +def is_solaris() -> bool: + """Return `True` only if current platform is of the Solaris family.""" + return platform.platform(aliased=True, terse=True).startswith("Solaris")
+ + +
[docs]@cache +def is_sunos() -> bool: + """Return `True` only if current platform is of the SunOS family.""" + return platform.platform(aliased=True, terse=True).startswith("SunOS")
+ + +
[docs]@cache +def is_windows() -> bool: + """Return `True` only if current platform is of the Windows family.""" + return sys.platform.startswith("win32")
+ + +
[docs]@cache +def is_wsl1() -> bool: + """Return `True` only if current platform is Windows Subsystem for Linux v1. + + .. caution:: + The only difference between WSL1 and WSL2 is `the case of the kernel release + version <https://github.com/andweeb/presence.nvim/pull/64#issue-1174430662>`_: + + - WSL 1: + + .. code-block:: shell-session + + $ uname -r + 4.4.0-22572-Microsoft + + - WSL 2: + + .. code-block:: shell-session + + $ uname -r + 5.10.102.1-microsoft-standard-WSL2 + """ + return "Microsoft" in platform.release()
+ + +
[docs]@cache +def is_wsl2() -> bool: + """Return `True` only if current platform is Windows Subsystem for Linux v2.""" + return "microsoft" in platform.release()
+ + +
[docs]@dataclass(frozen=True) +class Platform: + """A platform can identify multiple distributions or OSes with the same + characteristics. + + It has a unique ID, a human-readable name, and boolean to flag current platform. + """ + + id: str + """Unique ID of the platform.""" + + name: str + """User-friendly name of the platform.""" + + current: bool = field(init=False) + """`True` if current environment runs on this platform.""" + + def __post_init__(self): + """Set the ``current`` attribute to identifying the current platform.""" + check_func_id = f"is_{self.id}" + assert check_func_id in globals() + object.__setattr__(self, "current", globals()[check_func_id]())
+ + +AIX = Platform("aix", "AIX") +"""Identify distributions of the AIX family.""" + +CYGWIN = Platform("cygwin", "Cygwin") +"""Identify distributions of the Cygwin family.""" + +FREEBSD = Platform("freebsd", "FreeBSD") +"""Identify distributions of the FreeBSD family.""" + +HURD = Platform("hurd", "GNU/Hurd") +"""Identify distributions of the GNU/Hurd family.""" + +LINUX = Platform("linux", "Linux") +"""Identify distributions of the Linux family.""" + +MACOS = Platform("macos", "macOS") +"""Identify distributions of the macOS family.""" + +NETBSD = Platform("netbsd", "NetBSD") +"""Identify distributions of the NetBSD family.""" + +OPENBSD = Platform("openbsd", "OpenBSD") +"""Identify distributions of the OpenBSD family.""" + +SOLARIS = Platform("solaris", "Solaris") +"""Identify distributions of the Solaris family.""" + +SUNOS = Platform("sunos", "SunOS") +"""Identify distributions of the SunOS family.""" + +WINDOWS = Platform("windows", "Windows") +"""Identify distributions of the Windows family.""" + +WSL1 = Platform("wsl1", "Windows Subsystem for Linux v1") +"""Identify Windows Subsystem for Linux v1.""" + +WSL2 = Platform("wsl2", "Windows Subsystem for Linux v2") +"""Identify Windows Subsystem for Linux v2.""" + + +
[docs]@dataclass(frozen=True) +class Group: + """A ``Group`` identify a collection of ``Platform``. + + Used to group platforms of the same family. + """ + + id: str + """Unique ID of the group.""" + + name: str + """User-friendly description of a group.""" + + platforms: tuple[Platform, ...] = field(repr=False, default_factory=tuple) + """Sorted list of platforms that belong to this group.""" + + platform_ids: frozenset[str] = field(default_factory=frozenset) + """Set of platform IDs that belong to this group. + + Used to test platform overlaps between groups. + """ + + icon: str | None = field(repr=False, default=None) + """Optional icon of the group.""" + + def __post_init__(self): + """Keep the platforms sorted by IDs.""" + object.__setattr__( + self, + "platforms", + tuple(sorted(self.platforms, key=lambda p: p.id)), + ) + object.__setattr__( + self, + "platform_ids", + frozenset({p.id for p in self.platforms}), + ) + # Double-check there is no duplicate platforms. + assert len(self.platforms) == len(self.platform_ids) + + def __iter__(self) -> Iterator[Platform]: + """Iterate over the platforms of the group.""" + yield from self.platforms + + def __len__(self) -> int: + """Return the number of platforms in the group.""" + return len(self.platforms) + + @staticmethod + def _extract_platform_ids(other: Group | Iterable[Platform]) -> frozenset[str]: + """Extract the platform IDs from ``other``.""" + if isinstance(other, Group): + return other.platform_ids + return frozenset(p.id for p in other) + +
[docs] def isdisjoint(self, other: Group | Iterable[Platform]) -> bool: + """Return `True` if the group has no platforms in common with ``other``.""" + return self.platform_ids.isdisjoint(self._extract_platform_ids(other))
+ +
[docs] def fullyintersects(self, other: Group | Iterable[Platform]) -> bool: + """Return `True` if the group has all platforms in common with ``other``. + + We cannot just compare ``Groups`` with the ``==`` equality operator as the + latter takes all attributes into account, as per ``dataclass`` default behavior. + """ + return self.platform_ids == self._extract_platform_ids(other)
+ +
[docs] def issubset(self, other: Group | Iterable[Platform]) -> bool: + return self.platform_ids.issubset(self._extract_platform_ids(other))
+ +
[docs] def issuperset(self, other: Group | Iterable[Platform]) -> bool: + return self.platform_ids.issuperset(self._extract_platform_ids(other))
+ + +ALL_PLATFORMS: Group = Group( + "all_platforms", + "Any platforms", + ( + AIX, + CYGWIN, + FREEBSD, + HURD, + LINUX, + MACOS, + NETBSD, + OPENBSD, + SOLARIS, + SUNOS, + WINDOWS, + WSL1, + WSL2, + ), +) +"""All recognized platforms.""" + + +ALL_WINDOWS = Group("all_windows", "Any Windows", (WINDOWS,)) +"""All Windows operating systems.""" + + +UNIX = Group( + "unix", + "Any Unix", + tuple(p for p in ALL_PLATFORMS.platforms if p not in ALL_WINDOWS), +) +"""All Unix-like operating systems and compatibility layers.""" + + +UNIX_WITHOUT_MACOS = Group( + "unix_without_macos", + "Any Unix but macOS", + tuple(p for p in UNIX if p is not MACOS), +) +"""All Unix platforms, without macOS. + +This is useful to avoid macOS-specific workarounds on Unix platforms. +""" + + +BSD = Group("bsd", "Any BSD", (FREEBSD, MACOS, NETBSD, OPENBSD, SUNOS)) +"""All BSD platforms. + +.. note:: + Are considered of this family (`according Wikipedia + <https://en.wikipedia.org/wiki/Template:Unix>`_): + + - `386BSD` (`FreeBSD`, `NetBSD`, `OpenBSD`, `DragonFly BSD`) + - `NeXTSTEP` + - `Darwin` (`macOS`, `iOS`, `audioOS`, `iPadOS`, `tvOS`, `watchOS`, `bridgeOS`) + - `SunOS` + - `Ultrix` +""" + + +BSD_WITHOUT_MACOS = Group( + "bsd_without_macos", + "Any BSD but macOS", + tuple(p for p in BSD if p is not MACOS), +) +"""All BSD platforms, without macOS. + +This is useful to avoid macOS-specific workarounds on BSD platforms. +""" + + +ALL_LINUX = Group("all_linux", "Any Linux", (LINUX,)) +"""All Unix platforms based on a Linux kernel. + +.. note:: + Are considered of this family (`according Wikipedia + <https://en.wikipedia.org/wiki/Template:Unix>`_): + + - `Android` + - `ChromeOS` + - any other distribution +""" + + +LINUX_LAYERS = Group("linux_layers", "Any Linux compatibility layers", (WSL1, WSL2)) +"""Interfaces that allows Linux binaries to run on a different host system. + +.. note:: + Are considered of this family (`according Wikipedia + <https://en.wikipedia.org/wiki/Template:Unix>`_): + + - `Windows Subsystem for Linux` +""" + + +SYSTEM_V = Group("system_v", "Any Unix derived from AT&T System Five", (AIX, SOLARIS)) +"""All Unix platforms derived from AT&T System Five. + +.. note:: + Are considered of this family (`according Wikipedia + <https://en.wikipedia.org/wiki/Template:Unix>`_): + + - `A/UX` + - `AIX` + - `HP-UX` + - `IRIX` + - `OpenServer` + - `Solaris` + - `OpenSolaris` + - `Illumos` + - `Tru64` + - `UNIX` + - `UnixWare` +""" + + +UNIX_LAYERS = Group("unix_layers", "Any Unix compatibility layers", (CYGWIN,)) +"""Interfaces that allows Unix binaries to run on a different host system. + +.. note:: + Are considered of this family (`according Wikipedia + <https://en.wikipedia.org/wiki/Template:Unix>`_): + + - `Cygwin` + - `Darling` + - `Eunice` + - `GNV` + - `Interix` + - `MachTen` + - `Microsoft POSIX subsystem` + - `MKS Toolkit` + - `PASE` + - `P.I.P.S.` + - `PWS/VSE-AF` + - `UNIX System Services` + - `UserLAnd Technologies` + - `Windows Services for UNIX` +""" + + +OTHER_UNIX = Group( + "other_unix", + "Any other Unix", + tuple( + p + for p in UNIX + if p + not in ( + BSD.platforms + + ALL_LINUX.platforms + + LINUX_LAYERS.platforms + + SYSTEM_V.platforms + + UNIX_LAYERS.platforms + ) + ), +) +"""All other Unix platforms. + +.. note:: + Are considered of this family (`according Wikipedia + <https://en.wikipedia.org/wiki/Template:Unix>`_): + + - `Coherent` + - `GNU/Hurd` + - `HarmonyOS` + - `LiteOS` + - `LynxOS` + - `Minix` + - `MOS` + - `OSF/1` + - `QNX` + - `BlackBerry 10` + - `Research Unix` + - `SerenityOS` +""" + + +NON_OVERLAPPING_GROUPS: frozenset[Group] = frozenset( + ( + ALL_WINDOWS, + BSD, + ALL_LINUX, + LINUX_LAYERS, + SYSTEM_V, + UNIX_LAYERS, + OTHER_UNIX, + ), +) +"""Non-overlapping groups.""" + + +EXTRA_GROUPS: frozenset[Group] = frozenset( + ( + ALL_PLATFORMS, + UNIX, + UNIX_WITHOUT_MACOS, + BSD_WITHOUT_MACOS, + ), +) +"""Overlapping groups, defined for convenience.""" + + +ALL_GROUPS: frozenset[Group] = frozenset(NON_OVERLAPPING_GROUPS | EXTRA_GROUPS) +"""All groups.""" + + +ALL_OS_LABELS: frozenset[str] = frozenset(p.name for p in ALL_PLATFORMS.platforms) +"""Sets of all recognized labels.""" + + +
[docs]def reduce(items: Iterable[Group | Platform]) -> set[Group | Platform]: + """Reduce a collection of ``Group`` and ``Platform`` to a minimal set. + + Returns a deduplicated set of ``Group`` and ``Platform`` that covers the same exact + platforms as the original input, but group as much platforms as possible, to reduce + the number of items. + + .. hint:: + Maybe this could be solved with some `Euler diagram + <https://en.wikipedia.org/wiki/Euler_diagram>`_ algorithms, like those + implemented in `eule <https://github.com/trouchet/eule>`_. + + This is being discussed upstream at `trouchet/eule#120 + <https://github.com/trouchet/eule/issues/120>`_. + """ + # Collect all platforms. + platforms: set[Platform] = set() + for item in items: + if isinstance(item, Group): + platforms.update(item.platforms) + else: + platforms.add(item) + + # List any group matching the platforms. + valid_groups: set[Group] = set() + for group in ALL_GROUPS: + if group.issubset(platforms): + valid_groups.add(group) + + # Test all combination of groups to find the smallest set of groups + platforms. + min_items: int = 0 + results: list[set[Group | Platform]] = [] + # Serialize group sets for deterministic lookups. Sort them by platform count. + groups = tuple(sorted(valid_groups, key=len, reverse=True)) + for subset_size in range(1, len(groups) + 1): + # If we already have a solution that involves less items than the current + # subset of groups we're going to evaluates, there is no point in continuing. + if min_items and subset_size > min_items: + break + + for group_subset in combinations(groups, subset_size): + # If any group overlaps another, there is no point in exploring this subset. + if not all(g[0].isdisjoint(g[1]) for g in combinations(group_subset, 2)): + continue + + # Remove all platforms covered by the groups. + ungrouped_platforms = platforms.copy() + for group in group_subset: + ungrouped_platforms.difference_update(group.platforms) + + # Merge the groups and the remaining platforms. + reduction = ungrouped_platforms.union(group_subset) + reduction_size = len(reduction) + + # Reset the results if we have a new solution that is better than the + # previous ones. + if not results or reduction_size < min_items: + results = [reduction] + min_items = reduction_size + # If the solution is as good as the previous one, add it to the results. + elif reduction_size == min_items: + results.append(reduction) + + if len(results) > 1: + msg = f"Multiple solutions found: {results}" + raise RuntimeError(msg) + + # If no reduced solution was found, return the original platforms. + if not results: + return platforms # type: ignore[return-value] + + return results.pop()
+ + +
[docs]@cache +def os_label(os_id: str) -> str | None: + """Return platform label for user-friendly output.""" + for p in ALL_PLATFORMS.platforms: + if p.id == os_id: + return p.name + return None
+ + +
[docs]@cache +def current_os() -> Platform: + """Return the current platform.""" + matching = [] + for p in ALL_PLATFORMS.platforms: + if p.current: + matching.append(p) + + if len(matching) > 1: + msg = f"Multiple platforms match current OS: {matching}" + raise RuntimeError(msg) + + if not matching: + msg = ( + f"Unrecognized {sys.platform} / " + f"{platform.platform(aliased=True, terse=True)} platform." + ) + raise SystemError(msg) + + assert len(matching) == 1 + return matching.pop()
+ + +CURRENT_OS_ID: str = current_os().id +CURRENT_OS_LABEL: str = current_os().name +"""Constants about the current platform.""" +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/pygments.html b/_modules/click_extra/pygments.html new file mode 100644 index 000000000..9dec93aba --- /dev/null +++ b/_modules/click_extra/pygments.html @@ -0,0 +1,531 @@ + + + + + + + + click_extra.pygments - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.pygments

+# 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.
+"""Helpers and utilities to allow Pygments to parse and render ANSI codes."""
+
+from __future__ import annotations
+
+from typing import Iterable, Iterator
+
+from pygments import lexers
+from pygments.filter import Filter
+from pygments.filters import TokenMergeFilter
+from pygments.formatter import _lookup_style  # type: ignore[attr-defined]
+from pygments.formatters import HtmlFormatter
+from pygments.lexer import Lexer, LexerMeta
+from pygments.lexers.algebra import GAPConsoleLexer
+from pygments.lexers.dylan import DylanConsoleLexer
+from pygments.lexers.erlang import ElixirConsoleLexer, ErlangShellLexer
+from pygments.lexers.julia import JuliaConsoleLexer
+from pygments.lexers.matlab import MatlabSessionLexer
+from pygments.lexers.php import PsyshConsoleLexer
+from pygments.lexers.python import PythonConsoleLexer
+from pygments.lexers.r import RConsoleLexer
+from pygments.lexers.ruby import RubyConsoleLexer
+from pygments.lexers.shell import ShellSessionBaseLexer
+from pygments.lexers.special import OutputLexer
+from pygments.lexers.sql import PostgresConsoleLexer, SqliteConsoleLexer
+from pygments.style import StyleMeta
+from pygments.token import Generic, _TokenType, string_to_tokentype
+from pygments_ansi_color import (
+    AnsiColorLexer,
+    ExtendedColorHtmlFormatterMixin,
+    color_tokens,
+)
+
+DEFAULT_TOKEN_TYPE = Generic.Output
+"""Default Pygments' token type to render with ANSI support.
+
+We defaults to ``Generic.Output`` tokens, as this is the token type used by all REPL-
+like and terminal lexers.
+"""
+
+
+
[docs]class AnsiFilter(Filter): + """Custom filter transforming a particular kind of token (``Generic.Output`` by + defaults) into ANSI tokens.""" + + def __init__(self, **options) -> None: + """Initialize a ``AnsiColorLexer`` and configure the ``token_type`` to be + colorized. + + .. todo:: + + Allow multiple ``token_type`` to be configured for colorization (if + traditions are changed on Pygments' side). + """ + super().__init__(**options) + self.ansi_lexer = AnsiColorLexer() + self.token_type = string_to_tokentype( + options.get("token_type", DEFAULT_TOKEN_TYPE), + ) + +
[docs] def filter( + self, lexer: Lexer, stream: Iterable[tuple[_TokenType, str]] + ) -> Iterator[tuple[_TokenType, str]]: + """Transform each token of ``token_type`` type into a stream of ANSI tokens.""" + for ttype, value in stream: + if ttype == self.token_type: + # TODO: Should we re-wrap the resulting list of token into their + # original Generic.Output? + yield from self.ansi_lexer.get_tokens(value) + else: + yield ttype, value
+ + +
[docs]class AnsiSessionLexer(LexerMeta): + """Custom metaclass used as a class factory to derive an ANSI variant of default + shell session lexers.""" + + def __new__(cls, name, bases, dct): + """Setup class properties' defaults for new ANSI-capable lexers. + + - Adds an ``ANSI`` prefix to the lexer's name. + - Replaces all ``aliases`` IDs from the parent lexer with variants prefixed with + ``ansi-``. + """ + new_cls = super().__new__(cls, name, bases, dct) + new_cls.name = f"ANSI {new_cls.name}" + new_cls.aliases = tuple(f"ansi-{alias}" for alias in new_cls.aliases) + return new_cls
+ + +
[docs]class AnsiLexerFiltersMixin(Lexer): + def __init__(self, *args, **kwargs) -> None: + """Adds a ``TokenMergeFilter`` and ``AnsiOutputFilter`` to the list of filters. + + The session lexers we inherits from are parsing the code block line by line so + they can differentiate inputs and outputs. Each output line ends up + encapsulated into a ``Generic.Output`` token. We apply the ``TokenMergeFilter`` + filter to reduce noise and have each contiguous output lines part of the same + single token. + + Then we apply our custom ``AnsiOutputFilter`` to transform any + ``Generic.Output`` monoblocks into ANSI tokens. + """ + super().__init__(*args, **kwargs) + self.filters.append(TokenMergeFilter()) + self.filters.append(AnsiFilter())
+ + +
[docs]def collect_session_lexers() -> Iterator[type[Lexer]]: + """Retrieve all lexers producing shell-like sessions in Pygments. + + This function contain a manually-maintained list of lexers, to which we dynamiccaly + adds lexers inheriting from ``ShellSessionBaseLexer``. + + .. hint:: + + To help maintain this list, there is `a test that will fail + <https://github.com/kdeldycke/click-extra/blob/main/click_extra/tests/test_pygments.py>`_ + if a new REPL/terminal-like lexer is added to Pygments but not referenced here. + """ + yield from [ + DylanConsoleLexer, + ElixirConsoleLexer, + ErlangShellLexer, + GAPConsoleLexer, + JuliaConsoleLexer, + MatlabSessionLexer, + OutputLexer, + PostgresConsoleLexer, + PsyshConsoleLexer, + PythonConsoleLexer, + RConsoleLexer, + RubyConsoleLexer, + SqliteConsoleLexer, + ] + + for lexer in lexers._iter_lexerclasses(): + if ShellSessionBaseLexer in lexer.__bases__: + yield lexer
+ + +lexer_map = {} +"""Map original lexer to their ANSI variant.""" + + +# Auto-generate the ANSI variant of all lexers we collected. +for original_lexer in collect_session_lexers(): + new_name = f"Ansi{original_lexer.__name__}" + new_lexer = AnsiSessionLexer(new_name, (AnsiLexerFiltersMixin, original_lexer), {}) + locals()[new_name] = new_lexer + lexer_map[original_lexer] = new_lexer + + +
[docs]class AnsiHtmlFormatter(ExtendedColorHtmlFormatterMixin, HtmlFormatter): + """Extend standard Pygments' ``HtmlFormatter``. + + `Adds support for ANSI 256 colors <https://github.com/chriskuehl/pygments-ansi-color#optional-enable-256-color-support>`_. + """ + + name = "ANSI HTML" + aliases = ["ansi-html"] + + def __init__(self, **kwargs) -> None: + """Intercept the ``style`` argument to augment it with ANSI colors support. + + Creates a new style instance that inherits from the one provided by the user, + but updates its ``styles`` attribute to add ANSI colors support from + ``pygments_ansi_color``. + """ + # XXX Same default style as in Pygments' HtmlFormatter, which is... `default`: + # https://github.com/pygments/pygments/blob/1d83928/pygments/formatter.py#LL89C33-L89C33 + base_style_id = kwargs.setdefault("style", "default") + + # Fetch user-provided style. + base_style = _lookup_style(base_style_id) + + # Augment the style with ANSI colors support. + augmented_styles = dict(base_style.styles) + augmented_styles.update(color_tokens(enable_256color=True)) + + # Prefix the style name with `Ansi` to avoid name collision with the original + # and ease debugging. + new_name = f"Ansi{base_style.__name__}" + new_lexer = StyleMeta(new_name, (base_style,), {"styles": augmented_styles}) + + kwargs["style"] = new_lexer + + super().__init__(**kwargs)
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/sphinx.html b/_modules/click_extra/sphinx.html new file mode 100644 index 000000000..15bf83228 --- /dev/null +++ b/_modules/click_extra/sphinx.html @@ -0,0 +1,460 @@ + + + + + + + + click_extra.sphinx - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.sphinx

+# 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.
+"""Helpers and utilities for Sphinx rendering of CLI based on Click Extra.
+
+.. danger::
+    This module is quite janky but does the job. Still, it would benefits from a total
+    clean rewrite. This would require a better understanding of Sphinx, Click and MyST
+    internals. And as a side effect will eliminate the dependency on
+    ``pallets_sphinx_themes``.
+
+    If you're up to the task, you can try to refactor it. I'll probably start by moving
+    the whole ``pallets_sphinx_themes.themes.click.domain`` code here, merge it with
+    the local collection of monkey-patches below, then clean the whole code to make it
+    more readable and maintainable. And finally, address all the todo-list below.
+
+.. todo::
+    Add support for plain MyST directives to remove the need of wrapping rST into an
+    ``{eval-rst}`` block. Ideally, this would allow for the following simpler syntax in
+    MyST:
+
+    .. code-block:: markdown
+
+        ```{click-example}
+        from click_extra import echo, extra_command, option, style
+
+        @extra_command
+        @option("--name", prompt="Your name", help="The person to greet.")
+        def hello_world(name):
+            "Simple program that greets NAME."
+            echo(f"Hello, {style(name, fg='red')}!")
+        ```
+
+    .. code-block:: markdown
+
+        ```{click-run}
+        invoke(hello_world, args=["--help"])
+        ```
+
+.. todo::
+    Fix the need to have both ``.. click:example::`` and ``.. click:run::`` directives
+    in the same ``{eval-rst}`` block in MyST. This is required to have both directives
+    shares states and context.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from docutils.statemachine import ViewList
+from sphinx.highlighting import PygmentsBridge
+
+from .pygments import AnsiHtmlFormatter
+from .tests.conftest import ExtraCliRunner
+
+
+
[docs]class PatchedViewList(ViewList): + """Force the rendering of ANSI shell session. + + Replaces the ``.. sourcecode:: shell-session`` code block produced by + ``.. click:run::`` directive with an ANSI Shell Session: + ``.. code-block:: ansi-shell-session``. + + ``.. sourcecode:: shell-session`` has been `released in Pallets-Sphinx-Themes 2.1.0 + <https://github.com/pallets/pallets-sphinx-themes/pull/62>`_. + """ + +
[docs] def append(self, *args, **kwargs) -> None: + """Search the default code block and replace it with our own version.""" + default_code_block = ".. sourcecode:: shell-session" + new_code_block = ".. code-block:: ansi-shell-session" + + if default_code_block in args: + new_args = list(args) + index = args.index(default_code_block) + new_args[index] = new_code_block + args = tuple(new_args) + + return super().append(*args, **kwargs)
+ + +
[docs]def setup(app: Any) -> None: + """Register new directives, augmented with ANSI coloring. + + New directives: + - ``.. click:example::`` + - ``.. click:run::`` + + .. danger:: + This function activates lots of monkey-patches: + + - ``sphinx.highlighting.PygmentsBridge`` is updated to set its default HTML + formatter to an ANSI capable one for the whole Sphinx app. + + - ``pallets_sphinx_themes.themes.click.domain.ViewList`` is + `patched to force an ANSI lexer on the rST code block + <#click_extra.sphinx.PatchedViewList>`_. + + - ``pallets_sphinx_themes.themes.click.domain.ExampleRunner`` is replaced with + ``click_extra.testing.ExtraCliRunner`` to have full control of + contextual color settings by the way of the ``color`` parameter. It also + produce unfiltered ANSI codes so that the other ``PatchedViewList`` + monkey-patch can do its job and render colors in the HTML output. + """ + # Set Sphinx's default HTML formatter to an ANSI capable one. + PygmentsBridge.html_formatter = AnsiHtmlFormatter + + from pallets_sphinx_themes.themes.click import domain + + domain.ViewList = PatchedViewList + + # Brutal, but effective. + # Alternative patching methods: https://stackoverflow.com/a/38928265 + domain.ExampleRunner.__bases__ = (ExtraCliRunner,) + # Force color rendering in ``invoke`` calls. + domain.ExampleRunner.force_color = True + + # Register directives to Sphinx. + domain.setup(app)
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tabulate.html b/_modules/click_extra/tabulate.html new file mode 100644 index 000000000..01b7367d7 --- /dev/null +++ b/_modules/click_extra/tabulate.html @@ -0,0 +1,518 @@ + + + + + + + + click_extra.tabulate - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tabulate

+# 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.
+"""Collection of table rendering utilities."""
+
+from __future__ import annotations
+
+import csv
+from functools import partial
+from gettext import gettext as _
+from io import StringIO
+from typing import Sequence
+
+import tabulate
+from tabulate import DataRow, Line, TableFormat
+
+from . import Choice, Context, Parameter, echo
+from .parameters import ExtraOption
+
+tabulate.MIN_PADDING = 0
+"""Neutralize spurious double-spacing in table rendering."""
+
+
+tabulate._table_formats.update(  # type: ignore[attr-defined]
+    {
+        "github": TableFormat(
+            lineabove=Line("| ", "-", " | ", " |"),
+            linebelowheader=Line("| ", "-", " | ", " |"),
+            linebetweenrows=None,
+            linebelow=None,
+            headerrow=DataRow("| ", " | ", " |"),
+            datarow=DataRow("| ", " | ", " |"),
+            padding=0,
+            with_header_hide=["lineabove"],
+        ),
+    },
+)
+"""Tweak table separators to match MyST and GFM syntax.
+
+I.e. add a space between the column separator and the dashes filling a cell:
+
+``|---|---|---|`` → ``| --- | --- | --- |``
+
+That way we produce a table that doesn't need any supplement linting.
+
+This has been proposed upstream at `python-tabulate#261
+<https://github.com/astanin/python-tabulate/pull/261>`_.
+"""
+
+
+output_formats: list[str] = sorted(
+    # Formats from tabulate.
+    list(tabulate._table_formats)  # type: ignore[attr-defined]
+    # Formats inherited from previous legacy cli-helpers dependency.
+    + ["csv", "vertical"]
+    # Formats derived from CSV dialects.
+    + [f"csv-{d}" for d in csv.list_dialects()],
+)
+"""All output formats supported by click-extra."""
+
+
+
[docs]def get_csv_dialect(format_id: str) -> str | None: + """Extract, validate and normalize CSV dialect ID from format.""" + assert format_id.startswith("csv") + # Defaults to excel rendering, like in Python's csv module. + dialect = "excel" + parts = format_id.split("-", 1) + assert parts[0] == "csv" + if len(parts) > 1: + dialect = parts[1] + return dialect
+ + +
[docs]def render_csv( + tabular_data: Sequence[Sequence[str]], + headers: Sequence[str] = (), + **kwargs, +) -> None: + with StringIO(newline="") as output: + writer = csv.writer(output, **kwargs) + writer.writerow(headers) + writer.writerows(tabular_data) + # Use print instead of echo to conserve CSV dialect's line termination, + # avoid extra line returns and ANSI coloring. + print(output.getvalue(), end="")
+ + +
[docs]def render_vertical( + tabular_data: Sequence[Sequence[str]], + headers: Sequence[str] = (), + **kwargs, +) -> None: + """Re-implements ``cli-helpers``'s vertical table layout. + + See `cli-helpers source for reference + <https://github.com/dbcli/cli_helpers/blob/v2.3.0/cli_helpers/tabular_output/vertical_table_adapter.py>`_. + """ + header_len = max(len(h) for h in headers) + padded_headers = [h.ljust(header_len) for h in headers] + + for index, row in enumerate(tabular_data): + # 27 has been hardcoded in cli-helpers: + # https://github.com/dbcli/cli_helpers/blob/4e2c417/cli_helpers/tabular_output/vertical_table_adapter.py#L34 + echo(f"{'*' * 27}[ {index + 1}. row ]{'*' * 27}") + for cell_label, cell_value in zip(padded_headers, row): + echo(f"{cell_label} | {cell_value}")
+ + +
[docs]def render_table( + tabular_data: Sequence[Sequence[str]], + headers: Sequence[str] = (), + **kwargs, +) -> None: + """Render a table with tabulate and output it via echo.""" + defaults = { + "disable_numparse": True, + "numalign": None, + } + defaults.update(kwargs) + echo(tabulate.tabulate(tabular_data, headers, **defaults)) # type: ignore[arg-type]
+ + +
[docs]class TableFormatOption(ExtraOption): + """A pre-configured option that is adding a ``-t``/``--table-format`` flag to select + the rendering style of a table. + + The selected table format ID is made available in the context in + ``ctx.meta["click_extra.table_format"]``. + """ + + def __init__( + self, + param_decls: Sequence[str] | None = None, + type=Choice(output_formats, case_sensitive=False), + default="rounded_outline", + expose_value=False, + help=_("Rendering style of tables."), + **kwargs, + ) -> None: + if not param_decls: + param_decls = ("-t", "--table-format") + + kwargs.setdefault("callback", self.init_formatter) + + super().__init__( + param_decls=param_decls, + type=type, + default=default, + expose_value=expose_value, + help=help, + **kwargs, + ) + +
[docs] def init_formatter( + self, + ctx: Context, + param: Parameter, + value: str, + ) -> None: + """Save table format ID in the context, and adds ``print_table()`` to it. + + The ``print_table(tabular_data, headers)`` method added to the context is a + ready-to-use helper that takes for parameters: + - ``tabular_data``, a 2-dimensional iterable of iterables for cell values, + - ``headers``, a list of string to be used as headers. + """ + ctx.meta["click_extra.table_format"] = value + + render_func = None + if value.startswith("csv"): + render_func = partial(render_csv, dialect=get_csv_dialect(value)) + elif value == "vertical": + render_func = render_vertical # type: ignore[assignment] + else: + render_func = partial(render_table, tablefmt=value) + + ctx.print_table = render_func # type: ignore[attr-defined]
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/telemetry.html b/_modules/click_extra/telemetry.html new file mode 100644 index 000000000..a7727f546 --- /dev/null +++ b/_modules/click_extra/telemetry.html @@ -0,0 +1,411 @@ + + + + + + + + click_extra.telemetry - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.telemetry

+# 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.
+"""Telemetry utilities."""
+
+from __future__ import annotations
+
+from gettext import gettext as _
+from typing import TYPE_CHECKING, Sequence
+
+from .parameters import ExtraOption, extend_envvars
+
+if TYPE_CHECKING:
+    from . import Context, Parameter
+
+
+
[docs]class TelemetryOption(ExtraOption): + """A pre-configured ``--telemetry``/``--no-telemetry`` option flag. + + Respects the + `proposed DO_NOT_TRACK environment variable <https://consoledonottrack.com>`_ as a + unified standard to opt-out of telemetry for TUI/console apps. + + The ``DO_NOT_TRACK`` convention takes precedence over the user-defined environment + variables and the auto-generated values. + + .. seealso:: + + - A `knowledge base of telemetry disabling configuration options + <https://github.com/beatcracker/toptout>`_. + + - And another `list of environment variable to disable telemetry in desktop apps + <https://telemetry.timseverien.com/opt-out/>`_. + """ + +
[docs] def save_telemetry( + self, + ctx: Context, + param: Parameter, + value: bool, + ) -> None: + """Save the option value in the context, in ``ctx.telemetry``.""" + ctx.telemetry = value # type: ignore[attr-defined]
+ + def __init__( + self, + param_decls: Sequence[str] | None = None, + default=False, + expose_value=False, + envvar=None, + show_envvar=True, + help=_("Collect telemetry and usage data."), + **kwargs, + ) -> None: + if not param_decls: + param_decls = ("--telemetry/--no-telemetry",) + + envvar = extend_envvars(["DO_NOT_TRACK"], envvar) + + kwargs.setdefault("callback", self.save_telemetry) + + super().__init__( + param_decls=param_decls, + default=default, + expose_value=expose_value, + envvar=envvar, + show_envvar=show_envvar, + help=help, + **kwargs, + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/testing.html b/_modules/click_extra/testing.html new file mode 100644 index 000000000..194e413d1 --- /dev/null +++ b/_modules/click_extra/testing.html @@ -0,0 +1,998 @@ + + + + + + + + click_extra.testing - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.testing

+# 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.
+"""CLI testing and simulation of their execution."""
+
+from __future__ import annotations
+
+import contextlib
+import inspect
+import io
+import os
+import shlex
+import subprocess
+import sys
+from contextlib import nullcontext
+from functools import partial
+from pathlib import Path
+from textwrap import indent
+from typing import (
+    IO,
+    TYPE_CHECKING,
+    Any,
+    BinaryIO,
+    ContextManager,
+    Iterable,
+    Iterator,
+    Literal,
+    Mapping,
+    Optional,
+    Sequence,
+    Union,
+    cast,
+)
+from unittest.mock import patch
+
+import click
+import click.testing
+from boltons.iterutils import flatten
+from boltons.strutils import strip_ansi
+from boltons.tbutils import ExceptionInfo
+from click import formatting, termui, utils
+
+from . import Color, Style
+from .colorize import default_theme
+
+if TYPE_CHECKING:
+    from types import TracebackType
+
+PROMPT = "► "
+INDENT = " " * len(PROMPT)
+"""Constants for rendering of CLI execution."""
+
+
+EnvVars = Mapping[str, Optional[str]]
+"""Type for ``dict``-like environment variables."""
+
+Arg = Union[str, Path, None]
+Args = Iterable[Arg]
+NestedArgs = Iterable[Union[Arg, Iterable["NestedArgs"]]]
+"""Types for arbitrary nested CLI arguments.
+
+Arguments can be ``str``, :py:class:`pathlib.Path` objects or ``None`` values.
+"""
+
+
+
[docs]def args_cleanup(*args: Arg | NestedArgs) -> tuple[str, ...]: + """Flatten recursive iterables, remove all ``None``, and cast each element to + strings. + + Helps serialize :py:class:`pathlib.Path` and other objects. + + It also allows for nested iterables and ``None`` values as CLI arguments for + convenience. We just need to flatten and filters them out. + """ + return tuple(str(arg) for arg in flatten(args) if arg is not None)
+ + +
[docs]def format_cli_prompt(cmd_args: Iterable[str], extra_env: EnvVars | None = None) -> str: + """Simulate the console prompt used to invoke the CLI.""" + extra_env_string = "" + if extra_env: + extra_env_string = default_theme.envvar( + "".join(f"{k}={v} " for k, v in extra_env.items()), + ) + + cmd_str = default_theme.invoked_command(" ".join(cmd_args)) + + return f"{PROMPT}{extra_env_string}{cmd_str}"
+ + + + + +
[docs]def env_copy(extend: EnvVars | None = None) -> EnvVars | None: + """Returns a copy of the current environment variables and eventually ``extend`` it. + + Mimics `Python's original implementation + <https://github.com/python/cpython/blob/7b5b429/Lib/subprocess.py#L1648-L1649>`_ by + returning ``None`` if no ``extend`` content are provided. + + Environment variables are expected to be a ``dict`` of ``str:str``. + """ + if isinstance(extend, dict): + for k, v in extend.items(): + assert isinstance(k, str) + assert isinstance(v, str) + else: + assert not extend + env_copy: EnvVars | None = None + if extend: + # By casting to dict we make a copy and prevent the modification of the + # global environment. + env_copy = dict(os.environ) + env_copy.update(extend) + return env_copy
+ + +
[docs]def run_cmd( + *args: str, + extra_env: EnvVars | None = None, + print_output: bool = True, +) -> tuple[int, str, str]: + """Run a system command, print output and return results.""" + result = subprocess.run( + args, + capture_output=True, + encoding="utf-8", + env=cast("subprocess._ENV", env_copy(extra_env)), + ) + if print_output: + print_cli_run(args, result, env=extra_env) + return result.returncode, result.stdout, result.stderr
+ + +INVOKE_ARGS = set(inspect.getfullargspec(click.testing.CliRunner.invoke).args) +"""Parameter IDs of ``click.testing.CliRunner.invoke()``. + +We need to collect them to help us identify which extra parameters passed to +``invoke()`` collides with its original signature. + +.. warning:: + This has been `reported upstream to Click project + <https://github.com/pallets/click/issues/2110>`_ but has been rejected and not + considered an issue worth fixing. +""" + + +
[docs]class BytesIOCopy(io.BytesIO): + """Patch ``io.BytesIO`` to let the written stream be copied to another. + + .. caution:: + This has been `proposed upstream to Click project + <https://github.com/pallets/click/pull/2523>`_ but has not been merged yet. + """ + + def __init__(self, copy_to: io.BytesIO) -> None: + super().__init__() + self.copy_to = copy_to + +
[docs] def flush(self) -> None: + super().flush() + self.copy_to.flush()
+ +
[docs] def write(self, b) -> int: + self.copy_to.write(b) + return super().write(b)
+ + +
[docs]class StreamMixer: + """Mixes ``<stdout>`` and ``<stderr>`` streams if ``mix_stderr=True``. + + The result is available in the ``output`` attribute. + + If ``mix_stderr=False``, the ``<stdout>`` and ``<stderr>`` streams are kept + independent and the ``output`` is the same as the ``<stdout>`` stream. + + .. caution:: + This has been `proposed upstream to Click project + <https://github.com/pallets/click/pull/2523>`_ but has not been merged yet. + """ + + def __init__(self, mix_stderr: bool) -> None: + if not mix_stderr: + self.stdout = io.BytesIO() + self.stderr = io.BytesIO() + self.output = self.stdout + + else: + self.output = io.BytesIO() + self.stdout = BytesIOCopy(copy_to=self.output) + self.stderr = BytesIOCopy(copy_to=self.output)
+ + +
[docs]class ExtraResult(click.testing.Result): + """Like ``click.testing.Result``, with finer ``<stdout>`` and ``<stderr>`` streams. + + .. caution:: + This has been `proposed upstream to Click project + <https://github.com/pallets/click/pull/2523>`_ but has not been merged yet. + """ + + stderr_bytes: bytes + """Makes ``stderr_bytes`` mandatory.""" + + def __init__( + self, + runner: click.testing.CliRunner, + stdout_bytes: bytes, + stderr_bytes: bytes, + output_bytes: bytes, + return_value: Any, + exit_code: int, + exception: BaseException | None, + exc_info: tuple[type[BaseException], BaseException, TracebackType] + | None = None, + ) -> None: + """Same as original but adds ``output_bytes`` parameter. + + Also makes ``stderr_bytes`` mandatory. + """ + self.output_bytes = output_bytes + super().__init__( + runner=runner, + stdout_bytes=stdout_bytes, + stderr_bytes=stderr_bytes, + return_value=return_value, + exit_code=exit_code, + exception=exception, + exc_info=exc_info, + ) + + @property + def output(self) -> str: + """The terminal output as unicode string, as the user would see it. + + .. caution:: + Contrary to original ``click.testing.Result.output``, it is not a proxy for + ``self.stdout``. It now possess its own stream to mix ``<stdout>`` and + ``<stderr>`` depending on the ``mix_stderr`` value. + """ + return self.output_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", + "\n", + ) + + @property + def stderr(self) -> str: + """The standard error as unicode string. + + .. caution:: + Contrary to original ``click.testing.Result.stderr``, it no longer raise an + exception, and always returns the ``<stderr>`` string. + """ + return self.stderr_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", + "\n", + )
+ + +
[docs]class ExtraCliRunner(click.testing.CliRunner): + """Augment ``click.testing.CliRunner`` with extra features and bug fixes.""" + + force_color: bool = False + """Global class attribute to override the ``color`` parameter in ``invoke``. + + .. note:: + This was initially developed to `force the initialization of the runner during + the setup of Sphinx new directives <sphinx#click_extra.sphinx.setup>`_. This + was the only way we found, as to patch some code we had to operate at the class + level. + """ + +
[docs] @contextlib.contextmanager + def isolation( # type: ignore[override] + self, + input: str | bytes | IO[Any] | None = None, + env: Mapping[str, str | None] | None = None, + color: bool = False, + ) -> Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]: + """Copy of ``click.testing.CliRunner.isolation()`` with extra features. + + - An additional output stream is returned, which is a mix of ``<stdout>`` and + ``<stderr>`` streams if ``mix_stderr=True``. + + - Always returns the ``<stderr>`` stream. + + .. caution:: + This is a hard-copy of the modified ``isolation()`` method `from click#2523 + PR + <https://github.com/pallets/click/pull/2523/files#diff-b07fd6fad9f9ea8be5cbcbeaf34c956703b929b2de95c56229e77c328a7c6010>`_ + which has not been merged upstream yet. + + .. todo:: + Reduce the code duplication here by using clever monkeypatching? + """ + bytes_input = click.testing.make_input_stream(input, self.charset) + echo_input = None + + old_stdin = sys.stdin + old_stdout = sys.stdout + old_stderr = sys.stderr + old_forced_width = formatting.FORCED_WIDTH + formatting.FORCED_WIDTH = 80 + + env = self.make_env(env) + + stream_mixer = StreamMixer(mix_stderr=self.mix_stderr) + + if self.echo_stdin: + bytes_input = echo_input = cast( + BinaryIO, + click.testing.EchoingStdin(bytes_input, stream_mixer.stdout), + ) + + sys.stdin = text_input = click.testing._NamedTextIOWrapper( + bytes_input, + encoding=self.charset, + name="<stdin>", + mode="r", + ) + + if self.echo_stdin: + # Force unbuffered reads, otherwise TextIOWrapper reads a + # large chunk which is echoed early. + text_input._CHUNK_SIZE = 1 # type: ignore + + sys.stdout = click.testing._NamedTextIOWrapper( + stream_mixer.stdout, + encoding=self.charset, + name="<stdout>", + mode="w", + ) + + sys.stderr = click.testing._NamedTextIOWrapper( + stream_mixer.stderr, + encoding=self.charset, + name="<stderr>", + mode="w", + errors="backslashreplace", + ) + + @click.testing._pause_echo(echo_input) # type: ignore[arg-type] + def visible_input(prompt: str | None = None) -> str: + sys.stdout.write(prompt or "") + val = text_input.readline().rstrip("\r\n") + sys.stdout.write(f"{val}\n") + sys.stdout.flush() + return val + + @click.testing._pause_echo(echo_input) # type: ignore[arg-type] + def hidden_input(prompt: str | None = None) -> str: + sys.stdout.write(f"{prompt or ''}\n") + sys.stdout.flush() + return text_input.readline().rstrip("\r\n") + + @click.testing._pause_echo(echo_input) # type: ignore[arg-type] + def _getchar(echo: bool) -> str: + char = sys.stdin.read(1) + + if echo: + sys.stdout.write(char) + + sys.stdout.flush() + return char + + default_color = color + + def should_strip_ansi( + stream: IO[Any] | None = None, + color: bool | None = None, + ) -> bool: + if color is None: + return not default_color + return not color + + old_visible_prompt_func = termui.visible_prompt_func + old_hidden_prompt_func = termui.hidden_prompt_func + old__getchar_func = termui._getchar + old_should_strip_ansi = utils.should_strip_ansi + termui.visible_prompt_func = visible_input + termui.hidden_prompt_func = hidden_input + termui._getchar = _getchar + utils.should_strip_ansi = should_strip_ansi + + old_env = {} + try: + for key, value in env.items(): + old_env[key] = os.environ.get(key) + if value is None: + with contextlib.suppress(Exception): + del os.environ[key] + + else: + os.environ[key] = value + yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output) + finally: + for key, value in old_env.items(): + if value is None: + with contextlib.suppress(Exception): + del os.environ[key] + + else: + os.environ[key] = value + sys.stdout = old_stdout + sys.stderr = old_stderr + sys.stdin = old_stdin + termui.visible_prompt_func = old_visible_prompt_func + termui.hidden_prompt_func = old_hidden_prompt_func + termui._getchar = old__getchar_func + utils.should_strip_ansi = old_should_strip_ansi + formatting.FORCED_WIDTH = old_forced_width
+ +
[docs] def invoke2( + self, + cli: click.core.BaseCommand, + args: str | Sequence[str] | None = None, + input: str | bytes | IO[Any] | None = None, + env: Mapping[str, str | None] | None = None, + catch_exceptions: bool = True, + color: bool = False, + **extra: Any, + ) -> ExtraResult: + """Copy of ``click.testing.CliRunner.invoke()`` with extra ``<output>`` stream. + + .. caution:: + This is a hard-copy of the modified ``invoke()`` method `from click#2523 PR + <https://github.com/pallets/click/pull/2523/files#diff-b07fd6fad9f9ea8be5cbcbeaf34c956703b929b2de95c56229e77c328a7c6010>`_ + which has not been merged upstream yet. + + .. todo:: + Reduce the code duplication here by using clever monkeypatching? + """ + exc_info = None + with self.isolation(input=input, env=env, color=color) as outstreams: + return_value = None + exception: BaseException | None = None + exit_code = 0 + + if isinstance(args, str): + args = shlex.split(args) + + try: + prog_name = extra.pop("prog_name") + except KeyError: + prog_name = self.get_default_prog_name(cli) + + try: + return_value = cli.main(args=args or (), prog_name=prog_name, **extra) + except SystemExit as e: + exc_info = sys.exc_info() + e_code = cast(Optional[Union[int, Any]], e.code) + + if e_code is None: + e_code = 0 + + if e_code != 0: + exception = e + + if not isinstance(e_code, int): + sys.stdout.write(str(e_code)) + sys.stdout.write("\n") + e_code = 1 + + exit_code = e_code + + except Exception as e: + if not catch_exceptions: + raise + exception = e + exit_code = 1 + exc_info = sys.exc_info() + finally: + sys.stdout.flush() + stdout = outstreams[0].getvalue() + stderr = outstreams[1].getvalue() + output = outstreams[2].getvalue() + + return ExtraResult( + runner=self, + stdout_bytes=stdout, + stderr_bytes=stderr, + output_bytes=output, + return_value=return_value, + exit_code=exit_code, + exception=exception, + exc_info=exc_info, # type: ignore + )
+ +
[docs] def invoke( # type: ignore[override] + self, + cli: click.core.BaseCommand, + *args: Arg | NestedArgs, + input: str | bytes | IO | None = None, + env: EnvVars | None = None, + catch_exceptions: bool = True, + color: bool | Literal["forced"] | None = None, + **extra: Any, + ) -> click.testing.Result: + """Same as ``click.testing.CliRunner.invoke()`` with extra features. + + - The first positional parameter is the CLI to invoke. The remaining positional + parameters of the function are the CLI arguments. All other parameters are + required to be named. + + - The CLI arguments can be nested iterables of arbitrary depth. This is + `useful for argument composition of test cases with @pytest.mark.parametrize + <https://docs.pytest.org/en/stable/example/parametrize.html>`_. + + - Allow forcing of the ``color`` property at the class-level via + ``force_color`` attribute. + + - Adds a special case in the form of ``color="forced"`` parameter, which allows + colored output to be kept, while forcing the initialization of + ``Context.color = True``. This is `not allowed in current implementation + <https://github.com/pallets/click/issues/2110>`_ of + ``click.testing.CliRunner.invoke()`` because of colliding parameters. + + - Strips all ANSI codes from results if ``color`` was explicirely set to + ``False``. + + - Always prints a simulation of the CLI execution as the user would see it in + its terminal. Including colors. + + - Pretty-prints a formatted exception traceback if the command fails. + + :param cli: CLI to invoke. + :param *args: can be nested iterables composed of ``str``, + :py:class:`pathlib.Path` objects and ``None`` values. The nested structure + will be flattened and ``None`` values will be filtered out. Then all + elements will be casted to ``str``. See :func:`args_cleanup` for details. + :param input: same as ``click.testing.CliRunner.invoke()``. + :param env: same as ``click.testing.CliRunner.invoke()``. + :param catch_exceptions: same as ``click.testing.CliRunner.invoke()``. + :param color: If a boolean, the parameter will be passed as-is to + ``click.testing.CliRunner.isolation()``. If ``"forced"``, the parameter + will be passed as ``True`` to ``click.testing.CliRunner.isolation()`` and + an extra ``color=True`` parameter will be passed to the invoked CLI. + :param **extra: same as ``click.testing.CliRunner.invoke()``, but colliding + parameters are allowed and properly passed on to the invoked CLI. + """ + # Initialize ``extra`` if not provided. + if not extra: + extra = {} + + # Pop out the ``args`` parameter from ``extra`` and append it to the positional + # arguments. This situation append when the ``args`` parameter is passed as a + # keyword argument in + # ``pallets_sphinx_themes.themes.click.domain.ExampleRunner.invoke()``. + cli_args = list(args) + if "args" in extra: + cli_args.extend(extra.pop("args")) + # Flatten and filters out CLI arguments. + clean_args = args_cleanup(*cli_args) + + if color == "forced": + # Pass the color argument as an extra parameter to the invoked CLI. + extra["color"] = True + # TODO: investigate the possibility of forcing coloring on ``echo`` too, + # because by default, Windows is rendered colorless: + # https://github.com/pallets/click/blob/0c85d80/src/click/utils.py#L295-L296 + # echo_extra["color"] = True + + # The class attribute ``force_color`` overrides the ``color`` parameter. + if self.force_color: + isolation_color = True + # Cast to ``bool`` to avoid passing ``None`` or ``"forced"`` to ``invoke()``. + else: + isolation_color = bool(color) + + # No-op context manager without any effects. + extra_params_bypass: ContextManager = nullcontext() + + # If ``extra`` contains parameters that collide with the original ``invoke()`` + # parameters, we need to remove them from ``extra``, then use a monkeypatch to + # properly pass them to the CLI. + colliding_params = INVOKE_ARGS.intersection(extra) + if colliding_params: + # Transfer colliding parameters from ``extra`` to ``extra_bypass``. + extra_bypass = {pid: extra.pop(pid) for pid in colliding_params} + # Monkeypatch the original command's ``main()`` call to pass extra + # parameter for ``Context`` initialization. Because we cannot simply add + # colliding parameter IDs to ``**extra``. + extra_params_bypass = patch.object( + cli, + "main", + partial(cli.main, **extra_bypass), + ) + + with extra_params_bypass: + result = self.invoke2( + cli=cli, + args=clean_args, + input=input, + env=env, + catch_exceptions=catch_exceptions, + color=isolation_color, + **extra, + ) + + # ``color`` has been explicitly set to ``False``, so strip all ANSI codes. + if color is False: + result.stdout_bytes = strip_ansi(result.stdout_bytes) + result.stderr_bytes = strip_ansi(result.stderr_bytes) + result.output_bytes = strip_ansi(result.output_bytes) + + print_cli_run( + [self.get_default_prog_name(cli), *clean_args], + result, + env=env, + ) + + if result.exception: + print(ExceptionInfo.from_exc_info(*result.exc_info).get_formatted()) + + return result
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/conftest.html b/_modules/click_extra/tests/conftest.html new file mode 100644 index 000000000..75b8c3f65 --- /dev/null +++ b/_modules/click_extra/tests/conftest.html @@ -0,0 +1,668 @@ + + + + + + + + click_extra.tests.conftest - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.conftest

+# 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.
+"""Fixtures, configuration and helpers for tests."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import click
+import click.testing
+import cloup
+import pytest
+
+from click_extra.decorators import command, extra_command, extra_group, group
+from click_extra.platforms import is_linux, is_macos, is_windows
+from click_extra.testing import ExtraCliRunner
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from _pytest.mark import MarkDecorator
+    from _pytest.mark.structures import ParameterSet
+
+skip_linux = pytest.mark.skipif(is_linux(), reason="Skip Linux")
+"""Pytest mark to skip a test if run on a Linux system."""
+
+skip_macos = pytest.mark.skipif(is_macos(), reason="Skip macOS")
+"""Pytest mark to skip a test if run on a macOS system."""
+
+skip_windows = pytest.mark.skipif(is_windows(), reason="Skip Windows")
+"""Pytest mark to skip a test if run on a Windows system."""
+
+
+unless_linux = pytest.mark.skipif(not is_linux(), reason="Linux required")
+"""Pytest mark to skip a test unless it is run on a Linux system."""
+
+unless_macos = pytest.mark.skipif(not is_macos(), reason="macOS required")
+"""Pytest mark to skip a test unless it is run on a macOS system."""
+
+unless_windows = pytest.mark.skipif(not is_windows(), reason="Windows required")
+"""Pytest mark to skip a test unless it is run on a Windows system."""
+
+
+skip_windows_colors = skip_windows(reason="Click overstrip colors on Windows")
+"""Skips color tests on Windows as ``click.testing.invoke`` overzealously strips colors.
+
+See:
+- https://github.com/pallets/click/issues/2111
+- https://github.com/pallets/click/issues/2110
+"""
+
+
+
[docs]@pytest.fixture() +def extra_runner(): + """Runner fixture for ``click.testing.ExtraCliRunner``.""" + runner = ExtraCliRunner() + with runner.isolated_filesystem(): + yield runner
+ + +
[docs]@pytest.fixture() +def invoke(extra_runner): + """Invoke fixture shorthand for ``click.testing.ExtraCliRunner.invoke``.""" + return extra_runner.invoke
+ + +# XXX Support for decorator without parenthesis in Cloup has been reported upstream: +# https://github.com/janluke/cloup/issues/127 +skip_naked = pytest.mark.skip(reason="Naked decorator not supported yet.") + + +
[docs]def command_decorators( + no_commands: bool = False, + no_groups: bool = False, + no_click: bool = False, + no_cloup: bool = False, + no_redefined: bool = False, + no_extra: bool = False, + with_parenthesis: bool = True, + with_types: bool = False, +) -> tuple[ParameterSet, ...]: + """Returns collection of Pytest parameters to test all forms of click/cloup/click- + extra command-like decorators.""" + params: list[tuple[Any, set[str], str, tuple | MarkDecorator]] = [] + + if no_commands is False: + if not no_click: + params.append((click.command, {"click", "command"}, "click.command", ())) + if with_parenthesis: + params.append( + (click.command(), {"click", "command"}, "click.command()", ()), + ) + + if not no_cloup: + params.append( + (cloup.command, {"cloup", "command"}, "cloup.command", skip_naked), + ) + if with_parenthesis: + params.append( + (cloup.command(), {"cloup", "command"}, "cloup.command()", ()), + ) + + if not no_redefined: + params.append( + (command, {"redefined", "command"}, "click_extra.command", ()), + ) + if with_parenthesis: + params.append( + (command(), {"redefined", "command"}, "click_extra.command()", ()), + ) + + if not no_extra: + params.append( + ( + extra_command, + {"extra", "command"}, + "click_extra.extra_command", + (), + ), + ) + if with_parenthesis: + params.append( + ( + extra_command(), + {"extra", "command"}, + "click_extra.extra_command()", + (), + ), + ) + + if not no_groups: + if not no_click: + params.append((click.group, {"click", "group"}, "click.group", ())) + if with_parenthesis: + params.append((click.group(), {"click", "group"}, "click.group()", ())) + + if not no_cloup: + params.append((cloup.group, {"cloup", "group"}, "cloup.group", skip_naked)) + if with_parenthesis: + params.append((cloup.group(), {"cloup", "group"}, "cloup.group()", ())) + + if not no_redefined: + params.append((group, {"redefined", "group"}, "click_extra.group", ())) + if with_parenthesis: + params.append( + (group(), {"redefined", "group"}, "click_extra.group()", ()), + ) + + if not no_extra: + params.append( + ( + extra_group, + {"extra", "group"}, + "click_extra.extra_group", + (), + ), + ) + if with_parenthesis: + params.append( + ( + extra_group(), + {"extra", "group"}, + "click_extra.extra_group()", + (), + ), + ) + + decorator_params = [] + for deco, deco_type, label, marks in params: + args = [deco] + if with_types: + args.append(deco_type) + decorator_params.append(pytest.param(*args, id=label, marks=marks)) + + return tuple(decorator_params)
+ + +
[docs]@pytest.fixture() +def create_config(tmp_path): + """A generic fixture to produce a temporary configuration file.""" + + def _create_config(filename: str | Path, content: str) -> Path: + """Create a fake configuration file.""" + config_path: Path + if isinstance(filename, str): + config_path = tmp_path.joinpath(filename) + else: + config_path = filename.resolve() + + # Create the missing folder structure, like "mkdir -p" does. + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(content) + + return config_path + + return _create_config
+ + +default_options_uncolored_help = ( + r" --time / --no-time Measure and print elapsed execution time." + r" \[default:\n" + r" no-time\]\n" + r" --color, --ansi / --no-color, --no-ansi\n" + r" Strip out all colors and all ANSI codes from" + r" output.\n" + r" \[default: color\]\n" + r" -C, --config CONFIG_PATH Location of the configuration file. Supports glob\n" + r" pattern of local path and remote URL." + r" \[default:( \S+)?\n" + r"( .+\n)*" + r" \S+\.{toml,yaml,yml,json,ini,xml}\]\n" + r" --show-params Show all CLI parameters, their provenance, defaults\n" + r" and value, then exit.\n" + r" -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.\n" + r" \[default: WARNING\]\n" + r" --version Show the version and exit.\n" + r" -h, --help Show this message and exit.\n" +) + + +default_options_colored_help = ( + r" \x1b\[36m--time\x1b\[0m / \x1b\[36m--no-time\x1b\[0m" + r" Measure and print elapsed execution time." + r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault:\n" + r" " + r"\x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3mno-time\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + r" \x1b\[36m--color\x1b\[0m, \x1b\[36m--ansi\x1b\[0m /" + r" \x1b\[36m--no-color\x1b\[0m, \x1b\[36m--no-ansi\x1b\[0m\n" + r" Strip out all colors and all ANSI codes from" + r" output.\n" + r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault:" + r" \x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3mcolor\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + r" \x1b\[36m-C\x1b\[0m, \x1b\[36m--config\x1b\[0m" + r" \x1b\[36m\x1b\[2mCONFIG_PATH\x1b\[0m" + r" Location of the configuration file. Supports glob\n" + r" pattern of local path and remote URL." + r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault:( \S+)?\n" + r"( .+\n)*" + r" " + r"\S+\.{toml,yaml,yml,json,ini,xml}\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + r" \x1b\[36m--show-params\x1b\[0m" + r" Show all CLI parameters, their provenance, defaults\n" + r" and value, then exit.\n" + r" \x1b\[36m-v\x1b\[0m, \x1b\[36m--verbosity\x1b\[0m" + r" \x1b\[36m\x1b\[2mLEVEL\x1b\[0m" + r" Either \x1b\[35mCRITICAL\x1b\[0m, \x1b\[35mERROR\x1b\[0m, " + r"\x1b\[35mWARNING\x1b\[0m, \x1b\[35mINFO\x1b\[0m, \x1b\[35mDEBUG\x1b\[0m.\n" + r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault: " + r"\x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3mWARNING\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + r" \x1b\[36m--version\x1b\[0m Show the version and exit.\n" + r" \x1b\[36m-h\x1b\[0m, \x1b\[36m--help\x1b\[0m" + r" Show this message and exit.\n" +) + + +default_debug_uncolored_logging = ( + r"debug: Set <Logger click_extra \(DEBUG\)> to DEBUG.\n" + r"debug: Set <RootLogger root \(DEBUG\)> to DEBUG.\n" +) +default_debug_colored_logging = ( + r"\x1b\[34mdebug\x1b\[0m: Set <Logger click_extra \(DEBUG\)> to DEBUG.\n" + r"\x1b\[34mdebug\x1b\[0m: Set <RootLogger root \(DEBUG\)> to DEBUG.\n" +) + + +default_debug_uncolored_config = ( + r"debug: Load configuration matching .+\*\.{toml,yaml,yml,json,ini,xml}\n" + r"debug: Pattern is not an URL: search local file system.\n" + r"debug: No configuration file found.\n" +) +default_debug_colored_config = ( + r"\x1b\[34mdebug\x1b\[0m: Load configuration" + r" matching .+\*\.{toml,yaml,yml,json,ini,xml}\n" + r"\x1b\[34mdebug\x1b\[0m: Pattern is not an URL: search local file system.\n" + r"\x1b\[34mdebug\x1b\[0m: No configuration file found.\n" +) + + +default_debug_uncolored_version_details = ( + "debug: Version string template variables:\n" + r"debug: {module} : <module '\S+' from '.+'>\n" + r"debug: {module_name} : \S+\n" + r"debug: {module_file} : .+\n" + r"debug: {module_version} : \S+\n" + r"debug: {package_name} : \S+\n" + r"debug: {package_version}: \S+\n" + r"debug: {exec_name} : \S+\n" + r"debug: {version} : \S+\n" + r"debug: {prog_name} : \S+\n" + r"debug: {env_info} : {.*}\n" +) +default_debug_colored_version_details = ( + r"\x1b\[34mdebug\x1b\[0m: Version string template variables:\n" + r"\x1b\[34mdebug\x1b\[0m: {module} : <module '\S+' from '.+'>\n" + r"\x1b\[34mdebug\x1b\[0m: {module_name} : \x1b\[97m\S+\x1b\[0m\n" + r"\x1b\[34mdebug\x1b\[0m: {module_file} : .+\n" + r"\x1b\[34mdebug\x1b\[0m: {module_version} : \x1b\[32m\S+\x1b\[0m\n" + r"\x1b\[34mdebug\x1b\[0m: {package_name} : \x1b\[97m\S+\x1b\[0m\n" + r"\x1b\[34mdebug\x1b\[0m: {package_version}: \x1b\[32m\S+\x1b\[0m\n" + r"\x1b\[34mdebug\x1b\[0m: {exec_name} : \x1b\[97m\S+\x1b\[0m\n" + r"\x1b\[34mdebug\x1b\[0m: {version} : \x1b\[32m\S+\x1b\[0m\n" + r"\x1b\[34mdebug\x1b\[0m: {prog_name} : \x1b\[97m\S+\x1b\[0m\n" + r"\x1b\[34mdebug\x1b\[0m: {env_info} : \x1b\[90m{.*}\x1b\[0m\n" +) + + +default_debug_uncolored_log_start = ( + default_debug_uncolored_logging + + default_debug_uncolored_config + + default_debug_uncolored_version_details +) +default_debug_colored_log_start = ( + default_debug_colored_logging + + default_debug_colored_config + + default_debug_colored_version_details +) + + +default_debug_uncolored_log_end = ( + r"debug: Reset <RootLogger root \(DEBUG\)> to WARNING.\n" + r"debug: Reset <Logger click_extra \(DEBUG\)> to WARNING.\n" +) +default_debug_colored_log_end = ( + r"\x1b\[34mdebug\x1b\[0m: Reset <RootLogger root \(DEBUG\)> to WARNING.\n" + r"\x1b\[34mdebug\x1b\[0m: Reset <Logger click_extra \(DEBUG\)> to WARNING.\n" +) +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_colorize.html b/_modules/click_extra/tests/test_colorize.html new file mode 100644 index 000000000..ad027f1c3 --- /dev/null +++ b/_modules/click_extra/tests/test_colorize.html @@ -0,0 +1,1125 @@ + + + + + + + + click_extra.tests.test_colorize - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_colorize

+# 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.
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+from textwrap import dedent
+
+import click
+import cloup
+import pytest
+from boltons.strutils import strip_ansi
+from pytest_cases import parametrize
+
+from click_extra import (
+    Color,
+    ExtraCommand,
+    ExtraContext,
+    ExtraOption,
+    HelpTheme,
+    IntRange,
+    Style,
+    argument,
+    echo,
+    option,
+    option_group,
+    pass_context,
+    secho,
+    style,
+)
+from click_extra.colorize import (
+    HelpExtraFormatter,
+    HelpExtraTheme,
+    highlight,
+)
+from click_extra.colorize import (
+    default_theme as theme,
+)
+from click_extra.decorators import (
+    color_option,
+    command,
+    extra_command,
+    extra_group,
+    help_option,
+    verbosity_option,
+)
+from click_extra.logging import LOG_LEVELS
+
+from .conftest import (
+    command_decorators,
+    default_debug_colored_log_end,
+    default_debug_colored_log_start,
+    default_debug_colored_logging,
+    default_debug_uncolored_log_end,
+    default_debug_uncolored_log_start,
+    default_debug_uncolored_logging,
+    default_options_colored_help,
+    skip_windows_colors,
+)
+
+
+
[docs]def test_theme_definition(): + """Ensure we do not leave any property we would have inherited from cloup and + logging primitives.""" + assert ( + set(HelpTheme.__dataclass_fields__) + <= HelpExtraTheme.__dataclass_fields__.keys() + ) + + log_levels = {level.lower() for level in LOG_LEVELS} + assert log_levels <= HelpExtraTheme.__dataclass_fields__.keys() + assert log_levels.isdisjoint(HelpTheme.__dataclass_fields__)
+ + +
[docs]def test_extra_theme(): + theme = HelpExtraTheme() + + # Check the same instance is returned when no attribute is set. + assert theme.with_() == theme + assert theme.with_() is theme + + # Check that we can't set a non-existing attribute. + with pytest.raises(TypeError): + theme.with_(random_arg=Style()) + + # Create a new theme with a different color. + assert theme.choice != Style(fg=Color.magenta) + new_theme = theme.with_(choice=Style(fg=Color.magenta)) + assert new_theme != theme + assert new_theme is not theme + assert new_theme.choice == Style(fg=Color.magenta) + + # Derives a second theme from the first one. + second_theme = new_theme.with_(choice=Style(fg=Color.magenta)) + assert second_theme == new_theme + assert second_theme is new_theme
+ + +
[docs]@pytest.mark.parametrize( + ("opt", "expected_outputs"), + ( + # Short option. + ( + # Short option name is highlighted in both the synopsis and the description. + ExtraOption(["-e"], help="Option -e (-e), not -ee or --e."), + ( + f" {theme.option('-e')} {theme.metavar('TEXT')} ", + f" Option {theme.option('-e')} ({theme.option('-e')}), not -ee or --e.", + ), + ), + # Long option. + ( + # Long option name is highlighted in both the synopsis and the description. + ExtraOption(["--exclude"], help="Option named --exclude."), + ( + f" {theme.option('--exclude')} {theme.metavar('TEXT')} ", + f" Option named {theme.option('--exclude')}.", + ), + ), + # Default value. + ( + ExtraOption(["--n"], default=1, show_default=True), + ( + f" {theme.option('--n')} {theme.metavar('INTEGER')} ", + f" {theme.bracket('[')}" + f"{theme.bracket('default: ')}" + f"{theme.default('1')}" + f"{theme.bracket(']')}", + ), + ), + # Dynamic default. + ( + ExtraOption( + ["--username"], + prompt=True, + default=lambda: os.environ.get("USER", ""), + show_default="current user", + ), + ( + f" {theme.option('--username')} {theme.metavar('TEXT')} ", + f" {theme.bracket('[')}" + f"{theme.bracket('default: ')}" + f"{theme.default('(current user)')}" + f"{theme.bracket(']')}", + ), + ), + # Required option. + ( + ExtraOption(["--x"], required=True, type=int), + ( + f" {theme.option('--x')} {theme.metavar('INTEGER')} ", + f" {theme.bracket('[')}" + f"{theme.bracket('required')}" + f"{theme.bracket(']')}", + ), + ), + # Required and default value. + ( + ExtraOption(["--y"], default=1, required=True, show_default=True), + ( + f" {theme.option('--y')} {theme.metavar('INTEGER')} ", + f" {theme.bracket('[')}" + f"{theme.bracket('default: ')}" + f"{theme.default('1')}" + f"{theme.bracket('; ')}" + f"{theme.bracket('required')}" + f"{theme.bracket(']')}", + ), + ), + # Range option. + ( + ExtraOption(["--digit"], type=IntRange(0, 9)), + ( + f" {theme.option('--digit')} {theme.metavar('INTEGER RANGE')} ", + f" {theme.bracket('[')}" + f"{theme.bracket('0<=x<=9')}" + f"{theme.bracket(']')}", + ), + ), + # Boolean flags. + ( + # Option flag and its opposite names are highlighted, including in the + # description. + ExtraOption( + ["--flag/--no-flag"], + default=False, + help="Auto --no-flag and --flag options.", + ), + ( + f" {theme.option('--flag')} / {theme.option('--no-flag')} ", + f" Auto {theme.option('--no-flag')}" + f" and {theme.option('--flag')} options.", + ), + ), + ( + # Option with single flag is highlighted, but not its negative. + ExtraOption( + ["--shout"], + is_flag=True, + help="Auto --shout but no --no-shout.", + ), + ( + f" {theme.option('--shout')} ", + f" Auto {theme.option('--shout')} but no --no-shout.", + ), + ), + ( + # Option flag with alternative leading symbol. + ExtraOption( + ["/debug;/no-debug"], + help="Auto /no-debug and /debug options.", + ), + ( + f" {theme.option('/debug')}; {theme.option('/no-debug')} ", + f" Auto {theme.option('/no-debug')}" + f" and {theme.option('/debug')} options.", + ), + ), + ( + # Option flag with alternative leading symbol. + ExtraOption(["+w/-w"], help="Auto +w, and -w. Not ++w or -woo."), + ( + f" {theme.option('+w')} / {theme.option('-w')} ", + f" Auto {theme.option('+w')}, and {theme.option('-w')}." + " Not ++w or -woo.", + ), + ), + ( + # Option flag, and its short and negative name are highlighted. + ExtraOption( + ["--shout/--no-shout", " /-S"], + default=False, + help="Auto --shout, --no-shout and -S.", + ), + ( + f" {theme.option('--shout')} / {theme.option('-S')}," + f" {theme.option('--no-shout')} ", + f" Auto {theme.option('--shout')}, {theme.option('--no-shout')}" + f" and {theme.option('-S')}.", + ), + ), + # Choices. + ( + # Choices after the option name are highlighted. Case is respected. + ExtraOption( + ["--manager"], + type=click.Choice(["apm", "apt", "brew"]), + help="apt, APT (not aptitude or apt_mint) and brew.", + ), + ( + f" {theme.option('--manager')} " + f"[{theme.choice('apm')}|{theme.choice('apt')}" + f"|{theme.choice('brew')}] ", + f" {theme.choice('apt')}, APT (not aptitude or apt_mint) and" + f" {theme.choice('brew')}.", + ), + ), + # Tuple option. + ( + ExtraOption(["--item"], type=(str, int), help="Option with tuple type."), + (f" {theme.option('--item')} {theme.metavar('<TEXT INTEGER>...')} ",), + ), + # Metavar. + ( + # Metavar after the option name is highlighted. + ExtraOption( + ["--special"], + metavar="SPECIAL", + help="Option with SPECIAL metavar.", + ), + ( + f" {theme.option('--special')} {theme.metavar('SPECIAL')} ", + f" Option with {theme.metavar('SPECIAL')} metavar.", + ), + ), + # Envvars. + ( + # All envvars in square brackets are highlighted. + ExtraOption( + ["--flag1"], + is_flag=True, + envvar=["custom1", "FLAG1"], + show_envvar=True, + ), + ( + f" {theme.option('--flag1')} ", + f" {theme.bracket('[')}" + f"{theme.bracket('env var: ')}" + f"{theme.envvar('custom1, FLAG1, TEST_FLAG1')}" + f"{theme.bracket(']')}", + ), + ), + ( + # Envvars and default. + ExtraOption( + ["--flag1"], + default=1, + envvar="custom1", + show_envvar=True, + show_default=True, + ), + ( + f" {theme.option('--flag1')} ", + f" {theme.bracket('[')}" + f"{theme.bracket('env var: ')}" + f"{theme.envvar('custom1, TEST_FLAG1')}" + f"{theme.bracket('; ')}" + f"{theme.bracket('default: ')}" + f"{theme.default('1')}" + f"{theme.bracket(']')}", + ), + ), + ), +) +def test_option_highlight(opt, expected_outputs): + """Test highlighting of all option's variations.""" + # Add option to a dummy command. + cli = ExtraCommand("test", params=[opt]) + ctx = ExtraContext(cli) + + # Render full CLI help. + help = cli.get_help(ctx) + + # TODO: check extra elements of the option once + # https://github.com/pallets/click/pull/2517 is released. + # opt.get_help_extra() + + # Check that the option is highlighted. + for expected in expected_outputs: + assert expected in help
+ + +
[docs]def test_only_full_word_highlight(): + formatter = HelpExtraFormatter() + formatter.write("package snapshot") + + formatter.choices.add("snap") + + output = formatter.getvalue() + # Make sure no highlighting occurred + assert strip_ansi(output) == output
+ + +
[docs]@skip_windows_colors +def test_keyword_collection(invoke): + # Create a dummy Click CLI. + @extra_group + @option_group( + "Group 1", + option("-a", "--o1"), + option("-b", "--o2"), + ) + @cloup.option_group( + "Group 2", + option("--o3", metavar="MY_VAR"), + option("--o4"), + ) + @option("--test") + # Windows-style parameters. + @option("--boolean/--no-boolean", "-b/+B", is_flag=True) + @option("/debug;/no-debug") + # First option without an alias. + @option("--shout/--no-shout", " /-S", default=False) + def color_cli1(o1, o2, o3, o4, test, boolean, debug, shout): + echo("It works!") + + @extra_command(params=None) + @argument("MY_ARG", nargs=-1, help="Argument supports help.") + def command1(my_arg): + """CLI description with extra MY_VAR reference.""" + echo("Run click-extra command #1...") + + @cloup.command() + def command2(): + echo("Run cloup command #2...") + + @click.command + def command3(): + echo("Run click command #3...") + + @command(deprecated=True) + def command4(): + echo("Run click-extra command #4...") + + color_cli1.section("Subcommand group 1", command1, command2) + color_cli1.section("Extra commands", command3, command4) + + help_screen = ( + r"\x1b\[94m\x1b\[1m\x1b\[4mUsage:\x1b\[0m \x1b\[97mcolor-cli1\x1b\[0m " + r"\x1b\[36m\x1b\[2m\[OPTIONS\]\x1b\[0m" + r" \x1b\[36m\x1b\[2mCOMMAND \[ARGS\]...\x1b\[0m\n\n" + r"\x1b\[94m\x1b\[1m\x1b\[4mGroup 1:\x1b\[0m\n" + r" \x1b\[36m-a\x1b\[0m, \x1b\[36m--o1\x1b\[0m \x1b\[36m\x1b\[2mTEXT\x1b\[0m\n" + r" \x1b\[36m-b\x1b\[0m, \x1b\[36m--o2\x1b\[0m \x1b\[36m\x1b\[2mTEXT\x1b\[0m\n" + r"\n" + r"\x1b\[94m\x1b\[1m\x1b\[4mGroup 2:\x1b\[0m\n" + r" \x1b\[36m--o3\x1b\[0m \x1b\[36m\x1b\[2mMY_VAR\x1b\[0m\n" + r" \x1b\[36m--o4\x1b\[0m \x1b\[36m\x1b\[2mTEXT\x1b\[0m\n\n" + r"\x1b\[94m\x1b\[1m\x1b\[4mOther options:\x1b\[0m\n" + r" \x1b\[36m--test\x1b\[0m \x1b\[36m\x1b\[2mTEXT\x1b\[0m\n" + r" \x1b\[36m-b\x1b\[0m, \x1b\[36m--boolean\x1b\[0m / \x1b\[36m\+B\x1b\[0m," + r" \x1b\[36m--no-boolean\x1b\[0m\n" + r" " + r"\x1b\[2m\[\x1b\[0m\x1b\[2mdefault: " + r"\x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3mno-boolean\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + r" \x1b\[36m/debug\x1b\[0m; \x1b\[36m/no-debug\x1b\[0m" + r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault:" + r" \x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3mno-debug\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + r" \x1b\[36m--shout\x1b\[0m / \x1b\[36m-S\x1b\[0m, \x1b\[36m--no-shout\x1b\[0m" + r" \x1b\[2m\[\x1b\[0m\x1b\[2mdefault: " + r"\x1b\[0m\x1b\[32m\x1b\[2m\x1b\[3mno-shout\x1b\[0m\x1b\[2m\]\x1b\[0m\n" + rf"{default_options_colored_help}" + r"\n" + r"\x1b\[94m\x1b\[1m\x1b\[4mSubcommand group 1:\x1b\[0m\n" + r" \x1b\[36mcommand1\x1b\[0m CLI description with extra" + r" \x1b\[36m\x1b\[2mMY_VAR\x1b\[0m reference.\n" + r" \x1b\[36mcommand2\x1b\[0m\n\n" + r"\x1b\[94m\x1b\[1m\x1b\[4mExtra commands:\x1b\[0m\n" + r" \x1b\[36mcommand3\x1b\[0m\n" + r" \x1b\[36mcommand4\x1b\[0m \x1b\[93m\x1b\[1m\(Deprecated\)\x1b\[0m\n" + ) + + result = invoke(color_cli1, "--help", color=True) + assert result.exit_code == 0 + assert re.fullmatch(help_screen, result.stdout) + assert not result.stderr + + result = invoke(color_cli1, "-h", color=True) + assert result.exit_code == 0 + assert re.fullmatch(help_screen, result.stdout) + assert not result.stderr + + # CLI main group is invoked before sub-command. + result = invoke(color_cli1, "command1", "--help", color=True) + assert result.exit_code == 0 + assert result.stdout == ( + "It works!\n" + "\x1b[94m\x1b[1m\x1b[4mUsage:\x1b[0m \x1b[97mcolor-cli1 command1\x1b[0m" + " \x1b[36m\x1b[2m[OPTIONS]\x1b[0m [\x1b[36mMY_ARG\x1b[0m]...\n" + "\n" + " CLI description with extra MY_VAR reference.\n" + "\n" + "\x1b[94m\x1b[1m\x1b[4mPositional arguments:\x1b[0m\n" + " [\x1b[36mMY_ARG\x1b[0m]... Argument supports help.\n" + "\n" + "\x1b[94m\x1b[1m\x1b[4mOptions:\x1b[0m\n" + " \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m Show this message and exit.\n" + ) + assert not result.stderr + + # Standalone call to command: CLI main group is skipped. + result = invoke(command1, "--help", color=True) + assert result.exit_code == 0 + assert result.stdout == ( + "\x1b[94m\x1b[1m\x1b[4mUsage:\x1b[0m \x1b[97mcommand1\x1b[0m" + " \x1b[36m\x1b[2m[OPTIONS]\x1b[0m [\x1b[36mMY_ARG\x1b[0m]...\n" + "\n" + " CLI description with extra MY_VAR reference.\n" + "\n" + "\x1b[94m\x1b[1m\x1b[4mPositional arguments:\x1b[0m\n" + " [\x1b[36mMY_ARG\x1b[0m]... Argument supports help.\n" + "\n" + "\x1b[94m\x1b[1m\x1b[4mOptions:\x1b[0m\n" + " \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m Show this message and exit.\n" + ) + assert not result.stderr + + # Non-click-extra commands are not colorized nor have extra options. + for cmd_id in ("command2", "command3"): + result = invoke(color_cli1, cmd_id, "--help", color=True) + assert result.exit_code == 0 + assert result.stdout == dedent( + f"""\ + It works! + Usage: color-cli1 {cmd_id} [OPTIONS] + + Options: + -h, --help Show this message and exit. + """, + ) + assert not result.stderr
+ + +
[docs]@skip_windows_colors +@parametrize("option_decorator", (color_option, color_option())) +@pytest.mark.parametrize( + ("param", "expecting_colors"), + ( + ("--color", True), + ("--no-color", False), + ("--ansi", True), + ("--no-ansi", False), + (None, True), + ), +) +def test_standalone_color_option(invoke, option_decorator, param, expecting_colors): + """Check color option values, defaults and effects on all things colored, including + verbosity option.""" + + @click.command + @verbosity_option + @option_decorator + def standalone_color(): + echo(Style(fg="yellow")("It works!")) + echo("\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m") + echo(style("Run command.", fg="magenta")) + logging.getLogger("click_extra").warning("Processing...") + print(style("print() bypass Click.", fg="blue")) + secho("Done.", fg="green") + + result = invoke(standalone_color, param, "--verbosity", "DEBUG", color=True) + assert result.exit_code == 0 + + if expecting_colors: + assert result.stdout == ( + "\x1b[33mIt works!\x1b[0m\n" + "\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m\n" + "\x1b[35mRun command.\x1b[0m\n" + "\x1b[34mprint() bypass Click.\x1b[0m\n" + "\x1b[32mDone.\x1b[0m\n" + ) + assert re.fullmatch( + ( + rf"{default_debug_colored_logging}" + r"\x1b\[33mwarning\x1b\[0m: Processing...\n" + rf"{default_debug_colored_log_end}" + ), + result.stderr, + ) + else: + assert result.stdout == ( + "It works!\n" + "Art\n" + "Run command.\n" + "\x1b[34mprint() bypass Click.\x1b[0m\n" + "Done.\n" + ) + assert re.fullmatch( + ( + rf"{default_debug_uncolored_logging}" + rf"warning: Processing\.\.\.\n" + rf"{default_debug_uncolored_log_end}" + ), + result.stderr, + )
+ + +
[docs]@skip_windows_colors +@pytest.mark.parametrize( + ("env", "env_expect_colors"), + ( + ({"COLOR": "True"}, True), + ({"COLOR": "true"}, True), + ({"COLOR": "1"}, True), + ({"COLOR": ""}, True), + ({"COLOR": "False"}, False), + ({"COLOR": "false"}, False), + ({"COLOR": "0"}, False), + ({"NO_COLOR": "True"}, False), + ({"NO_COLOR": "true"}, False), + ({"NO_COLOR": "1"}, False), + ({"NO_COLOR": ""}, False), + ({"NO_COLOR": "False"}, True), + ({"NO_COLOR": "false"}, True), + ({"NO_COLOR": "0"}, True), + (None, True), + ), +) +@pytest.mark.parametrize( + ("param", "param_expect_colors"), + ( + ("--color", True), + ("--no-color", False), + ("--ansi", True), + ("--no-ansi", False), + (None, True), + ), +) +def test_no_color_env_convention( + invoke, + env, + env_expect_colors, + param, + param_expect_colors, +): + @click.command + @color_option + def color_cli7(): + echo(Style(fg="yellow")("It works!")) + + result = invoke(color_cli7, param, color=True, env=env) + assert result.exit_code == 0 + assert not result.stderr + + # Params always overrides env's expectations. + expecting_colors = env_expect_colors + if param: + expecting_colors = param_expect_colors + + if expecting_colors: + assert result.stdout == "\x1b[33mIt works!\x1b[0m\n" + else: + assert result.stdout == "It works!\n"
+ + +# TODO: test with configuration file + + +
[docs]@skip_windows_colors +@pytest.mark.parametrize( + ("param", "expecting_colors"), + ( + ("--color", True), + ("--no-color", False), + ("--ansi", True), + ("--no-ansi", False), + (None, True), + ), +) +def test_integrated_color_option(invoke, param, expecting_colors): + """Check effect of color option on all things colored, including verbosity option. + + Also checks the color option in subcommands is inherited from parent context. + """ + + @extra_group + @pass_context + def color_cli8(ctx): + echo(f"ctx.color={ctx.color}") + echo(Style(fg="yellow")("It works!")) + echo("\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m") + + @color_cli8.command() + @pass_context + def command1(ctx): + echo(f"ctx.color={ctx.color}") + echo(style("Run command #1.", fg="magenta")) + logging.getLogger("click_extra").warning("Processing...") + print(style("print() bypass Click.", fg="blue")) + secho("Done.", fg="green") + + result = invoke(color_cli8, param, "--verbosity", "DEBUG", "command1", color=True) + + assert result.exit_code == 0 + if expecting_colors: + assert result.stdout == ( + "ctx.color=True\n" + "\x1b[33mIt works!\x1b[0m\n" + "\x1b[0m\x1b[1;36mArt\x1b[46;34m\x1b[0m\n" + "ctx.color=True\n" + "\x1b[35mRun command #1.\x1b[0m\n" + "\x1b[34mprint() bypass Click.\x1b[0m\n" + "\x1b[32mDone.\x1b[0m\n" + ) + assert re.fullmatch( + ( + rf"{default_debug_colored_log_start}" + r"\x1b\[33mwarning\x1b\[0m: Processing...\n" + rf"{default_debug_colored_log_end}" + ), + result.stderr, + ) + + else: + assert result.stdout == ( + "ctx.color=False\n" + "It works!\n" + "Art\n" + "ctx.color=False\n" + "Run command #1.\n" + "\x1b[34mprint() bypass Click.\x1b[0m\n" + "Done.\n" + ) + assert re.fullmatch( + ( + rf"{default_debug_uncolored_log_start}" + rf"warning: Processing\.\.\.\n" + rf"{default_debug_uncolored_log_end}" + ), + result.stderr, + )
+ + +
[docs]@pytest.mark.parametrize( + ("substrings", "expected", "ignore_case"), + ( + # Function input types. + (["hey"], "Hey-xx-xxx-heY-xXxXxxxxx-\x1b[32mhey\x1b[0m", False), + (("hey",), "Hey-xx-xxx-heY-xXxXxxxxx-\x1b[32mhey\x1b[0m", False), + ({"hey"}, "Hey-xx-xxx-heY-xXxXxxxxx-\x1b[32mhey\x1b[0m", False), + ( + "hey", + "H\x1b[32mey\x1b[0m-xx-xxx-\x1b[32mhe\x1b[0mY-xXxXxxxxx-\x1b[32mhey\x1b[0m", + False, + ), + # Duplicate substrings. + (["hey", "hey"], "Hey-xx-xxx-heY-xXxXxxxxx-\x1b[32mhey\x1b[0m", False), + (("hey", "hey"), "Hey-xx-xxx-heY-xXxXxxxxx-\x1b[32mhey\x1b[0m", False), + ({"hey", "hey"}, "Hey-xx-xxx-heY-xXxXxxxxx-\x1b[32mhey\x1b[0m", False), + ( + "heyhey", + "H\x1b[32mey\x1b[0m-xx-xxx-\x1b[32mhe\x1b[0mY-xXxXxxxxx-\x1b[32mhey\x1b[0m", + False, + ), + # Case-sensitivity and multiple matches. + (["hey"], "Hey-xx-xxx-heY-xXxXxxxxx-\x1b[32mhey\x1b[0m", False), + ( + ["Hey"], + "\x1b[32mHey\x1b[0m-xx-xxx-\x1b[32mheY\x1b[0m-xXxXxxxxx-\x1b[32mhey\x1b[0m", + True, + ), + ( + "x", + "Hey-\x1b[32mxx\x1b[0m-\x1b[32mxxx\x1b[0m-heY-\x1b[32mx\x1b[0mX\x1b[32mx\x1b[0mX\x1b[32mxxxxx\x1b[0m-hey", + False, + ), + ( + "x", + "Hey-\x1b[32mxx\x1b[0m-\x1b[32mxxx\x1b[0m-heY-\x1b[32mxXxXxxxxx\x1b[0m-hey", + True, + ), + # Overlaps. + ( + ["xx"], + "Hey-\x1b[32mxx\x1b[0m-\x1b[32mxxx\x1b[0m-heY-\x1b[32mxXxXxxxxx\x1b[0m-hey", + True, + ), + ( + ["xx"], + "Hey-\x1b[32mxx\x1b[0m-\x1b[32mxxx\x1b[0m-heY-xXxX\x1b[32mxxxxx\x1b[0m-hey", + False, + ), + # No match. + ("z", "Hey-xx-xxx-heY-xXxXxxxxx-hey", False), + (["XX"], "Hey-xx-xxx-heY-xXxXxxxxx-hey", False), + ), +) +def test_substring_highlighting(substrings, expected, ignore_case): + result = highlight( + "Hey-xx-xxx-heY-xXxXxxxxx-hey", + substrings, + styling_method=theme.success, + ignore_case=ignore_case, + ) + assert result == expected
+ + +
[docs]@parametrize( + "cmd_decorator, cmd_type", + # Skip click extra's commands, as help option is already part of the default. + command_decorators(no_extra=True, with_types=True), +) +@parametrize("option_decorator", (help_option, help_option())) +def test_standalone_help_option(invoke, cmd_decorator, cmd_type, option_decorator): + @cmd_decorator + @option_decorator + def standalone_help(): + echo("It works!") + + result = invoke(standalone_help, "--help") + assert result.exit_code == 0 + assert not result.stderr + + if "group" in cmd_type: + assert result.stdout == dedent( + """\ + Usage: standalone-help [OPTIONS] COMMAND [ARGS]... + + Options: + -h, --help Show this message and exit. + """, + ) + else: + assert result.stdout == dedent( + """\ + Usage: standalone-help [OPTIONS] + + Options: + -h, --help Show this message and exit. + """, + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_commands.html b/_modules/click_extra/tests/test_commands.html new file mode 100644 index 000000000..cc99e0dba --- /dev/null +++ b/_modules/click_extra/tests/test_commands.html @@ -0,0 +1,815 @@ + + + + + + + + click_extra.tests.test_commands - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_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.
+"""Test defaults of our custom commands, as well as their customizations and attached
+options, and how they interact with each others."""
+
+from __future__ import annotations
+
+import ast
+import inspect
+import re
+from pathlib import Path
+from textwrap import dedent
+
+import click
+import cloup
+import pytest
+from pytest_cases import fixture, parametrize
+
+from click_extra import echo, option, option_group, pass_context
+from click_extra.decorators import extra_command, extra_group
+
+from .conftest import (
+    command_decorators,
+    default_debug_uncolored_log_end,
+    default_debug_uncolored_log_start,
+    default_options_colored_help,
+    default_options_uncolored_help,
+    skip_windows_colors,
+)
+
+
+
[docs]def test_module_root_declarations(): + def fetch_root_members(module): + """Fetch all members exposed at the module root.""" + members = set() + for name, member in inspect.getmembers(module): + # Exclude private members. + if name.startswith("_"): + continue + # Exclude automatic imports of submodules as we inspect __init__'s content + # only. + if inspect.ismodule(member): + continue + members.add(name) + return members + + click_members = fetch_root_members(click) + + cloup_members = {m for m in cloup.__all__ if not m.startswith("_")} + + tree = ast.parse(Path(__file__).parent.joinpath("../__init__.py").read_bytes()) + click_extra_members = [] + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if target.id == "__all__": + for element in node.value.elts: + click_extra_members.append(element.s) + + assert click_members <= set(click_extra_members) + assert cloup_members <= set(click_extra_members) + + expected_members = sorted( + click_members.union(cloup_members).union(click_extra_members), + key=lambda m: (m.lower(), m), + ) + assert expected_members == click_extra_members
+ + +
[docs]@fixture +def all_command_cli(): + """A CLI that is mixing all variations and flavors of subcommands.""" + + @extra_group(version="2021.10.08") + def command_cli1(): + echo("It works!") + + @command_cli1.command() + def default_subcommand(): + echo("Run default subcommand...") + + @extra_command + def click_extra_subcommand(): + echo("Run click-extra subcommand...") + + @cloup.command() + def cloup_subcommand(): + echo("Run cloup subcommand...") + + @click.command + def click_subcommand(): + echo("Run click subcommand...") + + command_cli1.section( + "Subcommand group", + click_extra_subcommand, + cloup_subcommand, + click_subcommand, + ) + + return command_cli1
+ + +help_screen = ( + r"Usage: command-cli1 \[OPTIONS\] COMMAND \[ARGS\]...\n" + r"\n" + r"Options:\n" + rf"{default_options_uncolored_help}" + r"\n" + r"Subcommand group:\n" + r" click-extra-subcommand\n" + r" cloup-subcommand\n" + r" click-subcommand\n" + r"\n" + r"Other commands:\n" + r" default-subcommand\n" +) + + +
[docs]def test_unknown_option(invoke, all_command_cli): + result = invoke(all_command_cli, "--blah") + assert result.exit_code == 2 + assert not result.stdout + assert "Error: No such option: --blah" in result.stderr
+ + +
[docs]def test_unknown_command(invoke, all_command_cli): + result = invoke(all_command_cli, "blah") + assert result.exit_code == 2 + assert not result.stdout + assert "Error: No such command 'blah'." in result.stderr
+ + +
[docs]def test_required_command(invoke, all_command_cli): + result = invoke(all_command_cli, "--verbosity", "DEBUG", color=False) + assert result.exit_code == 2 + # In debug mode, the version is always printed. + assert not result.stdout + assert re.fullmatch( + ( + rf"{default_debug_uncolored_log_start}" + rf"{default_debug_uncolored_log_end}" + r"Usage: command-cli1 \[OPTIONS\] COMMAND \[ARGS\]...\n" + r"\n" + r"Error: Missing command.\n" + ), + result.stderr, + )
+ + +
[docs]@pytest.mark.parametrize("param", (None, "-h", "--help")) +def test_group_help(invoke, all_command_cli, param): + result = invoke(all_command_cli, param, color=False) + assert result.exit_code == 0 + assert re.fullmatch(help_screen, result.stdout) + assert "It works!" not in result.stdout + assert not result.stderr
+ + +
[docs]@pytest.mark.parametrize( + "params", + ("--version", "blah", ("--verbosity", "DEBUG"), ("--config", "random.toml")), +) +def test_help_eagerness(invoke, all_command_cli, params): + """See: https://click.palletsprojects.com/en/8.0.x/advanced/#callback-evaluation- + order. + """ + result = invoke(all_command_cli, "--help", params, color=False) + assert result.exit_code == 0 + assert re.fullmatch(help_screen, result.stdout) + assert "It works!" not in result.stdout + assert not result.stderr
+ + +
[docs]@skip_windows_colors +@pytest.mark.parametrize("cmd_id", ("default", "click-extra", "cloup", "click")) +@pytest.mark.parametrize("param", ("-h", "--help")) +def test_subcommand_help(invoke, all_command_cli, cmd_id, param): + result = invoke(all_command_cli, f"{cmd_id}-subcommand", param) + assert result.exit_code == 0 + assert not result.stderr + + colored_help_header = ( + r"It works!\n" + r"\x1b\[94m\x1b\[1m\x1b\[4mUsage:\x1b\[0m " + rf"\x1b\[97mcommand-cli1 {cmd_id}-subcommand\x1b\[0m" + r" \x1b\[36m\x1b\[2m\[OPTIONS\]\x1b\[0m\n" + r"\n" + r"\x1b\[94m\x1b\[1m\x1b\[4mOptions:\x1b\[0m\n" + ) + + # Extra sucommands are colored and include all extra options. + if cmd_id == "click-extra": + assert re.fullmatch( + rf"{colored_help_header}{default_options_colored_help}", + result.stdout, + ) + + # Default subcommand inherits from extra family and is colored, but does not include + # extra options. + elif cmd_id == "default": + assert re.fullmatch( + ( + rf"{colored_help_header}" + r" \x1b\[36m-h\x1b\[0m, \x1b\[36m--help\x1b\[0m" + r" Show this message and exit.\n" + ), + result.stdout, + ) + + # Non-extra subcommands are not colored. + else: + assert result.stdout == dedent( + f"""\ + It works! + Usage: command-cli1 {cmd_id}-subcommand [OPTIONS] + + Options: + -h, --help Show this message and exit. + """, + )
+ + +
[docs]@pytest.mark.parametrize("cmd_id", ("default", "click-extra", "cloup", "click")) +def test_subcommand_execution(invoke, all_command_cli, cmd_id): + result = invoke(all_command_cli, f"{cmd_id}-subcommand", color=False) + assert result.exit_code == 0 + assert result.stdout == dedent( + f"""\ + It works! + Run {cmd_id} subcommand... + """, + ) + assert not result.stderr
+ + +
[docs]def test_integrated_version_value(invoke, all_command_cli): + result = invoke(all_command_cli, "--version", color=False) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == "command-cli1, version 2021.10.08\n"
+ + +
[docs]@skip_windows_colors +@parametrize( + "cmd_decorator", + command_decorators( + no_click=True, + no_cloup=True, + no_redefined=True, + with_parenthesis=False, + ), +) +@pytest.mark.parametrize("param", ("-h", "--help")) +def test_colored_bare_help(invoke, cmd_decorator, param): + """Extra decorators are always colored. + + Even when stripped of their default parameters, as reported in: + https://github.com/kdeldycke/click-extra/issues/534 + https://github.com/kdeldycke/click-extra/pull/543 + """ + + @cmd_decorator(params=None) + def bare_cli(): + pass + + result = invoke(bare_cli, param) + assert result.exit_code == 0 + assert not result.stderr + assert ( + "\n" + "\x1b[94m\x1b[1m\x1b[4mOptions:\x1b[0m\n" + " \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m Show this message and exit.\n" + ) in result.stdout
+ + +
[docs]def test_no_option_leaks_between_subcommands(invoke): + """As reported in https://github.com/kdeldycke/click-extra/issues/489.""" + + @click.group + def cli(): + echo("Run cli...") + + @extra_command + @click.option("--one") + def foo(): + echo("Run foo...") + + @extra_command(short_help="Bar subcommand.") + @click.option("--two") + def bar(): + echo("Run bar...") + + cli.add_command(foo) + cli.add_command(bar) + + result = invoke(cli, "--help", color=False) + assert result.exit_code == 0 + assert result.stdout == dedent( + """\ + Usage: cli [OPTIONS] COMMAND [ARGS]... + + Options: + --help Show this message and exit. + + Commands: + bar Bar subcommand. + foo + """, + ) + assert not result.stderr + + result = invoke(cli, "foo", "--help", color=False) + assert result.exit_code == 0 + assert re.fullmatch( + ( + r"Run cli\.\.\.\n" + r"Usage: cli foo \[OPTIONS\]\n" + r"\n" + r"Options:\n" + r" --one TEXT\n" + rf"{default_options_uncolored_help}" + ), + result.stdout, + ) + assert not result.stderr + + result = invoke(cli, "bar", "--help", color=False) + assert result.exit_code == 0 + assert re.fullmatch( + ( + r"Run cli\.\.\.\n" + r"Usage: cli bar \[OPTIONS\]\n" + r"\n" + r"Options:\n" + r" --two TEXT\n" + rf"{default_options_uncolored_help}" + ), + result.stdout, + ) + assert not result.stderr
+ + +
[docs]def test_option_group_integration(invoke): + # Mix regular and grouped options + @extra_group + @option_group( + "Group 1", + click.option("-a", "--opt1"), + option("-b", "--opt2"), + ) + @click.option("-c", "--opt3") + @option("-d", "--opt4") + def command_cli2(opt1, opt2, opt3, opt4): + echo("It works!") + + @command_cli2.command() + def default_command(): + echo("Run command...") + + # Remove colors to simplify output comparison. + result = invoke(command_cli2, "--help", color=False) + assert result.exit_code == 0 + assert re.fullmatch( + ( + r"Usage: command-cli2 \[OPTIONS\] COMMAND \[ARGS\]...\n" + r"\n" + r"Group 1:\n" + r" -a, --opt1 TEXT\n" + r" -b, --opt2 TEXT\n" + r"\n" + r"Other options:\n" + r" -c, --opt3 TEXT\n" + r" -d, --opt4 TEXT\n" + rf"{default_options_uncolored_help}" + r"\n" + r"Commands:\n" + r" default-command\n" + ), + result.stdout, + ) + assert "It works!" not in result.stdout + assert not result.stderr
+ + +
[docs]@pytest.mark.parametrize( + ("cmd_decorator", "ctx_settings", "expected_help"), + ( + # Click does not show all envvar in the help screen by default, unless + # specifficaly set on an option. + ( + click.command, + {}, + " --flag1\n --flag2 [env var: custom2]\n --flag3\n", + ), + # Click Extra defaults to let each option choose its own show_envvar value. + ( + extra_command, + {}, + " --flag1\n" + " --flag2 [env var: custom2, CLI_FLAG2]\n" + " --flag3\n", + ), + # Click Extra allow bypassing its global show_envvar setting. + ( + extra_command, + {"show_envvar": None}, + " --flag1\n" + " --flag2 [env var: custom2, CLI_FLAG2]\n" + " --flag3\n", + ), + # Click Extra force the show_envvar value on all options. + ( + extra_command, + {"show_envvar": True}, + " --flag1 [env var: custom1, CLI_FLAG1]\n" + " --flag2 [env var: custom2, CLI_FLAG2]\n" + " --flag3 [env var: custom3, CLI_FLAG3]\n", + ), + ( + extra_command, + {"show_envvar": False}, + " --flag1\n --flag2\n --flag3\n", + ), + ), +) +def test_show_envvar_parameter(invoke, cmd_decorator, ctx_settings, expected_help): + @cmd_decorator(context_settings=ctx_settings) + @option("--flag1", is_flag=True, envvar=["custom1"]) + @option("--flag2", is_flag=True, envvar=["custom2"], show_envvar=True) + @option("--flag3", is_flag=True, envvar=["custom3"], show_envvar=False) + def cli(): + pass + + # Remove colors to simplify output comparison. + result = invoke(cli, "--help", color=False) + assert result.exit_code == 0 + assert not result.stderr + assert expected_help in result.stdout
+ + +
[docs]def test_raw_args(invoke): + """Raw args are expected to be scoped in subcommands.""" + + @extra_group + @option("--dummy-flag/--no-flag") + @pass_context + def my_cli(ctx, dummy_flag): + echo("-- Group output --") + echo(f"dummy_flag is {dummy_flag!r}") + echo(f"Raw parameters: {ctx.meta.get('click_extra.raw_args', [])}") + + @my_cli.command() + @pass_context + @option("--int-param", type=int, default=10) + def subcommand(ctx, int_param): + echo("-- Subcommand output --") + echo(f"int_parameter is {int_param!r}") + echo(f"Raw parameters: {ctx.meta.get('click_extra.raw_args', [])}") + + result = invoke(my_cli, "--dummy-flag", "subcommand", "--int-param", "33") + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == dedent( + """\ + -- Group output -- + dummy_flag is True + Raw parameters: ['--dummy-flag', 'subcommand', '--int-param', '33'] + -- Subcommand output -- + int_parameter is 33 + Raw parameters: ['--int-param', '33'] + """, + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_config.html b/_modules/click_extra/tests/test_config.html new file mode 100644 index 000000000..b16c7c13a --- /dev/null +++ b/_modules/click_extra/tests/test_config.html @@ -0,0 +1,919 @@ + + + + + + + + click_extra.tests.test_config - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_config

+# 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.
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from textwrap import dedent
+
+import click
+import pytest
+from boltons.pathutils import shrinkuser
+from pytest_cases import fixture, parametrize
+
+from click_extra import (
+    command,
+    echo,
+    get_app_dir,
+    option,
+    pass_context,
+)
+from click_extra.colorize import escape_for_help_screen
+from click_extra.decorators import config_option, extra_group
+
+from .conftest import (
+    default_debug_uncolored_log_end,
+    default_debug_uncolored_log_start,
+    default_debug_uncolored_logging,
+    default_debug_uncolored_version_details,
+)
+
+DUMMY_TOML_FILE, DUMMY_TOML_DATA = (
+    dedent(
+        """
+        # Comment
+
+        top_level_param             = "to_ignore"
+
+        [config-cli1]
+        verbosity = "DEBUG"
+        blahblah = 234
+        dummy_flag = true
+        my_list = ["pip", "npm", "gem"]
+
+        [garbage]
+        # An empty random section that will be skipped
+
+        [config-cli1.default-command]
+        int_param = 3
+        random_stuff = "will be ignored"
+        """,
+    ),
+    {
+        "top_level_param": "to_ignore",
+        "config-cli1": {
+            "verbosity": "DEBUG",
+            "blahblah": 234,
+            "dummy_flag": True,
+            "my_list": ["pip", "npm", "gem"],
+            "default-command": {
+                "int_param": 3,
+                "random_stuff": "will be ignored",
+            },
+        },
+        "garbage": {},
+    },
+)
+
+DUMMY_YAML_FILE, DUMMY_YAML_DATA = (
+    dedent(
+        """
+        # Comment
+
+        top_level_param: to_ignore
+
+        config-cli1:
+            verbosity : DEBUG
+            blahblah: 234
+            dummy_flag: True
+            my_list:
+              - pip
+              - "npm"
+              - gem
+            default-command:
+                int_param: 3
+                random_stuff : will be ignored
+
+        garbage:
+            # An empty random section that will be skipped
+
+        """,
+    ),
+    {
+        "top_level_param": "to_ignore",
+        "config-cli1": {
+            "verbosity": "DEBUG",
+            "blahblah": 234,
+            "dummy_flag": True,
+            "my_list": ["pip", "npm", "gem"],
+            "default-command": {
+                "int_param": 3,
+                "random_stuff": "will be ignored",
+            },
+        },
+        "garbage": None,
+    },
+)
+
+DUMMY_JSON_FILE, DUMMY_JSON_DATA = (
+    dedent(
+        """
+        {
+            "top_level_param": "to_ignore",
+            "config-cli1": {
+                "blahblah": 234,
+                "dummy_flag": true,
+                "my_list": [
+                    "pip",
+                    "npm",
+                    "gem"
+                ],
+                "verbosity": "DEBUG",   // log level
+
+                # Subcommand config
+                "default-command": {
+                    "int_param": 3,
+                    "random_stuff": "will be ignored"
+                }
+            },
+
+            // Section to ignore
+            "garbage": {}
+        }
+        """,
+    ),
+    {
+        "top_level_param": "to_ignore",
+        "config-cli1": {
+            "blahblah": 234,
+            "dummy_flag": True,
+            "my_list": ["pip", "npm", "gem"],
+            "verbosity": "DEBUG",
+            "default-command": {
+                "int_param": 3,
+                "random_stuff": "will be ignored",
+            },
+        },
+        "garbage": {},
+    },
+)
+
+DUMMY_INI_FILE, DUMMY_INI_DATA = (
+    dedent(
+        """
+        ; Comment
+        # Another kind of comment
+
+        [to_ignore]
+        key=value
+        spaces in keys=allowed
+        spaces in values=allowed as well
+        spaces around the delimiter = obviously
+        you can also use : to delimit keys from values
+
+        [config-cli1.default-command]
+        int_param = 3
+        random_stuff = will be ignored
+
+        [garbage]
+        # An empty random section that will be skipped
+
+        [config-cli1]
+        verbosity : DEBUG
+        blahblah: 234
+        dummy_flag = true
+        my_list = ["pip", "npm", "gem"]
+        """,
+    ),
+    {
+        "to_ignore": {
+            "key": "value",
+            "spaces in keys": "allowed",
+            "spaces in values": "allowed as well",
+            "spaces around the delimiter": "obviously",
+            "you can also use": "to delimit keys from values",
+        },
+        "config-cli1": {
+            "default-command": {
+                "int_param": "3",
+                "random_stuff": "will be ignored",
+            },
+            "verbosity": "DEBUG",
+            "blahblah": "234",
+            "dummy_flag": "true",
+            "my_list": '["pip", "npm", "gem"]',
+        },
+        "garbage": {},
+    },
+)
+
+DUMMY_XML_FILE, DUMMY_XML_DATA = (
+    dedent(
+        """
+        <!-- Comment -->
+
+        <config-cli1 has="an attribute">
+
+            <to_ignore>
+                <key>value</key>
+                <spaces >    </spaces>
+                <text_as_value>
+                    Ratione omnis sit rerum dolor.
+                    Quas omnis dolores quod sint aspernatur.
+                    Veniam deleniti est totam pariatur temporibus qui
+                            accusantium eaque.
+                </text_as_value>
+
+            </to_ignore>
+
+            <verbosity>debug</verbosity>
+            <blahblah>234</blahblah>
+            <dummy_flag>true</dummy_flag>
+
+            <my_list>pip</my_list>
+            <my_list>npm</my_list>
+            <my_list>gem</my_list>
+
+            <garbage>
+                <!-- An empty random section that will be skipped -->
+            </garbage>
+
+            <default-command>
+                <int_param>3</int_param>
+                <random_stuff>will be ignored</random_stuff>
+            </default-command>
+
+        </config-cli1>
+    """,
+    ),
+    {
+        "config-cli1": {
+            "@has": "an attribute",
+            "to_ignore": {
+                "key": "value",
+                "spaces": None,
+                "text_as_value": (
+                    "Ratione omnis sit rerum dolor.\n"
+                    "            "
+                    "Quas omnis dolores quod sint aspernatur.\n"
+                    "            "
+                    "Veniam deleniti est totam pariatur temporibus qui\n"
+                    "                    "
+                    "accusantium eaque."
+                ),
+            },
+            "verbosity": "debug",
+            "blahblah": "234",
+            "dummy_flag": "true",
+            "my_list": ["pip", "npm", "gem"],
+            "garbage": None,
+            "default-command": {
+                "int_param": "3",
+                "random_stuff": "will be ignored",
+            },
+        },
+    },
+)
+
+all_config_formats = parametrize(
+    ("conf_name, conf_text, conf_data"),
+    (
+        pytest.param(f"configuration.{ext}", content, data, id=ext)
+        for ext, content, data in (
+            ("toml", DUMMY_TOML_FILE, DUMMY_TOML_DATA),
+            ("yaml", DUMMY_YAML_FILE, DUMMY_YAML_DATA),
+            ("json", DUMMY_JSON_FILE, DUMMY_JSON_DATA),
+            ("ini", DUMMY_INI_FILE, DUMMY_INI_DATA),
+            ("xml", DUMMY_XML_FILE, DUMMY_XML_DATA),
+        )
+    ),
+)
+
+
+
[docs]@fixture +def simple_config_cli(): + @extra_group(context_settings={"show_envvar": True}) + @option("--dummy-flag/--no-flag") + @option("--my-list", multiple=True) + def config_cli1(dummy_flag, my_list): + echo(f"dummy_flag = {dummy_flag!r}") + echo(f"my_list = {my_list!r}") + + @config_cli1.command() + @option("--int-param", type=int, default=10) + def default_command(int_param): + echo(f"int_parameter = {int_param!r}") + + return config_cli1
+ + +
[docs]def test_unset_conf_no_message(invoke, simple_config_cli): + result = invoke(simple_config_cli, "default-command") + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == "dummy_flag = False\nmy_list = ()\nint_parameter = 10\n"
+ + +
[docs]def test_unset_conf_debug_message(invoke, simple_config_cli): + result = invoke( + simple_config_cli, + "--verbosity", + "DEBUG", + "default-command", + color=False, + ) + assert result.exit_code == 0 + assert result.stdout == "dummy_flag = False\nmy_list = ()\nint_parameter = 10\n" + assert re.fullmatch( + default_debug_uncolored_log_start + default_debug_uncolored_log_end, + result.stderr, + )
+ + +
[docs]def test_conf_default_path(invoke, simple_config_cli): + result = invoke(simple_config_cli, "--help", color=False) + assert result.exit_code == 0 + assert not result.stderr + + # OS-specific path. + default_path = shrinkuser( + Path(get_app_dir("config-cli1")) / "*.{toml,yaml,yml,json,ini,xml}", + ) + + # Make path string compatible with regexp. + assert re.search( + r"\s+\[env\s+var:\s+CONFIG_CLI1_CONFIG;\s+" + rf"default:\s+{escape_for_help_screen(str(default_path))}\]\s+", + result.stdout, + )
+ + +
[docs]def test_conf_not_exist(invoke, simple_config_cli): + conf_path = Path("dummy.toml") + result = invoke( + simple_config_cli, + "--config", + str(conf_path), + "default-command", + color=False, + ) + assert result.exit_code == 2 + assert not result.stdout + assert f"Load configuration matching {conf_path}\n" in result.stderr + assert "critical: No configuration file found.\n" in result.stderr
+ + +
[docs]def test_conf_not_file(invoke, simple_config_cli): + conf_path = Path().parent + result = invoke( + simple_config_cli, + "--config", + str(conf_path), + "default-command", + color=False, + ) + assert result.exit_code == 2 + assert not result.stdout + + assert f"Load configuration matching {conf_path}\n" in result.stderr + assert "critical: No configuration file found.\n" in result.stderr
+ + +
[docs]def test_strict_conf(invoke, create_config): + """Same test as the one shown in the readme, but in strict validation mode.""" + + @click.group + @option("--dummy-flag/--no-flag") + @option("--my-list", multiple=True) + @config_option(strict=True) + def config_cli3(dummy_flag, my_list): + echo(f"dummy_flag is {dummy_flag!r}") + echo(f"my_list is {my_list!r}") + + @config_cli3.command + @option("--int-param", type=int, default=10) + def subcommand(int_param): + echo(f"int_parameter is {int_param!r}") + + conf_file = dedent( + """ + # My default configuration file. + + [config-cli3] + dummy_flag = true # New boolean default. + my_list = ["item 1", "item #2", "Very Last Item!"] + + [config-cli3.subcommand] + int_param = 3 + random_stuff = "will be ignored" + """, + ) + + conf_path = create_config("messy.toml", conf_file) + + result = invoke(config_cli3, "--config", str(conf_path), "subcommand", color=False) + + assert result.exception + assert type(result.exception) is ValueError + assert ( + str(result.exception) + == "Parameter 'random_stuff' is not allowed in configuration file." + ) + + assert result.exit_code == 1 + assert f"Load configuration matching {conf_path}\n" in result.stderr + assert not result.stdout
+ + +
[docs]@all_config_formats +def test_conf_file_overrides_defaults( + invoke, + simple_config_cli, + create_config, + httpserver, + conf_name, + conf_text, + conf_data, +): + # Create a local file and remote config. + conf_filepath = create_config(conf_name, conf_text) + httpserver.expect_request(f"/{conf_name}").respond_with_data(conf_text) + conf_url = httpserver.url_for(f"/{conf_name}") + + for conf_path, is_url in (conf_filepath, False), (conf_url, True): + result = invoke( + simple_config_cli, + "--config", + str(conf_path), + "default-command", + color=False, + ) + assert result.exit_code == 0 + assert result.stdout == ( + "dummy_flag = True\nmy_list = ('pip', 'npm', 'gem')\nint_parameter = 3\n" + ) + + # Debug level has been activated by configuration file. + debug_log = rf"Load configuration matching {re.escape(str(conf_path))}\n" + if is_url: + debug_log += ( + r"info: 127\.0\.0\.1 - - \[\S+ \S+\] " + rf'"GET /{re.escape(conf_name)} HTTP/1\.1" 200 -\n' + ) + debug_log += ( + default_debug_uncolored_logging + + default_debug_uncolored_version_details + + default_debug_uncolored_log_end + ) + assert re.fullmatch(debug_log, result.stderr)
+ + +
[docs]@all_config_formats +def test_auto_env_var_conf( + invoke, + simple_config_cli, + create_config, + httpserver, + conf_name, + conf_text, + conf_data, +): + # Check the --config option properly documents its environment variable. + result = invoke(simple_config_cli, "--help") + assert result.exit_code == 0 + assert not result.stderr + assert "CONFIG_CLI1_CONFIG" in result.stdout + + # Create a local config. + conf_filepath = create_config(conf_name, conf_text) + + # Create a remote config. + httpserver.expect_request(f"/{conf_name}").respond_with_data(conf_text) + conf_url = httpserver.url_for(f"/{conf_name}") + + for conf_path in conf_filepath, conf_url: + conf_path = create_config(conf_name, conf_text) + result = invoke( + simple_config_cli, + "default-command", + color=False, + env={"CONFIG_CLI1_CONFIG": str(conf_path)}, + ) + assert result.exit_code == 0 + assert result.stdout == ( + "dummy_flag = True\nmy_list = ('pip', 'npm', 'gem')\nint_parameter = 3\n" + ) + # Debug level has been activated by configuration file. + assert result.stderr.startswith( + f"Load configuration matching {conf_path}\n" + "debug: Set <Logger click_extra (DEBUG)> to DEBUG.\n" + "debug: Set <RootLogger root (DEBUG)> to DEBUG.\n", + )
+ + +
[docs]@all_config_formats +def test_conf_file_overridden_by_cli_param( + invoke, + simple_config_cli, + create_config, + httpserver, + conf_name, + conf_text, + conf_data, +): + # Create a local file and remote config. + conf_filepath = create_config(conf_name, conf_text) + httpserver.expect_request(f"/{conf_name}").respond_with_data(conf_text) + conf_url = httpserver.url_for(f"/{conf_name}") + + for conf_path in conf_filepath, conf_url: + conf_path = create_config(conf_name, conf_text) + result = invoke( + simple_config_cli, + "--my-list", + "super", + "--config", + str(conf_path), + "--verbosity", + "CRITICAL", + "--no-flag", + "--my-list", + "wow", + "default-command", + "--int-param", + "15", + ) + assert result.exit_code == 0 + assert result.stdout == ( + "dummy_flag = False\nmy_list = ('super', 'wow')\nint_parameter = 15\n" + ) + assert result.stderr == f"Load configuration matching {conf_path}\n"
+ + +
[docs]@all_config_formats +def test_conf_metadata( + invoke, + create_config, + httpserver, + conf_name, + conf_text, + conf_data, +): + @command + @config_option + @pass_context + def config_metadata(ctx): + echo(f"conf_source={ctx.meta['click_extra.conf_source']}") + echo(f"conf_full={ctx.meta['click_extra.conf_full']}") + echo(f"default_map={ctx.default_map}") + + # Create a local file and remote config. + conf_filepath = create_config(conf_name, conf_text) + httpserver.expect_request(f"/{conf_name}").respond_with_data(conf_text) + conf_url = httpserver.url_for(f"/{conf_name}") + + for conf_path in conf_filepath, conf_url: + conf_path = create_config(conf_name, conf_text) + result = invoke(config_metadata, "--config", str(conf_path)) + assert result.exit_code == 0 + assert result.stdout == ( + f"conf_source={conf_path}\n" + f"conf_full={conf_data}\n" + # No configuration values match the CLI's parameter structure, so default + # map is left untouched. + "default_map={}\n" + ) + assert result.stderr == f"Load configuration matching {conf_path}\n"
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_logging.html b/_modules/click_extra/tests/test_logging.html new file mode 100644 index 000000000..0dfd3eb1f --- /dev/null +++ b/_modules/click_extra/tests/test_logging.html @@ -0,0 +1,542 @@ + + + + + + + + click_extra.tests.test_logging - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_logging

+# 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.
+
+from __future__ import annotations
+
+import logging
+import random
+import re
+
+import click
+import pytest
+from pytest_cases import parametrize
+
+from click_extra import echo
+from click_extra.decorators import extra_command, verbosity_option
+from click_extra.logging import DEFAULT_LEVEL, LOG_LEVELS
+
+from .conftest import (
+    command_decorators,
+    default_debug_colored_log_end,
+    default_debug_colored_log_start,
+    default_debug_colored_logging,
+    default_debug_uncolored_log_end,
+    default_debug_uncolored_logging,
+    skip_windows_colors,
+)
+
+
+
[docs]def test_level_default_order(): + assert tuple(LOG_LEVELS) == ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG")
+ + +
[docs]def test_root_logger_defaults(): + """Check our internal default is aligned to Python's root logger.""" + # Check the root logger is the default logger, and that getLogger is + # properly patched on Python 3.8. + assert logging.getLogger() is logging.getLogger("root") + assert logging.getLogger() is logging.root + + # Check root logger's level. + assert logging.root.getEffectiveLevel() == logging.WARNING + assert logging._levelToName[logging.root.level] == "WARNING" + assert logging.root.level == DEFAULT_LEVEL
+ + +
[docs]@pytest.mark.parametrize( + ("cmd_decorator", "cmd_type"), + command_decorators(with_types=True), +) +def test_unrecognized_verbosity(invoke, cmd_decorator, cmd_type): + @cmd_decorator + @verbosity_option + def logging_cli1(): + echo("It works!") + + # Remove colors to simplify output comparison. + result = invoke(logging_cli1, "--verbosity", "random", color=False) + assert result.exit_code == 2 + assert not result.stdout + + group_help = " COMMAND [ARGS]..." if "group" in cmd_type else "" + extra_suggest = ( + "Try 'logging-cli1 --help' for help.\n" if "extra" not in cmd_type else "" + ) + assert result.stderr == ( + f"Usage: logging-cli1 [OPTIONS]{group_help}\n" + f"{extra_suggest}\n" + "Error: Invalid value for '--verbosity' / '-v': " + "'random' is not one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'.\n" + )
+ + +
[docs]@skip_windows_colors +@pytest.mark.parametrize( + "cmd_decorator", + # Skip click extra's commands, as verbosity option is already part of the default. + command_decorators(no_groups=True, no_extra=True), +) +@parametrize("option_decorator", (verbosity_option, verbosity_option())) +@pytest.mark.parametrize("level", LOG_LEVELS.keys()) +def test_default_root_logger(invoke, cmd_decorator, option_decorator, level): + """Checks: + - the default logger is ``<root>`` + - the default logger message format + - level names are colored + - log level is propagated to all other loggers. + """ + + @cmd_decorator + @option_decorator + def logging_cli2(): + echo("It works!") + + random_logger = logging.getLogger( + f"random_logger_{random.randrange(10000, 99999)}", + ) + random_logger.debug("my random message.") + + logging.debug("my debug message.") + logging.info("my info message.") + logging.warning("my warning message.") + logging.error("my error message.") + logging.critical("my critical message.") + + result = invoke(logging_cli2, "--verbosity", level, color=True) + assert result.exit_code == 0 + assert result.stdout == "It works!\n" + + messages = ( + ( + rf"{default_debug_colored_logging}" + r"\x1b\[34mdebug\x1b\[0m: my random message.\n" + r"\x1b\[34mdebug\x1b\[0m: my debug message.\n" + ), + r"info: my info message.\n", + r"\x1b\[33mwarning\x1b\[0m: my warning message.\n", + r"\x1b\[31merror\x1b\[0m: my error message.\n", + r"\x1b\[31m\x1b\[1mcritical\x1b\[0m: my critical message.\n", + ) + level_index = {index: level for level, index in enumerate(LOG_LEVELS)}[level] + log_records = r"".join(messages[-level_index - 1 :]) + + if level == "DEBUG": + log_records += default_debug_colored_log_end + assert re.fullmatch(log_records, result.stderr)
+ + +
[docs]@skip_windows_colors +@pytest.mark.parametrize("level", LOG_LEVELS.keys()) +# TODO: test extra_group +def test_integrated_verbosity_option(invoke, level): + @extra_command + def logging_cli3(): + echo("It works!") + + result = invoke(logging_cli3, "--verbosity", level, color=True) + assert result.exit_code == 0 + assert result.stdout == "It works!\n" + if level == "DEBUG": + assert re.fullmatch( + default_debug_colored_log_start + default_debug_colored_log_end, + result.stderr, + ) + else: + assert not result.stderr
+ + +
[docs]@pytest.mark.parametrize( + "logger_param", + (logging.getLogger("awesome_app"), "awesome_app"), +) +@pytest.mark.parametrize("params", (("--verbosity", "DEBUG"), None)) +def test_custom_logger_param(invoke, logger_param, params): + """Passing a logger instance or name to the ``default_logger`` parameter works.""" + + @click.command + @verbosity_option(default_logger=logger_param) + def awesome_app(): + echo("Starting Awesome App...") + logging.getLogger("awesome_app").debug("Awesome App has started.") + + result = invoke(awesome_app, params, color=False) + assert result.exit_code == 0 + assert result.stdout == "Starting Awesome App...\n" + if params: + assert re.fullmatch( + ( + r"debug: Set <Logger click_extra \(DEBUG\)> to DEBUG.\n" + r"debug: Set <Logger awesome_app \(DEBUG\)> to DEBUG.\n" + r"debug: Awesome App has started\.\n" + r"debug: Reset <Logger awesome_app \(DEBUG\)> to WARNING.\n" + r"debug: Reset <Logger click_extra \(DEBUG\)> to WARNING.\n" + ), + result.stderr, + ) + else: + assert not result.stderr
+ + +
[docs]def test_custom_option_name(invoke): + param_names = ("--blah", "-B") + + @click.command + @verbosity_option(*param_names) + def awesome_app(): + root_logger = logging.getLogger() + root_logger.debug("my debug message.") + + for name in param_names: + result = invoke(awesome_app, name, "DEBUG", color=False) + assert result.exit_code == 0 + assert not result.stdout + assert re.fullmatch( + ( + rf"{default_debug_uncolored_logging}" + r"debug: my debug message\.\n" + rf"{default_debug_uncolored_log_end}" + ), + result.stderr, + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_parameters.html b/_modules/click_extra/tests/test_parameters.html new file mode 100644 index 000000000..37abd3334 --- /dev/null +++ b/_modules/click_extra/tests/test_parameters.html @@ -0,0 +1,1022 @@ + + + + + + + + click_extra.tests.test_parameters - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_parameters

+# 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.
+
+from __future__ import annotations
+
+import re
+from os.path import sep
+from pathlib import Path
+from textwrap import dedent
+
+import click
+import pytest
+from pytest_cases import parametrize
+from tabulate import tabulate
+
+from click_extra import (
+    BOOL,
+    FLOAT,
+    INT,
+    STRING,
+    UNPROCESSED,
+    UUID,
+    Choice,
+    DateTime,
+    File,
+    FloatRange,
+    IntRange,
+    ParamType,
+    Tuple,
+    argument,
+    command,
+    echo,
+    get_app_dir,
+    option,
+    search_params,
+)
+from click_extra.decorators import extra_command, extra_group, show_params_option
+from click_extra.parameters import ShowParamsOption, extend_envvars, normalize_envvar
+from click_extra.platforms import is_windows
+
+from .conftest import command_decorators
+
+
+
[docs]@pytest.mark.parametrize( + ("envvars_1", "envvars_2", "result"), + ( + ("MY_VAR", "MY_VAR", ("MY_VAR",)), + (None, "MY_VAR", ("MY_VAR",)), + ("MY_VAR", None, ("MY_VAR",)), + (["MY_VAR"], "MY_VAR", ("MY_VAR",)), + (["MY_VAR"], None, ("MY_VAR",)), + ("MY_VAR", ["MY_VAR"], ("MY_VAR",)), + (None, ["MY_VAR"], ("MY_VAR",)), + (["MY_VAR"], ["MY_VAR"], ("MY_VAR",)), + (["MY_VAR1"], ["MY_VAR2"], ("MY_VAR1", "MY_VAR2")), + (["MY_VAR1", "MY_VAR2"], ["MY_VAR2"], ("MY_VAR1", "MY_VAR2")), + (["MY_VAR1"], ["MY_VAR1", "MY_VAR2"], ("MY_VAR1", "MY_VAR2")), + (["MY_VAR1"], ["MY_VAR2", "MY_VAR2"], ("MY_VAR1", "MY_VAR2")), + (["MY_VAR1", "MY_VAR1"], ["MY_VAR2"], ("MY_VAR1", "MY_VAR2")), + ), +) +def test_extend_envvars(envvars_1, envvars_2, result): + assert extend_envvars(envvars_1, envvars_2) == result
+ + +
[docs]@pytest.mark.parametrize( + ("env_name", "normalized_env"), + ( + ("show-params-cli_VERSION", "SHOW_PARAMS_CLI_VERSION"), + ("show---params-cli___VERSION", "SHOW_PARAMS_CLI_VERSION"), + ("__show-__params-_-_-", "SHOW_PARAMS"), + ), +) +def test_normalize_envvar(env_name, normalized_env): + assert normalize_envvar(env_name) == normalized_env
+ + +
[docs]@pytest.mark.parametrize( + ("cmd_decorator", "option_help"), + ( + # Click does not show the auto-generated envvar in the help screen. + (click.command, " --flag / --no-flag [env var: custom]\n"), + # Click Extra always adds the auto-generated envvar to the help screen + # (and show the defaults). + ( + extra_command, + " --flag / --no-flag " + "[env var: custom, yo_FLAG; default: no-flag]\n", + ), + ), +) +def test_show_auto_envvar_help(invoke, cmd_decorator, option_help): + """Check that the auto-generated envvar appears in the help screen with the extra + variants. + + Checks that https://github.com/pallets/click/issues/2483 is addressed. + """ + + @cmd_decorator(context_settings={"auto_envvar_prefix": "yo"}) + @option("--flag/--no-flag", envvar=["custom"], show_envvar=True) + def envvar_help(): + pass + + # Remove colors to simplify output comparison. + result = invoke(envvar_help, "--help", color=False) + assert result.exit_code == 0 + assert not result.stderr + assert option_help in result.stdout
+ + +
[docs]def envvars_test_cases(): + params = [] + + matrix = { + (command, "command"): { + "working_envvar": ( + # User-defined envvars are recognized as-is. + "Magic", + "sUper", + # XXX Uppercased auto-generated envvar is recognized but should not be. + "YO_FLAG", + ), + "unknown_envvar": ( + # Uppercased user-defined envvar is not recognized. + "MAGIC", + # XXX Literal auto-generated is not recognized but should be. + "yo_FLAG", + # Mixed-cased auto-generated envvat is not recognized. + "yo_FlAg", + ), + }, + (extra_command, "extra_command"): { + "working_envvar": ( + # User-defined envvars are recognized as-is. + "Magic", + "sUper", + # Literal auto-generated is properly recognized but is not in vanilla + # Click (see above). + "yo_FLAG", + # XXX Uppercased auto-generated envvar is recognized but should not be. + "YO_FLAG", + ), + "unknown_envvar": ( + # Uppercased user-defined envvar is not recognized. + "MAGIC", + # Mixed-cased auto-generated envvat is not recognized. + "yo_FlAg", + ), + }, + } + + # Windows is automaticcaly normalizing any env var to upper-case, see: + # https://github.com/python/cpython/blob/e715da6/Lib/os.py#L748-L749 + # https://docs.python.org/3/library/os.html?highlight=environ#os.environ + # So Windows needs its own test case. + if is_windows(): + all_envvars = ( + "Magic", + "MAGIC", + "sUper", + "yo_FLAG", + "YO_FLAG", + "yo_FlAg", + ) + matrix = { + (command, "command"): { + "working_envvar": all_envvars, + "unknown_envvar": (), + }, + (extra_command, "extra_command"): { + "working_envvar": all_envvars, + "unknown_envvar": (), + }, + } + + # If properly recognized, these envvar values should be passed to the flag. + working_value_map = { + "True": True, + "true": True, + "tRuE": True, + "1": True, + "": False, # XXX: Should be True? + "False": False, + "false": False, + "fAlsE": False, + "0": False, + } + # No envvar value will have an effect on the flag if the envvar is not recognized. + broken_value_map = {k: False for k in working_value_map} + + for (cmd_decorator, decorator_name), envvar_cases in matrix.items(): + for case_name, envvar_names in envvar_cases.items(): + value_map = ( + working_value_map if case_name == "working_envvar" else broken_value_map + ) + + for envvar_name in envvar_names: + for envar_value, expected_flag in value_map.items(): + envvar = {envvar_name: envar_value} + test_id = ( + f"{decorator_name}|{case_name}={envvar}" + f"|expected_flag={expected_flag}" + ) + params.append( + pytest.param(cmd_decorator, envvar, expected_flag, id=test_id) + ) + + return params
+ + +
[docs]@parametrize("cmd_decorator, envvars, expected_flag", envvars_test_cases()) +def test_auto_envvar_parsing(invoke, cmd_decorator, envvars, expected_flag): + """This test highlights the way Click recognize and parse envvars. + + It shows that the default behavior is not ideal, and covers how ``extra_command`` + improves the situation by normalizing the envvar name. + """ + + @cmd_decorator(context_settings={"auto_envvar_prefix": "yo"}) + @option("--flag/--no-flag", envvar=["Magic", "sUper"]) + def my_cli(flag): + echo(f"Flag value: {flag}") + + registered_envvars = ["Magic", "sUper"] + # @extra_command forces registration of auto-generated envvar. + if cmd_decorator == extra_command: + registered_envvars = (*registered_envvars, "yo_FLAG") + assert my_cli.params[0].envvar == registered_envvars + + result = invoke(my_cli, env=envvars) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == f"Flag value: {expected_flag}\n"
+ + +
[docs]class Custom(ParamType): + """A dummy custom type.""" + + name = "Custom" + +
[docs] def convert(self, value, param, ctx): + assert isinstance(value, str) + return value
+ + +
[docs]@parametrize("option_decorator", (show_params_option, show_params_option())) +def test_params_auto_types(invoke, option_decorator): + """Check parameters types and structure are properly derived from CLI.""" + + @click.command + @option("--flag1/--no-flag1") + @option("--flag2", is_flag=True) + @option("--str-param1", type=str) + @option("--str-param2", type=STRING) + @option("--int-param1", type=int) + @option("--int-param2", type=INT) + @option("--float-param1", type=float) + @option("--float-param2", type=FLOAT) + @option("--bool-param1", type=bool) + @option("--bool-param2", type=BOOL) + @option("--uuid-param", type=UUID) + @option("--unprocessed-param", type=UNPROCESSED) + @option("--file-param", type=File()) + @option("--path-param", type=click.Path()) + @option("--choice-param", type=Choice(("a", "b", "c"))) + @option("--int-range-param", type=IntRange()) + @option("--count-param", count=True) # See issue #170. + @option("--float-range-param", type=FloatRange()) + @option("--datetime-param", type=DateTime()) + @option("--custom-param", type=Custom()) + @option("--tuple1", nargs=2, type=Tuple([str, int])) + @option("--list1", multiple=True) + @option("--hidden-param", hidden=True) # See issue #689. + @argument("file_arg1", type=File("w")) + @argument("file_arg2", type=File("w"), nargs=-1) + @option_decorator + def params_introspection( + flag1, + flag2, + str_param1, + str_param2, + int_param1, + int_param2, + float_param1, + float_param2, + bool_param1, + bool_param2, + uuid_param, + unprocessed_param, + file_param, + path_param, + choice_param, + int_range_param, + count_param, + float_range_param, + datetime_param, + custom_param, + tuple1, + list1, + hidden_param, + file_arg1, + file_arg2, + ): + echo("Works!") + + # Invoke the --show-params option to trigger the introspection. + result = invoke( + params_introspection, + "--show-params", + "random_file1", + "random_file2", + color=False, + ) + + assert result.exit_code == 0 + assert result.stdout != "Works!\n" + + show_param_option = search_params(params_introspection.params, ShowParamsOption) + assert show_param_option.params_template == { + "params-introspection": { + "flag1": None, + "flag2": None, + "str_param1": None, + "str_param2": None, + "int_param1": None, + "int_param2": None, + "float_param1": None, + "float_param2": None, + "bool_param1": None, + "bool_param2": None, + "uuid_param": None, + "unprocessed_param": None, + "file_param": None, + "path_param": None, + "show_params": None, + "choice_param": None, + "int_range_param": None, + "count_param": None, + "float_range_param": None, + "datetime_param": None, + "custom_param": None, + "tuple1": None, + "list1": None, + "hidden_param": None, + "file_arg1": None, + "file_arg2": None, + }, + } + assert show_param_option.params_types == { + "params-introspection": { + "flag1": bool, + "flag2": bool, + "str_param1": str, + "str_param2": str, + "int_param1": int, + "int_param2": int, + "float_param1": float, + "float_param2": float, + "bool_param1": bool, + "bool_param2": bool, + "uuid_param": str, + "unprocessed_param": str, + "file_param": str, + "path_param": str, + "show_params": bool, + "choice_param": str, + "int_range_param": int, + "count_param": int, + "float_range_param": float, + "datetime_param": str, + "custom_param": str, + "tuple1": list, + "list1": list, + "hidden_param": str, + "file_arg1": str, + "file_arg2": list, + }, + }
+ + +# Skip click extra's commands, as show_params option is already part of the default. +
[docs]@parametrize("cmd_decorator", command_decorators(no_extra=True)) +@parametrize("option_decorator", (show_params_option, show_params_option())) +def test_standalone_show_params_option(invoke, cmd_decorator, option_decorator): + @cmd_decorator + @option_decorator + def show_params(): + echo("It works!") + + result = invoke(show_params, "--show-params") + assert result.exit_code == 0 + + table = [ + ( + "show-params.show_params", + "click_extra.parameters.ShowParamsOption", + "--show-params", + "click.types.BoolParamType", + "bool", + "✘", + "✘", + "", + "", + False, + "", + "COMMANDLINE", + ), + ] + output = tabulate( + table, + headers=ShowParamsOption.TABLE_HEADERS, + tablefmt="rounded_outline", + disable_numparse=True, + ) + assert result.stdout == f"{output}\n" + + assert re.fullmatch( + r"warning: Cannot extract parameters values: " + r"<(Group|Command) show-params> does not inherits from ExtraCommand\.\n", + result.stderr, + )
+ + +
[docs]def test_integrated_show_params_option(invoke, create_config): + @extra_command + @option("--int-param1", type=int, default=10) + @option("--int-param2", type=int, default=555) + @option("--hidden-param", hidden=True) # See issue #689. + @option("--custom-param", type=Custom()) # See issue #721. + def show_params_cli(int_param1, int_param2, hidden_param, custom_param): + echo(f"int_param1 is {int_param1!r}") + echo(f"int_param2 is {int_param2!r}") + echo(f"hidden_param is {hidden_param!r}") + echo(f"custom_param is {custom_param!r}") + + conf_file = dedent( + """ + [show-params-cli] + int_param1 = 3 + extra_value = "unallowed" + """, + ) + conf_path = create_config("show-params-cli.toml", conf_file) + + raw_args = [ + "--verbosity", + "DeBuG", + "--config", + str(conf_path), + "--int-param1", + "9999", + "--show-params", + "--help", + ] + result = invoke(show_params_cli, *raw_args, color=False) + + assert result.exit_code == 0 + assert f"debug: click_extra.raw_args: {raw_args!r}\n" in result.stderr + + table = [ + ( + "show-params-cli.color", + "click_extra.colorize.ColorOption", + "--color, --ansi / --no-color, --no-ansi", + "click.types.BoolParamType", + "bool", + "✘", + "✘", + "✓", + "SHOW_PARAMS_CLI_COLOR", + True, + True, + "DEFAULT", + ), + ( + "show-params-cli.config", + "click_extra.config.ConfigOption", + "-C, --config CONFIG_PATH", + "click.types.StringParamType", + "str", + "✘", + "✘", + "✘", + "SHOW_PARAMS_CLI_CONFIG", + ( + f"{Path(get_app_dir('show-params-cli')).resolve()}{sep}" + "*.{toml,yaml,yml,json,ini,xml}" + ), + str(conf_path), + "COMMANDLINE", + ), + ( + "show-params-cli.custom_param", + "cloup._params.Option", + "--custom-param CUSTOM", + "click_extra.tests.test_parameters.Custom", + "str", + "✘", + "✓", + "✓", + "SHOW_PARAMS_CLI_CUSTOM_PARAM", + "None", + None, + "DEFAULT", + ), + ( + "show-params-cli.help", + "click_extra.colorize.HelpOption", + "-h, --help", + "click.types.BoolParamType", + "bool", + "✘", + "✘", + "✘", + "SHOW_PARAMS_CLI_HELP", + False, + True, + "COMMANDLINE", + ), + ( + "show-params-cli.hidden_param", + "cloup._params.Option", + "--hidden-param TEXT", + "click.types.StringParamType", + "str", + "✓", + "✓", + "✓", + "SHOW_PARAMS_CLI_HIDDEN_PARAM", + "None", + None, + "DEFAULT", + ), + ( + "show-params-cli.int_param1", + "cloup._params.Option", + "--int-param1 INTEGER", + "click.types.IntParamType", + "int", + "✘", + "✓", + "✓", + "SHOW_PARAMS_CLI_INT_PARAM1", + 3, + 9999, + "COMMANDLINE", + ), + ( + "show-params-cli.int_param2", + "cloup._params.Option", + "--int-param2 INTEGER", + "click.types.IntParamType", + "int", + "✘", + "✓", + "✓", + "SHOW_PARAMS_CLI_INT_PARAM2", + 555, + 555, + "DEFAULT", + ), + ( + "show-params-cli.show_params", + "click_extra.parameters.ShowParamsOption", + "--show-params", + "click.types.BoolParamType", + "bool", + "✘", + "✘", + "✘", + "SHOW_PARAMS_CLI_SHOW_PARAMS", + False, + True, + "COMMANDLINE", + ), + ( + "show-params-cli.time", + "click_extra.timer.TimerOption", + "--time / --no-time", + "click.types.BoolParamType", + "bool", + "✘", + "✘", + "✓", + "SHOW_PARAMS_CLI_TIME", + False, + False, + "DEFAULT", + ), + ( + "show-params-cli.verbosity", + "click_extra.logging.VerbosityOption", + "-v, --verbosity LEVEL", + "click.types.Choice", + "str", + "✘", + "✘", + "✓", + "SHOW_PARAMS_CLI_VERBOSITY", + "WARNING", + "DeBuG", + "COMMANDLINE", + ), + ( + "show-params-cli.version", + "click_extra.version.ExtraVersionOption", + "--version", + "click.types.BoolParamType", + "bool", + "✘", + "✘", + "✘", + "SHOW_PARAMS_CLI_VERSION", + False, + False, + "DEFAULT", + ), + ] + output = tabulate( + table, + headers=ShowParamsOption.TABLE_HEADERS, + tablefmt="rounded_outline", + disable_numparse=True, + ) + assert result.stdout == f"{output}\n"
+ + +
[docs]def test_recurse_subcommands(invoke): + @extra_group(params=[ShowParamsOption()]) + def show_params_cli_main(): + echo("main cmd") + + @show_params_cli_main.group(params=[]) + def show_params_sub_cmd(): + echo("subcommand") + + @show_params_sub_cmd.command() + @option("--int-param", type=int, default=10) + def show_params_sub_sub_cmd(int_param): + echo(f"subsubcommand int_param is {int_param!r}") + + result = invoke(show_params_cli_main, "--show-params", color=False) + + table = [ + ( + "show-params-cli-main.show_params", + "click_extra.parameters.ShowParamsOption", + "--show-params", + "click.types.BoolParamType", + "bool", + "✘", + "✘", + "", + "SHOW_PARAMS_CLI_MAIN_SHOW_PARAMS", + False, + True, + "COMMANDLINE", + ), + ( + "show-params-cli-main.show-params-sub-cmd.show-params-sub-sub-cmd.int_param", + "cloup._params.Option", + "--int-param INTEGER", + "click.types.IntParamType", + "int", + "✘", + "✓", + "", + "SHOW_PARAMS_SUB_SUB_CMD_INT_PARAM, SHOW_PARAMS_CLI_MAIN_INT_PARAM", + 10, + 10, + "DEFAULT", + ), + ] + output = tabulate( + table, + headers=ShowParamsOption.TABLE_HEADERS, + tablefmt="rounded_outline", + disable_numparse=True, + ) + assert result.stdout == f"{output}\n"
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_platforms.html b/_modules/click_extra/tests/test_platforms.html new file mode 100644 index 000000000..1e78356b3 --- /dev/null +++ b/_modules/click_extra/tests/test_platforms.html @@ -0,0 +1,706 @@ + + + + + + + + click_extra.tests.test_platforms - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_platforms

+# 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.
+
+from __future__ import annotations
+
+import functools
+from itertools import combinations
+
+import pytest
+
+from click_extra import platforms as platforms_module
+from click_extra.platforms import (
+    AIX,
+    ALL_GROUPS,
+    ALL_LINUX,
+    ALL_OS_LABELS,
+    ALL_PLATFORMS,
+    ALL_WINDOWS,
+    BSD,
+    BSD_WITHOUT_MACOS,
+    CURRENT_OS_ID,
+    CURRENT_OS_LABEL,
+    CYGWIN,
+    EXTRA_GROUPS,
+    FREEBSD,
+    HURD,
+    LINUX,
+    LINUX_LAYERS,
+    MACOS,
+    NETBSD,
+    NON_OVERLAPPING_GROUPS,
+    OPENBSD,
+    OTHER_UNIX,
+    SOLARIS,
+    SUNOS,
+    SYSTEM_V,
+    UNIX,
+    UNIX_LAYERS,
+    UNIX_WITHOUT_MACOS,
+    WINDOWS,
+    WSL1,
+    WSL2,
+    Group,
+    current_os,
+    is_aix,
+    is_cygwin,
+    is_freebsd,
+    is_hurd,
+    is_linux,
+    is_macos,
+    is_netbsd,
+    is_openbsd,
+    is_solaris,
+    is_sunos,
+    is_windows,
+    is_wsl1,
+    is_wsl2,
+    os_label,
+    reduce,
+)
+
+from .conftest import (
+    skip_linux,
+    skip_macos,
+    skip_windows,
+    unless_linux,
+    unless_macos,
+    unless_windows,
+)
+
+
+
[docs]def test_mutual_exclusion(): + """Only directly tests OSes on which the test suite is running via GitHub + actions.""" + if is_linux(): + assert LINUX.id == CURRENT_OS_ID + assert os_label(LINUX.id) == CURRENT_OS_LABEL + assert not is_aix() + assert not is_cygwin() + assert not is_freebsd() + assert not is_hurd() + assert not is_macos() + assert not is_netbsd() + assert not is_openbsd() + assert not is_solaris() + assert not is_sunos() + assert not is_windows() + assert not is_wsl1() + assert not is_wsl2() + if is_macos(): + assert MACOS.id == CURRENT_OS_ID + assert os_label(MACOS.id) == CURRENT_OS_LABEL + assert not is_aix() + assert not is_cygwin() + assert not is_freebsd() + assert not is_hurd() + assert not is_linux() + assert not is_netbsd() + assert not is_openbsd() + assert not is_solaris() + assert not is_sunos() + assert not is_windows() + assert not is_wsl1() + assert not is_wsl2() + if is_windows(): + assert WINDOWS.id == CURRENT_OS_ID + assert os_label(WINDOWS.id) == CURRENT_OS_LABEL + assert not is_aix() + assert not is_cygwin() + assert not is_freebsd() + assert not is_hurd() + assert not is_linux() + assert not is_macos() + assert not is_netbsd() + assert not is_openbsd() + assert not is_solaris() + assert not is_sunos() + assert not is_wsl1() + assert not is_wsl2()
+ + +
[docs]def test_platform_definitions(): + for platform in ALL_PLATFORMS.platforms: + # ID. + assert platform.id + assert platform.id.isascii() + assert platform.id.isalnum() + assert platform.id.islower() + # Name. + assert platform.name + assert platform.name.isascii() + assert platform.name.isprintable() + assert platform.name in ALL_OS_LABELS + # Identification function. + check_func_id = f"is_{platform.id}" + assert check_func_id in globals() + check_func = globals()[check_func_id] + assert isinstance(check_func, functools._lru_cache_wrapper) + assert isinstance(check_func(), bool) + assert check_func() == platform.current
+ + +
[docs]def test_unique_ids(): + """Platform and group IDs must be unique.""" + all_platform_ids = [p.id for p in ALL_PLATFORMS] + + # Platforms are expected to be sorted by ID. + assert sorted(all_platform_ids) == all_platform_ids + assert len(set(all_platform_ids)) == len(all_platform_ids) + + assert len(all_platform_ids) == len(ALL_PLATFORMS) + assert len(all_platform_ids) == len(ALL_PLATFORMS.platform_ids) + + all_group_ids = {g.id for g in ALL_GROUPS} + assert len(all_group_ids) == len(ALL_GROUPS) + + assert all_group_ids.isdisjoint(all_platform_ids)
+ + +
[docs]def test_group_constants(): + """Group constants and IDs must be aligned.""" + for group in ALL_GROUPS: + group_constant = group.id.upper() + assert group_constant in platforms_module.__dict__ + assert getattr(platforms_module, group_constant) is group
+ + +
[docs]def test_groups_content(): + for groups in (NON_OVERLAPPING_GROUPS, EXTRA_GROUPS, ALL_GROUPS): + assert isinstance(groups, frozenset) + for group in groups: + assert isinstance(group, Group) + + assert len(group) > 0 + assert len(group.platforms) == len(group.platform_ids) + assert group.platform_ids.issubset(ALL_PLATFORMS.platform_ids) + + # Check general subset properties. + assert group.issubset(ALL_PLATFORMS) + assert ALL_PLATFORMS.issuperset(group) + + # Each group is both a subset and a superset of itself. + assert group.issubset(group) + assert group.issuperset(group) + assert group.issubset(group.platforms) + assert group.issuperset(group.platforms) + + # Test against empty iterables. + assert group.issuperset(()) + assert group.issuperset([]) + assert group.issuperset({}) + assert group.issuperset(set()) + assert group.issuperset(frozenset()) + assert not group.issubset(()) + assert not group.issubset([]) + assert not group.issubset({}) + assert not group.issubset(set()) + assert not group.issubset(frozenset()) + + for platform in group.platforms: + assert platform in group + assert platform in ALL_PLATFORMS + assert platform.id in group.platform_ids + assert group.issuperset([platform]) + if len(group) == 1: + assert group.issubset([platform]) + else: + assert not group.issubset([platform]) + + # A group cannot be disjoint from itself. + assert not group.isdisjoint(group) + assert not group.isdisjoint(group.platforms) + assert group.fullyintersects(group) + assert group.fullyintersects(group.platforms)
+ + +
[docs]def test_logical_grouping(): + """Test logical grouping of platforms.""" + for group in BSD, ALL_LINUX, LINUX_LAYERS, SYSTEM_V, UNIX_LAYERS, OTHER_UNIX: + assert group.issubset(UNIX) + assert UNIX.issuperset(group) + + assert UNIX_WITHOUT_MACOS.issubset(UNIX) + assert UNIX.issuperset(UNIX_WITHOUT_MACOS) + + assert BSD_WITHOUT_MACOS.issubset(UNIX) + assert BSD_WITHOUT_MACOS.issubset(BSD) + assert UNIX.issuperset(BSD_WITHOUT_MACOS) + assert BSD.issuperset(BSD_WITHOUT_MACOS) + + # All platforms are divided into Windows and Unix at the highest level. + assert {p.id for p in ALL_PLATFORMS} == ALL_WINDOWS.platform_ids | UNIX.platform_ids + + # All UNIX platforms are divided into BSD, Linux, and Unix families. + assert UNIX.platform_ids == ( + BSD.platform_ids + | ALL_LINUX.platform_ids + | LINUX_LAYERS.platform_ids + | SYSTEM_V.platform_ids + | UNIX_LAYERS.platform_ids + | OTHER_UNIX.platform_ids + )
+ + +
[docs]def test_group_no_missing_platform(): + """Check all platform are attached to at least one group.""" + grouped_platforms = set() + for group in ALL_GROUPS: + grouped_platforms |= group.platform_ids + assert grouped_platforms == ALL_PLATFORMS.platform_ids
+ + +
[docs]def test_non_overlapping_groups(): + """Check non-overlapping groups are mutually exclusive.""" + for combination in combinations(NON_OVERLAPPING_GROUPS, 2): + group1, group2 = combination + assert group1.isdisjoint(group2) + assert group2.isdisjoint(group1)
+ + +
[docs]def test_overlapping_groups(): + """Check all extra groups overlaps with at least one non-overlapping.""" + for extra_group in EXTRA_GROUPS: + overlap = False + for group in NON_OVERLAPPING_GROUPS: + if not extra_group.isdisjoint(group): + overlap = True + break + assert overlap is True
+ + +
[docs]@pytest.mark.parametrize( + ("items", "expected"), + [ + ([], set()), + ((), set()), + (set(), set()), + ([AIX], {AIX}), + ([AIX, AIX], {AIX}), + ([UNIX], {UNIX}), + ([UNIX, UNIX], {UNIX}), + ([UNIX, AIX], {UNIX}), + ([WINDOWS], {ALL_WINDOWS}), + ([ALL_PLATFORMS, WINDOWS], {ALL_PLATFORMS}), + ([UNIX, WINDOWS], {ALL_PLATFORMS}), + ([UNIX, ALL_WINDOWS], {ALL_PLATFORMS}), + ([BSD_WITHOUT_MACOS, UNIX], {UNIX}), + ([BSD_WITHOUT_MACOS, MACOS], {BSD}), + ( + [ + AIX, + CYGWIN, + FREEBSD, + HURD, + LINUX, + MACOS, + NETBSD, + OPENBSD, + SOLARIS, + SUNOS, + WINDOWS, + WSL1, + WSL2, + ], + {ALL_PLATFORMS}, + ), + ], +) +def test_reduction(items, expected): + assert reduce(items) == expected
+ + +
[docs]def test_current_os_func(): + # Function. + current_platform = current_os() + assert current_platform in ALL_PLATFORMS.platforms + # Constants. + assert current_platform.id == CURRENT_OS_ID + assert current_platform.name == CURRENT_OS_LABEL
+ + +
[docs]def test_os_labels(): + assert len(ALL_OS_LABELS) == len(ALL_PLATFORMS) + current_platform = current_os() + assert os_label(current_platform.id) == current_platform.name
+ + +
[docs]@skip_linux +def test_skip_linux(): + assert not is_linux() + assert is_macos() or is_windows()
+ + +
[docs]@skip_macos +def test_skip_macos(): + assert not is_macos() + assert is_linux() or is_windows()
+ + +
[docs]@skip_windows +def test_skip_windows(): + assert not is_windows() + assert is_linux() or is_macos()
+ + +
[docs]@unless_linux +def test_unless_linux(): + assert is_linux() + assert not is_macos() + assert not is_windows()
+ + +
[docs]@unless_macos +def test_unless_macos(): + assert not is_linux() + assert is_macos() + assert not is_windows()
+ + +
[docs]@unless_windows +def test_unless_windows(): + assert not is_linux() + assert not is_macos() + assert is_windows()
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_pygments.html b/_modules/click_extra/tests/test_pygments.html new file mode 100644 index 000000000..3911892ad --- /dev/null +++ b/_modules/click_extra/tests/test_pygments.html @@ -0,0 +1,561 @@ + + + + + + + + click_extra.tests.test_pygments - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_pygments

+# 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.
+
+from __future__ import annotations
+
+import sys
+import tarfile
+from importlib import metadata
+from operator import itemgetter
+from pathlib import Path
+
+if sys.version_info >= (3, 11):
+    import tomllib
+else:
+    import tomli as tomllib  # type: ignore[import-not-found]
+
+import requests
+from boltons.strutils import camel2under
+from boltons.typeutils import issubclass
+from pygments.filter import Filter
+from pygments.filters import get_filter_by_name
+from pygments.formatter import Formatter
+from pygments.formatters import get_formatter_by_name
+from pygments.lexer import Lexer
+from pygments.lexers import find_lexer_class_by_name, get_lexer_by_name
+
+from click_extra import pygments as extra_pygments
+from click_extra.pygments import DEFAULT_TOKEN_TYPE, collect_session_lexers
+
+PROJECT_ROOT = Path(__file__).parent.parent.parent
+
+
+
[docs]def is_relative_to(path: Path, *other: Path) -> bool: + """Return `True` if the path is relative to another path or `False`. + + This is a backport of `pathlib.Path.is_relative_to` from Python 3.9. + """ + try: + path.relative_to(*other) + return True + except ValueError: + return False
+ + +
[docs]def test_ansi_lexers_candidates(tmp_path): + """Look into Pygments test suite to find all ANSI lexers candidates. + + Good candidates for ANSI colorization are lexers that are producing + ``Generic.Output`` tokens, which are often used by REPL-like and scripting + terminal to render text in a console. + + The list is manually maintained in Click Extra code, and this test is here to + detect new candidates from new releases of Pygments. + + .. attention:: + The Pygments source code is downloaded from GitHub in the form of an archive, + and extracted in a temporary folder. + + The version of Pygments used for this test is the one installed in the current + environment. + + .. danger:: Security check + While extracting the archive, we double check we are not fed an archive + exploiting relative ``..`` or ``.`` path attacks. + """ + version = metadata.version("pygments") + + source_url = ( + f"https://github.com/pygments/pygments/archive/refs/tags/{version}.tar.gz" + ) + base_folder = f"pygments-{version}" + archive_path = tmp_path / f"{base_folder}.tar.gz" + + # Download the source distribution from GitHub. + with requests.get(source_url) as response: + assert response.ok + archive_path.write_bytes(response.content) + + assert archive_path.exists() + assert archive_path.is_file() + assert archive_path.stat().st_size > 0 + + # Locations of lexer artifacts in test suite. + parser_token_traces = { + str(tmp_path / base_folder / "tests" / "examplefiles" / "*" / "*.output"), + str(tmp_path / base_folder / "tests" / "snippets" / "*" / "*.txt"), + } + + # Browse the downloaded package to find the test suite, and inspect the + # traces of parsed tokens used as gold master for lexers tests. + lexer_candidates = set() + with tarfile.open(archive_path, "r:gz") as tar: + for member in tar.getmembers(): + # Skip non-test files. + if not member.isfile(): + continue + + # XXX Security check of relative ``..`` or ``.`` path attacks. + filename = tmp_path.joinpath(member.name).resolve() + if sys.version_info >= (3, 9): + assert filename.is_relative_to(tmp_path) + else: + assert is_relative_to(filename, tmp_path) + + # Skip files that are not part of the test suite data. + match = False + for pattern in parser_token_traces: + if filename.match(pattern): + match = True + break + if not match: + continue + + file = tar.extractfile(member) + # Skip empty files. + if not file: + continue + + content = file.read().decode("utf-8") + + # Skip lexers that are rendering generic, terminal-like output tokens. + if f" {'.'.join(DEFAULT_TOKEN_TYPE)}\n" not in content: + continue + + # Extarct lexer alias from the test file path. + lexer_candidates.add(filename.parent.name) + + assert lexer_candidates + lexer_classes = {find_lexer_class_by_name(alias) for alias in lexer_candidates} + # We cannot test for strict equality yet, as some ANSI-ready lexers do not + # have any test artifacts producing ``Generic.Output`` tokens. + assert lexer_classes <= set(collect_session_lexers())
+ + +
[docs]def collect_classes(klass, prefix="Ansi"): + """Returns all classes defined in ``click_extra.pygments`` that are a subclass of + ``klass``, and whose name starts with the provided ``prefix``.""" + klasses = {} + for name, var in extra_pygments.__dict__.items(): + if issubclass(var, klass) and name.startswith(prefix): + klasses[name] = var + return klasses
+ + +
[docs]def get_pyproject_section(*section_path: str) -> dict[str, str]: + """Descends into the TOML tree of ``pyproject.toml`` to reach the value specified by + ``section_path``.""" + toml_path = PROJECT_ROOT.joinpath("pyproject.toml").resolve() + section: dict = tomllib.loads(toml_path.read_text(encoding="utf-8")) + for section_id in section_path: + section = section[section_id] + return section
+ + +
[docs]def check_entry_points(entry_points: dict[str, str], *section_path: str) -> None: + entry_points = dict(sorted(entry_points.items(), key=itemgetter(0))) + project_entry_points = get_pyproject_section(*section_path) + assert project_entry_points == entry_points
+ + +
[docs]def test_formatter_entry_points(): + entry_points = {} + for name in collect_classes(Formatter): + entry_id = camel2under(name).replace("_", "-") + entry_points[entry_id] = f"click_extra.pygments:{name}" + + check_entry_points(entry_points, "tool", "poetry", "plugins", "pygments.formatters")
+ + +
[docs]def test_filter_entry_points(): + entry_points = {} + for name in collect_classes(Filter): + entry_id = camel2under(name).replace("_", "-") + entry_points[entry_id] = f"click_extra.pygments:{name}" + + check_entry_points(entry_points, "tool", "poetry", "plugins", "pygments.filters")
+ + +
[docs]def test_lexer_entry_points(): + entry_points = {} + for lexer in collect_session_lexers(): + # Check an ANSI lexer variant is available for import from Click Extra. + ansi_lexer_id = f"Ansi{lexer.__name__}" + assert ansi_lexer_id in extra_pygments.__dict__ + + # Transform ANSI lexer class ID into entry point ID. + entry_id = "-".join( + w for w in camel2under(ansi_lexer_id).split("_") if w != "lexer" + ) + + # Generate the lexer entry point. + class_path = f"click_extra.pygments:{ansi_lexer_id}" + entry_points[entry_id] = class_path + + check_entry_points(entry_points, "tool", "poetry", "plugins", "pygments.lexers")
+ + +
[docs]def test_registered_formatters(): + for klass in collect_classes(Formatter).values(): + for alias in klass.aliases: + get_formatter_by_name(alias)
+ + +
[docs]def test_registered_filters(): + for name in collect_classes(Filter): + entry_id = camel2under(name).replace("_", "-") + get_filter_by_name(entry_id)
+ + +
[docs]def test_registered_lexers(): + for klass in collect_classes(Lexer).values(): + for alias in klass.aliases: + get_lexer_by_name(alias)
+ + +
[docs]def test_ansi_lexers_doc(): + doc_content = PROJECT_ROOT.joinpath("docs/pygments.md").read_text() + for lexer in collect_session_lexers(): + assert lexer.__name__ in doc_content
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_tabulate.html b/_modules/click_extra/tests/test_tabulate.html new file mode 100644 index 000000000..fd222b448 --- /dev/null +++ b/_modules/click_extra/tests/test_tabulate.html @@ -0,0 +1,877 @@ + + + + + + + + click_extra.tests.test_tabulate - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_tabulate

+# 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.
+
+from __future__ import annotations
+
+import pytest
+import tabulate
+from pytest_cases import fixture, parametrize
+
+# We use vanilla click primitives here to demonstrate the full-compatibility.
+from click_extra import echo, pass_context
+from click_extra.decorators import table_format_option
+from click_extra.platforms import is_windows
+from click_extra.tabulate import output_formats
+
+from .conftest import command_decorators
+
+
+
[docs]@pytest.mark.parametrize( + ("cmd_decorator", "cmd_type"), + command_decorators(with_types=True), +) +def test_unrecognized_format(invoke, cmd_decorator, cmd_type): + @cmd_decorator + @table_format_option + def tabulate_cli1(): + echo("It works!") + + result = invoke(tabulate_cli1, "--table-format", "random", color=False) + assert result.exit_code == 2 + assert not result.stdout + + group_help = " COMMAND [ARGS]..." if "group" in cmd_type else "" + extra_suggest = ( + "Try 'tabulate-cli1 --help' for help.\n" if "extra" not in cmd_type else "" + ) + assert result.stderr == ( + f"Usage: tabulate-cli1 [OPTIONS]{group_help}\n" + f"{extra_suggest}\n" + "Error: Invalid value for '-t' / '--table-format': 'random' is not one of " + "'asciidoc', 'csv', 'csv-excel', 'csv-excel-tab', 'csv-unix', 'double_grid', " + "'double_outline', 'fancy_grid', 'fancy_outline', 'github', 'grid', " + "'heavy_grid', 'heavy_outline', 'html', 'jira', 'latex', 'latex_booktabs', " + "'latex_longtable', 'latex_raw', 'mediawiki', 'mixed_grid', 'mixed_outline', " + "'moinmoin', 'orgtbl', 'outline', 'pipe', 'plain', 'presto', 'pretty', " + "'psql', 'rounded_grid', 'rounded_outline', 'rst', 'simple', 'simple_grid', " + "'simple_outline', 'textile', 'tsv', 'unsafehtml', 'vertical', 'youtrack'.\n" + )
+ + +asciidoc_table = ( + '[cols="5<,13<",options="header"]\n' + "|====\n" + "| day | temperature \n" + "| 1 | 87 \n" + "| 2 | 80 \n" + "| 3 | 79 \n" + "|====\n" +) + +csv_table = """\ +day,temperature\r +1,87\r +2,80\r +3,79\r +""" + +csv_excel_table = csv_table + +csv_excel_tab_table = """\ +day\ttemperature\r +1\t87\r +2\t80\r +3\t79\r +""" + +csv_unix_table = """\ +"day","temperature" +"1","87" +"2","80" +"3","79" +""" + +double_grid_table = """\ +╔═════╦═════════════╗ +║ day ║ temperature ║ +╠═════╬═════════════╣ +║ 1 ║ 87 ║ +╠═════╬═════════════╣ +║ 2 ║ 80 ║ +╠═════╬═════════════╣ +║ 3 ║ 79 ║ +╚═════╩═════════════╝ +""" + +double_outline_table = """\ +╔═════╦═════════════╗ +║ day ║ temperature ║ +╠═════╬═════════════╣ +║ 1 ║ 87 ║ +║ 2 ║ 80 ║ +║ 3 ║ 79 ║ +╚═════╩═════════════╝ +""" + +fancy_grid_table = """\ +╒═════╤═════════════╕ +│ day │ temperature │ +╞═════╪═════════════╡ +│ 1 │ 87 │ +├─────┼─────────────┤ +│ 2 │ 80 │ +├─────┼─────────────┤ +│ 3 │ 79 │ +╘═════╧═════════════╛ +""" + +fancy_outline_table = """\ +╒═════╤═════════════╕ +│ day │ temperature │ +╞═════╪═════════════╡ +│ 1 │ 87 │ +│ 2 │ 80 │ +│ 3 │ 79 │ +╘═════╧═════════════╛ +""" + +github_table = """\ +| day | temperature | +| --- | ----------- | +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | +""" + +grid_table = """\ ++-----+-------------+ +| day | temperature | ++=====+=============+ +| 1 | 87 | ++-----+-------------+ +| 2 | 80 | ++-----+-------------+ +| 3 | 79 | ++-----+-------------+ +""" + +heavy_grid_table = """\ +┏━━━━━┳━━━━━━━━━━━━━┓ +┃ day ┃ temperature ┃ +┣━━━━━╋━━━━━━━━━━━━━┫ +┃ 1 ┃ 87 ┃ +┣━━━━━╋━━━━━━━━━━━━━┫ +┃ 2 ┃ 80 ┃ +┣━━━━━╋━━━━━━━━━━━━━┫ +┃ 3 ┃ 79 ┃ +┗━━━━━┻━━━━━━━━━━━━━┛ +""" + +heavy_outline_table = """\ +┏━━━━━┳━━━━━━━━━━━━━┓ +┃ day ┃ temperature ┃ +┣━━━━━╋━━━━━━━━━━━━━┫ +┃ 1 ┃ 87 ┃ +┃ 2 ┃ 80 ┃ +┃ 3 ┃ 79 ┃ +┗━━━━━┻━━━━━━━━━━━━━┛ +""" + +html_table = """\ +<table> +<thead> +<tr><th>day</th><th>temperature</th></tr> +</thead> +<tbody> +<tr><td>1 </td><td>87 </td></tr> +<tr><td>2 </td><td>80 </td></tr> +<tr><td>3 </td><td>79 </td></tr> +</tbody> +</table> +""" + +jira_table = """\ +|| day || temperature || +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | +""" + +latex_table = """\ +\\begin{tabular}{ll} +\\hline + day & temperature \\\\ +\\hline + 1 & 87 \\\\ + 2 & 80 \\\\ + 3 & 79 \\\\ +\\hline +\\end{tabular} +""" + +latex_booktabs_table = """\ +\\begin{tabular}{ll} +\\toprule + day & temperature \\\\ +\\midrule + 1 & 87 \\\\ + 2 & 80 \\\\ + 3 & 79 \\\\ +\\bottomrule +\\end{tabular} +""" + +latex_longtable_table = """\ +\\begin{longtable}{ll} +\\hline + day & temperature \\\\ +\\hline +\\endhead + 1 & 87 \\\\ + 2 & 80 \\\\ + 3 & 79 \\\\ +\\hline +\\end{longtable} +""" + +latex_raw_table = """\ +\\begin{tabular}{ll} +\\hline + day & temperature \\\\ +\\hline + 1 & 87 \\\\ + 2 & 80 \\\\ + 3 & 79 \\\\ +\\hline +\\end{tabular} +""" + +mediawiki_table = """\ +{| class="wikitable" style="text-align: left;" +|+ <!-- caption --> +|- +! day !! temperature +|- +| 1 || 87 +|- +| 2 || 80 +|- +| 3 || 79 +|} +""" + +mixed_grid_table = """\ +┍━━━━━┯━━━━━━━━━━━━━┑ +│ day │ temperature │ +┝━━━━━┿━━━━━━━━━━━━━┥ +│ 1 │ 87 │ +├─────┼─────────────┤ +│ 2 │ 80 │ +├─────┼─────────────┤ +│ 3 │ 79 │ +┕━━━━━┷━━━━━━━━━━━━━┙ +""" + +mixed_outline_table = """\ +┍━━━━━┯━━━━━━━━━━━━━┑ +│ day │ temperature │ +┝━━━━━┿━━━━━━━━━━━━━┥ +│ 1 │ 87 │ +│ 2 │ 80 │ +│ 3 │ 79 │ +┕━━━━━┷━━━━━━━━━━━━━┙ +""" + +moinmoin_table = """\ +|| ''' day ''' || ''' temperature ''' || +|| 1 || 87 || +|| 2 || 80 || +|| 3 || 79 || +""" + +orgtbl_table = """\ +| day | temperature | +|-----+-------------| +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | +""" + +outline_table = """\ ++-----+-------------+ +| day | temperature | ++=====+=============+ +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | ++-----+-------------+ +""" + +pipe_table = """\ +| day | temperature | +|:----|:------------| +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | +""" + +plain_table = """\ +day temperature +1 87 +2 80 +3 79 +""" + +presto_table = """\ + day | temperature +-----+------------- + 1 | 87 + 2 | 80 + 3 | 79 +""" + +pretty_table = """\ ++-----+-------------+ +| day | temperature | ++-----+-------------+ +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | ++-----+-------------+ +""" + +psql_table = """\ ++-----+-------------+ +| day | temperature | +|-----+-------------| +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | ++-----+-------------+ +""" + +rounded_grid_table = """\ +╭─────┬─────────────╮ +│ day │ temperature │ +├─────┼─────────────┤ +│ 1 │ 87 │ +├─────┼─────────────┤ +│ 2 │ 80 │ +├─────┼─────────────┤ +│ 3 │ 79 │ +╰─────┴─────────────╯ +""" + +rounded_outline_table = """\ +╭─────┬─────────────╮ +│ day │ temperature │ +├─────┼─────────────┤ +│ 1 │ 87 │ +│ 2 │ 80 │ +│ 3 │ 79 │ +╰─────┴─────────────╯ +""" + +rst_table = """\ +=== =========== +day temperature +=== =========== +1 87 +2 80 +3 79 +=== =========== +""" + +simple_table = """\ +day temperature +--- ----------- +1 87 +2 80 +3 79 +""" + +simple_grid_table = """\ +┌─────┬─────────────┐ +│ day │ temperature │ +├─────┼─────────────┤ +│ 1 │ 87 │ +├─────┼─────────────┤ +│ 2 │ 80 │ +├─────┼─────────────┤ +│ 3 │ 79 │ +└─────┴─────────────┘ +""" + +simple_outline_table = """\ +┌─────┬─────────────┐ +│ day │ temperature │ +├─────┼─────────────┤ +│ 1 │ 87 │ +│ 2 │ 80 │ +│ 3 │ 79 │ +└─────┴─────────────┘ +""" + +textile_table = """\ +|_. day |_. temperature | +|<. 1 |<. 87 | +|<. 2 |<. 80 | +|<. 3 |<. 79 | +""" + +tsv_table = """\ +day\ttemperature +1 \t87 +2 \t80 +3 \t79 +""" + +unsafehtml_table = """\ +<table> +<thead> +<tr><th>day</th><th>temperature</th></tr> +</thead> +<tbody> +<tr><td>1 </td><td>87 </td></tr> +<tr><td>2 </td><td>80 </td></tr> +<tr><td>3 </td><td>79 </td></tr> +</tbody> +</table> +""" + +youtrack_table = """\ +|| day || temperature || +| 1 | 87 | +| 2 | 80 | +| 3 | 79 | +""" + +vertical_table = """\ +***************************[ 1. row ]*************************** +day | 1 +temperature | 87 +***************************[ 2. row ]*************************** +day | 2 +temperature | 80 +***************************[ 3. row ]*************************** +day | 3 +temperature | 79 +""" + + +expected_renderings = { + "asciidoc": asciidoc_table, + "csv": csv_table, + "csv-excel": csv_excel_table, + "csv-excel-tab": csv_excel_tab_table, + "csv-unix": csv_unix_table, + "double_grid": double_grid_table, + "double_outline": double_outline_table, + "fancy_grid": fancy_grid_table, + "fancy_outline": fancy_outline_table, + "github": github_table, + "grid": grid_table, + "heavy_grid": heavy_grid_table, + "heavy_outline": heavy_outline_table, + "html": html_table, + "jira": jira_table, + "latex": latex_table, + "latex_booktabs": latex_booktabs_table, + "latex_longtable": latex_longtable_table, + "latex_raw": latex_raw_table, + "mediawiki": mediawiki_table, + "mixed_grid": mixed_grid_table, + "mixed_outline": mixed_outline_table, + "moinmoin": moinmoin_table, + "orgtbl": orgtbl_table, + "outline": outline_table, + "pipe": pipe_table, + "plain": plain_table, + "presto": presto_table, + "pretty": pretty_table, + "psql": psql_table, + "rounded_grid": rounded_grid_table, + "rounded_outline": rounded_outline_table, + "rst": rst_table, + "simple": simple_table, + "simple_grid": simple_grid_table, + "simple_outline": simple_outline_table, + "textile": textile_table, + "tsv": tsv_table, + "unsafehtml": unsafehtml_table, + "youtrack": youtrack_table, + "vertical": vertical_table, +} + + +
[docs]def test_recognized_modes(): + """Check all rendering modes proposed by the table module are accounted for and + there is no duplicates.""" + assert set(tabulate._table_formats) <= expected_renderings.keys() + assert set(tabulate._table_formats) <= set(output_formats) + + assert len(output_formats) == len(expected_renderings.keys()) + assert set(output_formats) == set(expected_renderings.keys())
+ + +
[docs]@fixture +@parametrize("cmd_decorator", command_decorators(no_groups=True)) +@parametrize("option_decorator", (table_format_option, table_format_option())) +def table_cli(cmd_decorator, option_decorator): + @cmd_decorator + @option_decorator + @pass_context + def tabulate_cli2(ctx): + format_id = ctx.meta["click_extra.table_format"] + echo(f"Table format: {format_id}") + + data = ((1, 87), (2, 80), (3, 79)) + headers = ("day", "temperature") + ctx.print_table(data, headers) + + return tabulate_cli2
+ + +
[docs]@pytest.mark.parametrize( + ("format_name", "expected"), + (pytest.param(k, v, id=k) for k, v in expected_renderings.items()), +) +def test_all_table_rendering(invoke, table_cli, format_name, expected): + result = invoke(table_cli, "--table-format", format_name) + assert result.exit_code == 0 + if not is_windows(): + expected = expected.replace("\r\n", "\n") + assert result.stdout == f"Table format: {format_name}\n{expected}" + assert not result.stderr
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_telemetry.html b/_modules/click_extra/tests/test_telemetry.html new file mode 100644 index 000000000..87331e39f --- /dev/null +++ b/_modules/click_extra/tests/test_telemetry.html @@ -0,0 +1,419 @@ + + + + + + + + click_extra.tests.test_telemetry - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_telemetry

+# 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.
+
+from __future__ import annotations
+
+from textwrap import dedent
+
+from pytest_cases import parametrize
+
+from click_extra import command, echo, pass_context, telemetry_option
+
+from .conftest import command_decorators
+
+
+
[docs]@parametrize("cmd_decorator", command_decorators(no_groups=True, no_extra=True)) +@parametrize("option_decorator", (telemetry_option, telemetry_option())) +def test_standalone_telemetry_option(invoke, cmd_decorator, option_decorator): + @cmd_decorator + @option_decorator + @pass_context + def standalone_telemetry(ctx): + echo("It works!") + echo(f"Telemetry value: {ctx.telemetry}") + + result = invoke(standalone_telemetry, "--help") + assert result.exit_code == 0 + assert not result.stderr + + assert result.stdout == dedent( + """\ + Usage: standalone-telemetry [OPTIONS] + + Options: + --telemetry / --no-telemetry Collect telemetry and usage data. [env var: + DO_NOT_TRACK] + --help Show this message and exit. + """, + ) + + result = invoke(standalone_telemetry, "--telemetry") + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == "It works!\nTelemetry value: True\n" + + result = invoke(standalone_telemetry, "--no-telemetry") + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == "It works!\nTelemetry value: False\n"
+ + +
[docs]def test_multiple_envvars(invoke): + @command(context_settings={"auto_envvar_prefix": "yo", "show_default": True}) + @telemetry_option + @pass_context + def standalone_telemetry(ctx): + echo("It works!") + echo(f"Telemetry value: {ctx.telemetry}") + + result = invoke(standalone_telemetry, "--help") + assert result.exit_code == 0 + assert not result.stderr + + assert result.stdout == dedent( + """\ + Usage: standalone-telemetry [OPTIONS] + + Options: + --telemetry / --no-telemetry Collect telemetry and usage data. [env var: + DO_NOT_TRACK; default: no-telemetry] + --help Show this message and exit. + """, + ) + + result = invoke(standalone_telemetry, env={"DO_NOT_TRACK": "1"}) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == "It works!\nTelemetry value: True\n"
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_testing.html b/_modules/click_extra/tests/test_testing.html new file mode 100644 index 000000000..ca0c79c48 --- /dev/null +++ b/_modules/click_extra/tests/test_testing.html @@ -0,0 +1,575 @@ + + + + + + + + click_extra.tests.test_testing - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_testing

+# 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.
+"""Test the testing utilities and the simulation of CLI execution."""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+
+import click
+import pytest
+from pytest_cases import fixture, parametrize
+
+from click_extra import Style, command, echo, pass_context, secho, style
+from click_extra.platforms import is_windows
+from click_extra.testing import ExtraCliRunner, env_copy
+
+from .conftest import command_decorators, skip_windows_colors
+
+
+
[docs]def test_real_fs(): + """Check a simple test is not caught into the CLI runner fixture which is + encapsulating all filesystem access into temporary directory structure.""" + assert str(Path(__file__)).startswith(str(Path.cwd()))
+ + +
[docs]def test_temporary_fs(extra_runner): + """Check the CLI runner fixture properly encapsulated the filesystem in temporary + directory.""" + assert not str(Path(__file__)).startswith(str(Path.cwd()))
+ + +
[docs]def test_env_copy(): + env_var = "MPM_DUMMY_ENV_VAR_93725" + assert env_var not in os.environ + + no_env = env_copy() + assert no_env is None + + extended_env = env_copy({env_var: "yo"}) + assert env_var in extended_env + assert extended_env[env_var] == "yo" + assert env_var not in os.environ
+ + +
[docs]def test_runner_output(): + @command + def cli_output(): + echo("1 - stdout") + echo("2 - stderr", err=True) + echo("3 - stdout") + echo("4 - stderr", err=True) + + runner = ExtraCliRunner(mix_stderr=False) + result = runner.invoke(cli_output) + + assert result.output == "1 - stdout\n3 - stdout\n" + assert result.stdout == result.output + assert result.stderr == "2 - stderr\n4 - stderr\n" + + runner_mix = ExtraCliRunner(mix_stderr=True) + result_mix = runner_mix.invoke(cli_output) + + assert result_mix.output == "1 - stdout\n2 - stderr\n3 - stdout\n4 - stderr\n" + assert result_mix.stdout == "1 - stdout\n3 - stdout\n" + assert result_mix.stderr == "2 - stderr\n4 - stderr\n"
+ + +
[docs]@pytest.mark.parametrize("mix_stderr", (True, False)) +def test_runner_empty_stderr(mix_stderr): + @command + def cli_empty_stderr(): + echo("stdout") + + runner = ExtraCliRunner(mix_stderr=mix_stderr) + result = runner.invoke(cli_empty_stderr) + + assert result.output == "stdout\n" + assert result.stdout == result.output + assert result.stderr == ""
+ + +@click.command +@pass_context +def run_cli1(ctx): + """https://github.com/pallets/click/issues/2111.""" + echo(Style(fg="green")("echo()")) + echo(Style(fg="green")("echo(color=None)"), color=None) + echo(Style(fg="red")("echo(color=True) bypass invoke.color = False"), color=True) + echo(Style(fg="green")("echo(color=False)"), color=False) + + secho("secho()", fg="green") + secho("secho(color=None)", fg="green", color=None) + secho("secho(color=True) bypass invoke.color = False", fg="red", color=True) + secho("secho(color=False)", fg="green", color=False) + + logging.getLogger("click_extra").warning("Is the logger colored?") + + print(style("print() bypass Click.", fg="blue")) + + echo(f"Context.color = {ctx.color!r}") + echo(f"click.utils.should_strip_ansi = {click.utils.should_strip_ansi()!r}") + + +
[docs]@fixture +@parametrize("cmd_decorator", command_decorators(no_groups=True)) +def color_cli(cmd_decorator): + @cmd_decorator + @pass_context + def run_cli2(ctx): + """https://github.com/pallets/click/issues/2111.""" + echo(Style(fg="green")("echo()")) + echo(Style(fg="green")("echo(color=None)"), color=None) + echo( + Style(fg="red")("echo(color=True) bypass invoke.color = False"), + color=True, + ) + echo(Style(fg="green")("echo(color=False)"), color=False) + + secho("secho()", fg="green") + secho("secho(color=None)", fg="green", color=None) + secho("secho(color=True) bypass invoke.color = False", fg="red", color=True) + secho("secho(color=False)", fg="green", color=False) + + logging.getLogger("click_extra").warning("Is the logger colored?") + + print(style("print() bypass Click.", fg="blue")) + + echo(f"Context.color = {ctx.color!r}") + echo(f"click.utils.should_strip_ansi = {click.utils.should_strip_ansi()!r}") + + return run_cli2
+ + +
[docs]def check_default_colored_rendering(result): + assert result.exit_code == 0 + assert result.stdout.startswith( + "\x1b[32mecho()\x1b[0m\n" + "\x1b[32mecho(color=None)\x1b[0m\n" + "\x1b[31mecho(color=True) bypass invoke.color = False\x1b[0m\n" + "echo(color=False)\n" + "\x1b[32msecho()\x1b[0m\n" + "\x1b[32msecho(color=None)\x1b[0m\n" + "\x1b[31msecho(color=True) bypass invoke.color = False\x1b[0m\n" + "secho(color=False)\n" + "\x1b[34mprint() bypass Click.\x1b[0m\n", + ) + assert result.stderr == "\x1b[33mwarning\x1b[0m: Is the logger colored?\n"
+ + +
[docs]def check_default_uncolored_rendering(result): + assert result.exit_code == 0 + assert result.stdout.startswith( + "echo()\n" + "echo(color=None)\n" + "\x1b[31mecho(color=True) bypass invoke.color = False\x1b[0m\n" + "echo(color=False)\n" + "secho()\n" + "secho(color=None)\n" + "\x1b[31msecho(color=True) bypass invoke.color = False\x1b[0m\n" + "secho(color=False)\n" + "\x1b[34mprint() bypass Click.\x1b[0m\n", + ) + assert result.stderr == "warning: Is the logger colored?\n"
+ + +
[docs]def check_forced_uncolored_rendering(result): + assert result.exit_code == 0 + assert result.stdout.startswith( + "echo()\n" + "echo(color=None)\n" + "echo(color=True) bypass invoke.color = False\n" + "echo(color=False)\n" + "secho()\n" + "secho(color=None)\n" + "secho(color=True) bypass invoke.color = False\n" + "secho(color=False)\n" + "print() bypass Click.\n", + ) + assert result.stderr == "warning: Is the logger colored?\n"
+ + +
[docs]@skip_windows_colors +def test_invoke_optional_color(invoke): + result = invoke(run_cli1, color=None) + check_default_uncolored_rendering(result) + assert result.stdout.endswith( + "Context.color = None\nclick.utils.should_strip_ansi = True\n", + )
+ + +
[docs]@skip_windows_colors +def test_invoke_default_color(invoke): + result = invoke(run_cli1) + check_default_uncolored_rendering(result) + assert result.stdout.endswith( + "Context.color = None\nclick.utils.should_strip_ansi = True\n", + )
+ + +
[docs]@skip_windows_colors +def test_invoke_forced_color_stripping(invoke): + result = invoke(run_cli1, color=False) + check_forced_uncolored_rendering(result) + assert result.stdout.endswith( + "Context.color = None\nclick.utils.should_strip_ansi = True\n", + )
+ + +
[docs]@skip_windows_colors +def test_invoke_color_keep(invoke): + """On Windows Click ends up deciding it is not running in an interactive terminal + and forces the stripping of all colors.""" + result = invoke(run_cli1, color=True) + if is_windows(): + check_default_uncolored_rendering(result) + else: + check_default_colored_rendering(result) + assert result.stdout.endswith( + "Context.color = None\nclick.utils.should_strip_ansi = False\n", + )
+ + +
[docs]@skip_windows_colors +def test_invoke_color_forced(invoke): + """Test colors are preserved while invoking, and forced to be rendered on + Windows.""" + result = invoke(run_cli1, color="forced") + check_default_colored_rendering(result) + assert result.stdout.endswith( + "Context.color = True\nclick.utils.should_strip_ansi = False\n", + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_timer.html b/_modules/click_extra/tests/test_timer.html new file mode 100644 index 000000000..a6df434c8 --- /dev/null +++ b/_modules/click_extra/tests/test_timer.html @@ -0,0 +1,445 @@ + + + + + + + + click_extra.tests.test_timer - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_timer

+# 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.
+"""Test defaults of our custom commands, as well as their customizations and attached
+options, and how they interact with each others."""
+
+from __future__ import annotations
+
+import re
+from textwrap import dedent
+from time import sleep
+
+from pytest_cases import parametrize
+
+from click_extra import echo
+from click_extra.decorators import extra_group, timer_option
+
+from .conftest import (
+    command_decorators,
+)
+
+
+@extra_group
+def integrated_timer():
+    echo("Start of CLI")
+
+
+@integrated_timer.command()
+def fast_subcommand():
+    sleep(0.02)
+    echo("End of fast subcommand")
+
+
+@integrated_timer.command()
+def slow_subcommand():
+    sleep(0.2)
+    echo("End of slow subcommand")
+
+
+
[docs]@parametrize( + "subcommand_id, time_min, time_max", + ( + ("fast", 0.01, 0.2), + ("slow", 0.1, 1), + ), +) +def test_integrated_time_option(invoke, subcommand_id, time_min, time_max): + result = invoke(integrated_timer, "--time", f"{subcommand_id}-subcommand") + assert result.exit_code == 0 + assert not result.stderr + group = re.fullmatch( + rf"Start of CLI\nEnd of {subcommand_id} subcommand\n" + r"Execution time: (?P<time>[0-9.]+) seconds.\n", + result.stdout, + ) + assert group + assert time_min < float(group.groupdict()["time"]) < time_max
+ + +
[docs]@parametrize("subcommand_id", ("fast", "slow")) +def test_integrated_notime_option(invoke, subcommand_id): + result = invoke(integrated_timer, "--no-time", f"{subcommand_id}-subcommand") + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == f"Start of CLI\nEnd of {subcommand_id} subcommand\n"
+ + +
[docs]@parametrize( + "cmd_decorator", + # Skip click extra's commands, as timer option is already part of the default. + command_decorators(no_groups=True, no_extra=True), +) +@parametrize("option_decorator", (timer_option, timer_option())) +def test_standalone_timer_option(invoke, cmd_decorator, option_decorator): + @cmd_decorator + @option_decorator + def standalone_timer(): + echo("It works!") + + result = invoke(standalone_timer, "--help") + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == dedent( + """\ + Usage: standalone-timer [OPTIONS] + + Options: + --time / --no-time Measure and print elapsed execution time. + --help Show this message and exit. + """, + ) + + result = invoke(standalone_timer, "--time") + assert result.exit_code == 0 + assert not result.stderr + assert re.fullmatch( + r"It works!\nExecution time: [0-9.]+ seconds.\n", + result.stdout, + ) + + result = invoke(standalone_timer, "--no-time") + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == "It works!\n"
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/tests/test_version.html b/_modules/click_extra/tests/test_version.html new file mode 100644 index 000000000..998a58719 --- /dev/null +++ b/_modules/click_extra/tests/test_version.html @@ -0,0 +1,609 @@ + + + + + + + + click_extra.tests.test_version - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.tests.test_version

+# 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.
+"""Test the ``--version`` option.
+
+.. todo::
+    Test standalone scripts setting package name to filename and version to
+    `None`.
+
+.. todo::
+    Test standalone script fetching version from ``__version__`` variable.
+"""
+
+from __future__ import annotations
+
+import re
+
+import click
+import pytest
+from boltons.strutils import strip_ansi
+from pytest_cases import parametrize
+
+from click_extra import ExtraVersionOption, Style, __version__, echo, pass_context
+from click_extra.decorators import (
+    color_option,
+    extra_group,
+    extra_version_option,
+    verbosity_option,
+)
+
+from .conftest import (
+    command_decorators,
+    default_debug_colored_log_end,
+    default_debug_colored_logging,
+    default_debug_colored_version_details,
+    skip_windows_colors,
+)
+
+
+
[docs]@skip_windows_colors +@parametrize("cmd_decorator", command_decorators()) +@parametrize("option_decorator", (extra_version_option, extra_version_option())) +def test_standalone_version_option(invoke, cmd_decorator, option_decorator): + @cmd_decorator + @option_decorator + def standalone_option(): + echo("It works!") + + result = invoke(standalone_option, "--version", color=True) + assert result.exit_code == 0 + assert not result.stderr + assert result.output == ( + "\x1b[97mstandalone-option\x1b[0m, " + f"version \x1b[32m{__version__}" + "\x1b[0m\n" + )
+ + +
[docs]@skip_windows_colors +@parametrize("cmd_decorator", command_decorators()) +@parametrize("option_decorator", (extra_version_option, extra_version_option())) +def test_debug_output(invoke, cmd_decorator, option_decorator): + @cmd_decorator + @verbosity_option + @option_decorator + def debug_output(): + echo("It works!") + + result = invoke(debug_output, "--verbosity", "DEBUG", "--version", color=True) + assert result.exit_code == 0 + + assert re.fullmatch( + ( + default_debug_colored_logging + + default_debug_colored_version_details + + r"\x1b\[97mdebug-output\x1b\[0m, " + rf"version \x1b\[32m{re.escape(__version__)}\x1b\[0m\n" + + default_debug_colored_log_end + ), + result.output, + )
+ + +
[docs]@skip_windows_colors +def test_set_version(invoke): + @click.group + @extra_version_option(version="1.2.3.4") + def color_cli2(): + echo("It works!") + + # Test default coloring. + result = invoke(color_cli2, "--version", color=True) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == ( + "\x1b[97mcolor-cli2\x1b[0m, version \x1b[32m1.2.3.4\x1b[0m\n" + )
+ + +
[docs]@skip_windows_colors +@parametrize("cmd_decorator", command_decorators(no_groups=True)) +@parametrize( + "message, regex_stdout", + ( + ( + "{prog_name}, version {version}", + r"\x1b\[97mcolor-cli3\x1b\[0m, " + rf"version \x1b\[32m{re.escape(__version__)}" + r"\x1b\[0m\n", + ), + ( + "{prog_name}, version {version}\n{env_info}", + r"\x1b\[97mcolor-cli3\x1b\[0m, " + rf"version \x1b\[32m{re.escape(__version__)}" + r"\x1b\[0m\n" + r"\x1b\[90m{'.+'}" + r"\x1b\[0m\n", + ), + ( + "{prog_name} v{version} - {package_name}", + r"\x1b\[97mcolor-cli3\x1b\[0m " + rf"v\x1b\[32m{re.escape(__version__)}" + r"\x1b\[0m - " + r"\x1b\[97mclick_extra" + r"\x1b\[0m\n", + ), + ( + "{prog_name}, version {version} (Python {env_info[python][version]})", + r"\x1b\[97mcolor-cli3\x1b\[0m, " + rf"version \x1b\[32m{re.escape(__version__)}\x1b\[0m " + r"\(Python \x1b\[90m3\.\d+\.\d+ .+\x1b\[0m\)\n", + ), + ), +) +def test_custom_message(invoke, cmd_decorator, message, regex_stdout): + @cmd_decorator + @extra_version_option(message=message) + def color_cli3(): + echo("It works!") + + result = invoke(color_cli3, "--version", color=True) + assert result.exit_code == 0 + assert not result.stderr + assert re.fullmatch(regex_stdout, result.output)
+ + +
[docs]@parametrize("cmd_decorator", command_decorators(no_groups=True)) +def test_style_reset(invoke, cmd_decorator): + @cmd_decorator + @extra_version_option( + message_style=None, + version_style=None, + prog_name_style=None, + ) + def color_reset(): + pass + + result = invoke(color_reset, "--version", color=True) + assert result.exit_code == 0 + assert not result.stderr + assert result.output == strip_ansi(result.output)
+ + +
[docs]@skip_windows_colors +@parametrize("cmd_decorator", command_decorators(no_groups=True)) +def test_custom_message_style(invoke, cmd_decorator): + @cmd_decorator + @extra_version_option( + message="{prog_name} v{version} - {package_name} (latest)", + message_style=Style(fg="cyan"), + prog_name_style=Style(fg="green", bold=True), + version_style=Style(fg="bright_yellow", bg="red"), + package_name_style=Style(fg="bright_blue", italic=True), + ) + def custom_style(): + pass + + result = invoke(custom_style, "--version", color=True) + assert result.exit_code == 0 + assert not result.stderr + assert result.output == ( + "\x1b[32m\x1b[1mcustom-style\x1b[0m\x1b[36m " + f"v\x1b[0m\x1b[93m\x1b[41m{__version__}\x1b[0m\x1b[36m - " + "\x1b[0m\x1b[94m\x1b[3mclick_extra\x1b[0m\x1b[36m (latest)\x1b[0m\n" + )
+ + +
[docs]@parametrize("cmd_decorator", command_decorators(no_groups=True)) +def test_context_meta(invoke, cmd_decorator): + @cmd_decorator + @extra_version_option + @pass_context + def version_metadata(ctx): + for field in ExtraVersionOption.template_fields: + value = ctx.meta[f"click_extra.{field}"] + echo(f"{field} = {value}") + + result = invoke(version_metadata, color=True) + assert result.exit_code == 0 + assert not result.stderr + assert re.fullmatch( + ( + r"module = <module 'click_extra\.testing' from '.+testing\.py'>\n" + r"module_name = click_extra\.testing\n" + r"module_file = .+testing\.py\n" + rf"module_version = None\n" + r"package_name = click_extra\n" + rf"package_version = {__version__}\n" + r"exec_name = click_extra\.testing\n" + rf"version = {__version__}\n" + r"prog_name = version-metadata\n" + r"env_info = {'.+'}\n" + ), + result.output, + ) + assert result.output == strip_ansi(result.output)
+ + +
[docs]@skip_windows_colors +@pytest.mark.parametrize( + "params", + (None, "--help", "blah", ("--config", "random.toml")), +) +def test_integrated_version_option_precedence(invoke, params): + @extra_group(version="1.2.3.4") + def color_cli4(): + echo("It works!") + + result = invoke(color_cli4, "--version", params, color=True) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == ( + "\x1b[97mcolor-cli4\x1b[0m, version \x1b[32m1.2.3.4\x1b[0m\n" + )
+ + +
[docs]@skip_windows_colors +def test_color_option_precedence(invoke): + """--no-color has an effect on --version, if placed in the right order. + + Eager parameters are evaluated in the order as they were provided on the command + line by the user as expleined in: + https://click.palletsprojects.com/en/8.0.x/advanced/#callback-evaluation-order + + .. todo:: + + Maybe have the possibility to tweak CLI callback evaluation order so we can + let the user to have the NO_COLOR env set to allow for color-less ``--version`` + output. + """ + + @click.command + @color_option + @extra_version_option(version="2.1.9") + def color_cli6(): + echo(Style(fg="yellow")("It works!")) + + result = invoke(color_cli6, "--no-color", "--version", "command1", color=True) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == "color-cli6, version 2.1.9\n" + + result = invoke(color_cli6, "--version", "--no-color", "command1", color=True) + assert result.exit_code == 0 + assert not result.stderr + assert result.stdout == ( + "\x1b[97mcolor-cli6\x1b[0m, version \x1b[32m2.1.9\x1b[0m\n" + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/timer.html b/_modules/click_extra/timer.html new file mode 100644 index 000000000..36e015da4 --- /dev/null +++ b/_modules/click_extra/timer.html @@ -0,0 +1,409 @@ + + + + + + + + click_extra.timer - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.timer

+# 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.
+"""Command execution time measurement."""
+
+from __future__ import annotations
+
+from gettext import gettext as _
+from time import perf_counter
+from typing import Sequence
+
+from . import Context, Parameter, echo
+from .parameters import ExtraOption
+
+
+
[docs]class TimerOption(ExtraOption): + """A pre-configured option that is adding a ``--time``/``--no-time`` flag to print + elapsed time at the end of CLI execution. + + The start time is made available in the context in + ``ctx.meta["click_extra.start_time"]``. + """ + +
[docs] def print_timer(self): + """Compute and print elapsed execution time.""" + echo(f"Execution time: {perf_counter() - self.start_time:0.3f} seconds.")
+ +
[docs] def register_timer_on_close( + self, + ctx: Context, + param: Parameter, + value: bool, + ) -> None: + """Callback setting up all timer's machinery. + + Computes and print the execution time at the end of the CLI, if option has been + activated. + """ + # Take timestamp snapshot. + self.start_time = perf_counter() + + ctx.meta["click_extra.start_time"] = self.start_time + + # Skip timekeeping if option is not active. + if value: + # Register printing at the end of execution. + ctx.call_on_close(self.print_timer)
+ + def __init__( + self, + param_decls: Sequence[str] | None = None, + default=False, + expose_value=False, + help=_("Measure and print elapsed execution time."), + **kwargs, + ) -> None: + if not param_decls: + param_decls = ("--time/--no-time",) + + kwargs.setdefault("callback", self.register_timer_on_close) + + super().__init__( + param_decls=param_decls, + default=default, + expose_value=expose_value, + help=help, + **kwargs, + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/click_extra/version.html b/_modules/click_extra/version.html new file mode 100644 index 000000000..f9870e82f --- /dev/null +++ b/_modules/click_extra/version.html @@ -0,0 +1,798 @@ + + + + + + + + click_extra.version - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for click_extra.version

+# 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.
+"""Gather CLI metadata and print them."""
+
+from __future__ import annotations
+
+import inspect
+import logging
+import os
+from functools import cached_property
+from gettext import gettext as _
+from importlib import metadata
+from typing import TYPE_CHECKING, cast
+
+import click
+from boltons.ecoutils import get_profile
+from boltons.formatutils import BaseFormatField, tokenize_format_str
+
+from . import Context, Parameter, Style, echo, get_current_context
+from .colorize import default_theme
+from .parameters import ExtraOption
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+    from types import FrameType, ModuleType
+
+    from cloup.styling import IStyle
+
+
+
[docs]class ExtraVersionOption(ExtraOption): + """Gather CLI metadata and prints a colored version string. + + .. note:: + This started as a `copy of the standard @click.version_option() decorator + <https://github.com/pallets/click/blob/dc918b4/src/click/decorators.py#L399-L466>`_, + but is **no longer a drop-in replacement**. Hence the ``Extra`` prefix. + + This address the following Click issues: + + - `click#2324 <https://github.com/pallets/click/issues/2324>`_, + to allow its use with the declarative ``params=`` argument. + + - `click#2331 <https://github.com/pallets/click/issues/2331>`_, + by distinguishing the module from the package. + + - `click#1756 <https://github.com/pallets/click/issues/1756>`_, + by allowing path and Python version. + """ + + message: str = _("{prog_name}, version {version}") + """Default message template used to render the version string.""" + + template_fields: tuple[str, ...] = ( + "module", + "module_name", + "module_file", + "module_version", + "package_name", + "package_version", + "exec_name", + "version", + "prog_name", + "env_info", + ) + """List of field IDs recognized by the message template.""" + + def __init__( + self, + param_decls: Sequence[str] | None = None, + message: str | None = None, + # Field value overrides. + module: str | None = None, + module_name: str | None = None, + module_file: str | None = None, + module_version: str | None = None, + package_name: str | None = None, + package_version: str | None = None, + exec_name: str | None = None, + version: str | None = None, + prog_name: str | None = None, + env_info: dict[str, str] | None = None, + # Field style overrides. + message_style: IStyle | None = None, + module_style: IStyle | None = None, + module_name_style: IStyle | None = default_theme.invoked_command, + module_file_style: IStyle | None = None, + module_version_style: IStyle | None = Style(fg="green"), + package_name_style: IStyle | None = default_theme.invoked_command, + package_version_style: IStyle | None = Style(fg="green"), + exec_name_style: IStyle | None = default_theme.invoked_command, + version_style: IStyle | None = Style(fg="green"), + prog_name_style: IStyle | None = default_theme.invoked_command, + env_info_style: IStyle | None = Style(fg="bright_black"), + is_flag=True, + expose_value=False, + is_eager=True, + help=_("Show the version and exit."), + **kwargs, + ) -> None: + """Preconfigured as a ``--version`` option flag. + + :param message: the message template to print, in `format string syntax + <https://docs.python.org/3/library/string.html#format-string-syntax>`_. + Defaults to ``{prog_name}, version {version}``. + + :param module: forces the value of ``{module}``. + :param module_name: forces the value of ``{module_name}``. + :param module_file: forces the value of ``{module_file}``. + :param module_version: forces the value of ``{module_version}``. + :param package_name: forces the value of ``{package_name}``. + :param package_version: forces the value of ``{package_version}``. + :param exec_name: forces the value of ``{exec_name}``. + :param version: forces the value of ``{version}``. + :param prog_name: forces the value of ``{prog_name}``. + :param env_info: forces the value of ``{env_info}``. + + :param message_style: default style of the message. + + :param module_style: style of ``{module}``. + :param module_name_style: style of ``{module_name}``. + :param module_file_style: style of ``{module_file}``. + :param module_version_style: style of ``{module_version}``. + :param package_name_style: style of ``{package_name}``. + :param package_version_style: style of ``{package_version}``. + :param exec_name_style: style of ``{exec_name}``. + :param version_style: style of ``{version}``. + :param prog_name_style: style of ``{prog_name}``. + :param env_info_style: style of ``{env_info}``. + """ + if not param_decls: + param_decls = ("--version",) + + if message is not None: + self.message = message + + self.message_style = message_style + + # Overrides default field's value and style with user-provided parameters. + for field_id in self.template_fields: + # Override field value. + user_value = locals().get(field_id) + if user_value is not None: + setattr(self, field_id, user_value) + + # Set field style. + style_id = f"{field_id}_style" + setattr(self, style_id, locals()[style_id]) + + kwargs.setdefault("callback", self.print_and_exit) + + super().__init__( + param_decls=param_decls, + is_flag=is_flag, + expose_value=expose_value, + is_eager=is_eager, + help=help, + **kwargs, + ) + +
[docs] @staticmethod + def cli_frame() -> FrameType: + """Returns the frame in which the CLI is implemented. + + Inspects the execution stack frames to find the package in which the user's CLI + is implemented. + + Returns the frame name, the frame itself, and the frame chain for debugging. + """ + # Keep a list of all frames inspected for debugging. + frame_chain: list[tuple[str, str]] = [] + + # Walk the execution stack from bottom to top. + for frame_info in inspect.stack(): + frame = frame_info.frame + + # Get the current package name from the frame's globals. + frame_name = frame.f_globals["__name__"] + + # Get the current function name. + func_name = frame_info.function + + # Keep track of the inspected frames. + frame_chain.append((frame_name, func_name)) + + # Stop at the invoke() function of any CliRunner class, which is used for + # testing. + if func_name == "invoke" and isinstance( + frame.f_locals.get("self"), + click.testing.CliRunner, + ): + pass + + # Skip the intermediate frames added by the `@cached_property` decorator + # and the Click ecosystem. + elif frame_name.startswith(("functools", "click_extra", "cloup", "click")): + continue + + # We found the frame where the CLI is implemented. + return frame + + # Our heuristics to locate the CLI implementation failed. + logger = logging.getLogger("click_extra") + count_size = len(str(len(frame_chain))) + for counter, (p_name, f_name) in enumerate(frame_chain): + logger.debug(f"Frame {counter:<{count_size}} # {p_name}:{f_name}") + msg = "Could not find the frame in which the CLI is implemented." + raise RuntimeError(msg)
+ + @cached_property + def module(self) -> ModuleType: + """Returns the module in which the CLI resides.""" + frame = self.cli_frame() + + module = inspect.getmodule(frame) + if not module: + msg = f"Cannot find module of {frame!r}" + raise RuntimeError(msg) + + return module + + @cached_property + def module_name(self) -> str: + """Returns the full module name or ``__main__`.""" + return self.module.__name__ + + @cached_property + def module_file(self) -> str | None: + """Returns the module's file full path.""" + return self.module.__file__ + + @cached_property + def module_version(self) -> str | None: + """Returns the string found in the local ``__version__`` variable.""" + version = getattr(self.module, "__version__", None) + if version is not None and not isinstance(version, str): + msg = f"Module version {version!r} expected to be a string or None." + raise ValueError(msg) + return version + + @cached_property + def package_name(self) -> str | None: + """Returns the package name.""" + return self.module.__package__ + + @cached_property + def package_version(self) -> str | None: + """Returns the package version if installed. + + Will raise an error if the package is not installed, or if the package version + cannot be determined from the package metadata. + """ + if not self.package_name: + return None + + try: + version = metadata.version(self.package_name) + except metadata.PackageNotFoundError: + msg = ( + f"{self.package_name!r} is not installed. Try passing " + "'package_name' instead." + ) + raise RuntimeError(msg) from None + + if not version: + msg = ( + f"Could not determine the version for {self.package_name!r} " + "automatically." + ) + raise RuntimeError(msg) + + return version + + @cached_property + def exec_name(self) -> str: + """User-friendly name of the executed CLI. + + Returns the module name. But if the later is ``__main__``, returns the package + name. + + If not packaged, the CLI is assumed to be a simple standalone script, and the + returned name is the script's file name (including its extension). + """ + # The CLI has its own module. + if self.module_name != "__main__": + return self.module_name + + # The CLI module is a `__main__` entry-point, so returns its package name. + if self.package_name: + return self.package_name + + # The CLI is not packaged: it is a standalone script. Fallback to its + # filename. + if self.module_file: + return os.path.basename(self.module_file) + + msg = ( + "Could not determine the user-friendly name of the CLI from the frame " + "stack." + ) + raise RuntimeError(msg) + + @cached_property + def version(self) -> str | None: + """Return the version of the CLI. + + Returns the module version if a ``__version__`` variable is set alongside the + CLI in its module. + + Else returns the package version if the CLI is implemented in a package, using + `importlib.metadata.version() + <https://docs.python.org/3/library/importlib.metadata.html?highlight=metadata#distribution-versions>`_. + """ + if self.module_version: + return self.module_version + + if self.package_version: + return self.package_version + + return None + + @cached_property + def prog_name(self) -> str | None: + """Return the name of the CLI, from Click's point of view.""" + return get_current_context().find_root().info_name + + @cached_property + def env_info(self) -> dict[str, str]: + """Various environment info. + + Returns the data produced by `boltons.ecoutils.get_profile() + <https://boltons.readthedocs.io/en/latest/ecoutils.html#boltons.ecoutils.get_profile>`_. + """ + return cast("dict[str, str]", get_profile(scrub=True)) + +
[docs] def colored_template(self, template: str | None = None) -> str: + """Insert ANSI styles to a message template. + + Accepts a custom ``template`` as parameter, otherwise uses the default message + defined on the Option instance. + + This step is necessary because we need to linearize the template to apply the + ANSI codes on the string segments. This is a consequence of the nature of ANSI, + directives which cannot be encapsulated within another (unlike markup tags + like HTML). + """ + if template is None: + template = self.message + + # Normalize the default to a no-op Style() callable to simplify the code + # of the colorization step. + def noop(s: str) -> str: + return s + + default_style = self.message_style if self.message_style else noop + + # Associate each field with its own style. + field_styles = {} + for field_id in self.template_fields: + field_style = getattr(self, f"{field_id}_style") + # If no style is defined for this field, use the default style of the + # message. + if not field_style: + field_style = default_style + field_styles[field_id] = field_style + + # Split the template semantically between fields and literals. + segments = tokenize_format_str(template, resolve_pos=False) + + # A copy of the template, where literals and fields segments are colored. + colored_template = "" + + # Apply styles to field and literal segments. + literal_accu = "" + for i, segment in enumerate(segments): + # Is the segment a format field? + is_field = isinstance(segment, BaseFormatField) + # If not, keep accumulating literal strings until the next field. + if not is_field: + # Re-escape literal curly braces to avoid messing up the format. + literal_accu += segment.replace("{", "{{").replace("}", "}}") + + # Dump the accumulated literals before processing the field, or at the end + # of the template. + is_last_segment = i + 1 == len(segments) + if (is_field or is_last_segment) and literal_accu: + # Colorize literals with the default style. + colored_template += default_style(literal_accu) + # Reset the accumulator. + literal_accu = "" + + # Add the field to the template copy, colored with its own style. + if is_field: + colored_template += field_styles[segment.base_name](str(segment)) + + return colored_template
+ +
[docs] def render_message(self, template: str | None = None) -> str: + """Render the version string from the provided template. + + Accepts a custom ``template`` as parameter, otherwise uses the default + ``self.colored_template()`` produced by the instance. + """ + if template is None: + template = self.colored_template() + + return template.format(**{v: getattr(self, v) for v in self.template_fields})
+ +
[docs] def print_debug_message(self) -> None: + """Render in debug logs all template fields in color. + + .. todo:: + Pretty print JSON output (easier to read in bug reports)? + """ + logger = logging.getLogger("click_extra") + if logger.getEffectiveLevel() == logging.DEBUG: + all_fields = { + f"{{{{{field_id}}}}}": f"{{{field_id}}}" + for field_id in self.template_fields + } + max_len = max(map(len, all_fields)) + raw_format = "\n".join( + f"{k:<{max_len}}: {v}" for k, v in all_fields.items() + ) + msg = self.render_message(self.colored_template(raw_format)) + logger.debug("Version string template variables:") + for line in msg.splitlines(): + logger.debug(line)
+ +
[docs] def print_and_exit( + self, + ctx: Context, + param: Parameter, + value: bool, + ) -> None: + """Print the version string and exits. + + Also stores all version string elements in the Context's ``meta`` `dict`. + """ + # Populate the context's meta dict with the version string elements. + for var in self.template_fields: + ctx.meta[f"click_extra.{var}"] = getattr(self, var) + + # Always print debug messages, even if --version is not called. + self.print_debug_message() + + if not value or ctx.resilient_parsing: + # Do not print the version and continue normal CLI execution. + return + + echo(self.render_message(), color=ctx.color) + + # XXX Despite monkey-patching of click.Context.exit to force closing before + # exit, we still need to force it here for unknown reason. 🤷 + # See: https://github.com/pallets/click/pull/2680 + ctx.close() + ctx.exit()
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/_commands.html b/_modules/cloup/_commands.html new file mode 100644 index 000000000..ede8de9c8 --- /dev/null +++ b/_modules/cloup/_commands.html @@ -0,0 +1,1097 @@ + + + + + + + + cloup._commands - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup._commands

+"""
+This modules contains Cloup command classes and decorators.
+
+Note that Cloup commands *are* Click commands. Apart from supporting more
+features, Cloup command decorators have detailed type hints and are generics so
+that type checkers can precisely infer the type of the returned command based on
+the ``cls`` argument.
+
+Why did you overload all decorators?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+I wanted that the return type of decorators depended from the ``cls`` argument
+but MyPy doesn't allow to set a default value on a generic argument, see:
+https://github.com/python/mypy/issues/3737.
+So I had to resort to a workaround using @overload which makes things more
+verbose. The ``@overload`` is on the ``cls`` argument:
+
+- in one signature, ``cls`` has type ``None`` and it's set to ``None``; in this
+  case the type of the instantiated command is ``cloup.Command`` for ``@command``
+  and ``cloup.Group`` for ``@group``
+- in the other signature, there's ``cls: C`` without a default, where ``C`` is
+  a type variable; in this case the type of the instantiated command is ``C``.
+
+When and if the MyPy issue is resolved, the overloads will be removed.
+"""
+import inspect
+from typing import (
+    Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Sequence, Tuple,
+    Type, TypeVar, Union, cast, overload, MutableMapping, Mapping,
+)
+
+import click
+
+import cloup
+from ._context import Context
+from ._option_groups import OptionGroupMixin
+from ._sections import Section, SectionMixin
+from ._util import click_version_ge_8_1, first_bool, reindent
+from .constraints import ConstraintMixin
+from .styling import DEFAULT_THEME
+from .typing import AnyCallable
+
+# Generic types of ``cls`` args of ``@command`` and ``@group``
+C = TypeVar('C', bound=click.Command)
+G = TypeVar('G', bound=click.Group)
+
+
+
[docs]class Command(ConstraintMixin, OptionGroupMixin, click.Command): + """A ``click.Command`` supporting option groups and constraints. + + Refer to superclasses for the documentation of all accepted parameters: + + - :class:`ConstraintMixin` + - :class:`OptionGroupMixin` + - :class:`click.Command` + + Besides other things, this class also: + + * adds a ``formatter_settings`` instance attribute. + + Refer to :class:`click.Command` for the documentation of all parameters. + + .. versionadded:: 0.8.0 + """ + context_class: Type[Context] = Context + + def __init__( + self, *args: Any, + aliases: Optional[Iterable[str]] = None, + formatter_settings: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ): + super().__init__(*args, **kwargs) + #: HelpFormatter options that are merged with ``Context.formatter_settings`` + #: (eventually overriding some values). + self.aliases: List[str] = [] if aliases is None else list(aliases) + self.formatter_settings: Dict[str, Any] = ( + {} if formatter_settings is None else formatter_settings) + +
[docs] def get_normalized_epilog(self) -> str: + if self.epilog and click_version_ge_8_1: + return inspect.cleandoc(self.epilog) + return self.epilog or ""
+ + # Differently from Click, this doesn't indent the epilog. +
[docs] def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + if self.epilog: + assert isinstance(formatter, cloup.HelpFormatter) + epilog = self.get_normalized_epilog() + formatter.write_paragraph() + formatter.write_epilog(epilog)
+ +
[docs] def format_help_text( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + assert isinstance(formatter, cloup.HelpFormatter) + formatter.write_command_help_text(self)
+ +
[docs] def format_aliases(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + if not self.aliases: + return + assert isinstance(formatter, cloup.HelpFormatter) + formatter.write_aliases(self.aliases)
+ +
[docs] def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + self.format_usage(ctx, formatter) + self.format_aliases(ctx, formatter) + self.format_help_text(ctx, formatter) + self.format_params(ctx, formatter) + if self.must_show_constraints(ctx): + self.format_constraints(ctx, formatter) # type: ignore + if isinstance(self, click.MultiCommand): + self.format_commands(ctx, formatter) + self.format_epilog(ctx, formatter)
+ + +
[docs]class Group(SectionMixin, Command, click.Group): + """ + A ``click.Group`` that allows to organize its subcommands in multiple help + sections and whose subcommands are, by default, of type :class:`cloup.Command`. + + Refer to superclasses for the documentation of all accepted parameters: + + - :class:`SectionMixin` + - :class:`Command` + - :class:`click.Group` + + Apart from superclasses arguments, the following is the only additional parameter: + + ``show_subcommand_aliases``: ``Optional[bool] = None`` + whether to show subcommand aliases; aliases are shown by default and + can be disabled using this argument or the homonym context setting. + + .. versionchanged:: 0.14.0 + this class now supports option groups and constraints. + + .. versionadded:: 0.10.0 + the "command aliases" feature, including the ``show_subcommand_aliases`` + parameter/attribute. + + .. versionchanged:: 0.8.0 + this class now inherits from :class:`cloup.BaseCommand`. + """ + SHOW_SUBCOMMAND_ALIASES: bool = False + + def __init__( + self, *args: Any, + show_subcommand_aliases: Optional[bool] = None, + commands: Optional[ + Union[MutableMapping[str, click.Command], Sequence[click.Command]] + ] = None, + **kwargs: Any + ): + super().__init__(*args, **kwargs) + self.show_subcommand_aliases = show_subcommand_aliases + """Whether to show subcommand aliases.""" + + self.alias2name: Dict[str, str] = {} + """Dictionary mapping each alias to a command name.""" + + if commands: + self.add_multiple_commands(commands) + +
[docs] def add_multiple_commands( + self, commands: Union[Mapping[str, click.Command], Sequence[click.Command]] + ) -> None: + if isinstance(commands, Mapping): + for name, cmd in commands.items(): + self.add_command(cmd, name=name) + else: + for cmd in commands: + self.add_command(cmd)
+ +
[docs] def add_command( + self, cmd: click.Command, + name: Optional[str] = None, + section: Optional[Section] = None, + fallback_to_default_section: bool = True, + ) -> None: + super().add_command(cmd, name, section, fallback_to_default_section) + name = cast(str, cmd.name) if name is None else name + aliases = getattr(cmd, 'aliases', []) + for alias in aliases: + self.alias2name[alias] = name
+ +
[docs] def resolve_command_name(self, ctx: click.Context, name: str) -> Optional[str]: + """Map a string supposed to be a command name or an alias to a normalized + command name. If no match is found, it returns ``None``.""" + if ctx.token_normalize_func: + name = ctx.token_normalize_func(name) + if name in self.commands: + return name + return self.alias2name.get(name)
+ +
[docs] def resolve_command( + self, ctx: click.Context, args: List[str] + ) -> Tuple[Optional[str], Optional[click.Command], List[str]]: + normalized_name = self.resolve_command_name(ctx, args[0]) + if normalized_name: + # Replacing this string ensures that super().resolve_command() returns a + # normalized command name rather than an alias. The technique described in + # Click's docs doesn't work if the subcommand is added using Group.group + # passing the "name" argument. + args[0] = normalized_name + try: + return super().resolve_command(ctx, args) + except click.UsageError as error: + new_error = self.handle_bad_command_name( + bad_name=args[0], + valid_names=[*self.commands, *self.alias2name], + error=error + ) + raise new_error
+ +
[docs] def handle_bad_command_name( + self, bad_name: str, valid_names: List[str], error: click.UsageError + ) -> click.UsageError: + """This method is called when a command name cannot be resolved. + Useful to implement the "Did you mean <x>?" feature. + + :param bad_name: the command name that could not be resolved. + :param valid_names: the list of valid command names, including aliases. + :param error: the original error coming from Click. + :return: the original error or a new one. + """ + import difflib + matches = difflib.get_close_matches(bad_name, valid_names) + if not matches: + return error + elif len(matches) == 1: + extra_msg = f"Did you mean '{matches[0]}'?" + else: + matches_list = "\n".join(" " + match for match in matches) + extra_msg = 'Did you mean one of these?\n' + matches_list + + error_msg = str(error) + " " + extra_msg + return click.exceptions.UsageError(error_msg, error.ctx)
+ +
[docs] def must_show_subcommand_aliases(self, ctx: click.Context) -> bool: + return first_bool( + self.show_subcommand_aliases, + getattr(ctx, 'show_subcommand_aliases', None), + Group.SHOW_SUBCOMMAND_ALIASES, + )
+ +
[docs] def format_subcommand_name( + self, ctx: click.Context, name: str, cmd: click.Command + ) -> str: + aliases = getattr(cmd, 'aliases', None) + if aliases and self.must_show_subcommand_aliases(ctx): + assert isinstance(ctx, cloup.Context) + theme = cast( + cloup.HelpTheme, ctx.formatter_settings.get("theme", DEFAULT_THEME) + ) + alias_list = self.format_subcommand_aliases(aliases, theme) + return f"{name} {alias_list}" + return name
+ +
[docs] @staticmethod + def format_subcommand_aliases(aliases: Sequence[str], theme: cloup.HelpTheme) -> str: + secondary_style = theme.alias_secondary + if secondary_style is None or secondary_style == theme.alias: + return theme.alias(f"({', '.join(aliases)})") + else: + return ( + secondary_style("(") + + secondary_style(", ").join(theme.alias(alias) for alias in aliases) + + secondary_style(")") + )
+ + # MyPy complains because "Signature of "group" incompatible with supertype". + # The supertype signature is (*args, **kwargs), which is compatible with + # this provided that you pass all arguments (expect "name") as keyword arg. + @overload # type: ignore + def command( # Why overloading? Refer to module docstring. + self, name: Optional[str] = None, + *, + aliases: Optional[Iterable[str]] = None, + cls: None = None, # default to Group.command_class or cloup.Command + section: Optional[Section] = None, + context_settings: Optional[Dict[str, Any]] = None, + formatter_settings: Optional[Dict[str, Any]] = None, + help: Optional[str] = None, + epilog: Optional[str] = None, + short_help: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + align_option_groups: Optional[bool] = None, + show_constraints: Optional[bool] = None, + params: Optional[List[click.Parameter]] = None, + ) -> Callable[[AnyCallable], click.Command]: + ... + + @overload + def command( # Why overloading? Refer to module docstring. + self, name: Optional[str] = None, + *, + aliases: Optional[Iterable[str]] = None, + cls: Type[C], + section: Optional[Section] = None, + context_settings: Optional[Dict[str, Any]] = None, + help: Optional[str] = None, + epilog: Optional[str] = None, + short_help: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + params: Optional[List[click.Parameter]] = None, + **kwargs: Any, + ) -> Callable[[AnyCallable], C]: + ... + +
[docs] def command( + self, name: Optional[str] = None, *, + aliases: Optional[Iterable[str]] = None, + cls: Optional[Type[C]] = None, + section: Optional[Section] = None, + **kwargs: Any + ) -> Callable[[AnyCallable], Union[click.Command, C]]: + """Return a decorator that creates a new subcommand of this ``Group`` + using the decorated function as callback. + + It takes the same arguments of :func:`command` plus: + + ``section``: ``Optional[Section]`` + if provided, put the subcommand in this section. + + .. versionchanged:: 0.10.0 + all arguments but ``name`` are now keyword-only. + """ + make_command = command( + name=name, cls=(self.command_class if cls is None else cls), + aliases=aliases, **kwargs + ) + + def decorator(f: AnyCallable) -> click.Command: + cmd = make_command(f) + self.add_command(cmd, section=section) + return cmd + + return decorator
+ + # MyPy complains because "Signature of "group" incompatible with supertype". + # The supertype signature is (*args, **kwargs), which is compatible with + # this provided that you pass all arguments (expect "name") as keyword arg. + @overload # type: ignore + def group( # Why overloading? Refer to module docstring. + self, name: Optional[str] = None, + *, + aliases: Optional[Iterable[str]] = None, + cls: None = None, # cls not provided + section: Optional[Section] = None, + sections: Iterable[Section] = (), + align_sections: Optional[bool] = None, + invoke_without_command: bool = False, + no_args_is_help: bool = False, + context_settings: Optional[Dict[str, Any]] = None, + formatter_settings: Dict[str, Any] = {}, + help: Optional[str] = None, + epilog: Optional[str] = None, + short_help: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + subcommand_metavar: Optional[str] = None, + add_help_option: bool = True, + chain: bool = False, + hidden: bool = False, + deprecated: bool = False, + ) -> Callable[[AnyCallable], click.Group]: + ... + + @overload + def group( # Why overloading? Refer to module docstring. + self, name: Optional[str] = None, *, + aliases: Optional[Iterable[str]] = None, + cls: Optional[Type[G]] = None, + section: Optional[Section] = None, + invoke_without_command: bool = False, + no_args_is_help: bool = False, + context_settings: Optional[Dict[str, Any]] = None, + help: Optional[str] = None, + epilog: Optional[str] = None, + short_help: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + subcommand_metavar: Optional[str] = None, + add_help_option: bool = True, + chain: bool = False, + hidden: bool = False, + deprecated: bool = False, + params: Optional[List[click.Parameter]] = None, + **kwargs: Any + ) -> Callable[[AnyCallable], G]: + ... + +
[docs] def group( # type: ignore + self, name: Optional[None] = None, + *, + cls: Optional[Type[G]] = None, + aliases: Optional[Iterable[str]] = None, + section: Optional[Section] = None, + **kwargs: Any + ) -> Callable[[AnyCallable], Union[click.Group, G]]: + """Return a decorator that creates a new subcommand of this ``Group`` + using the decorated function as callback. + + It takes the same argument of :func:`group` plus: + + ``section``: ``Optional[Section]`` + if provided, put the subcommand in this section. + + .. versionchanged:: 0.10.0 + all arguments but ``name`` are now keyword-only. + """ + make_group = group( + name=name, cls=cls or self._default_group_class(), aliases=aliases, **kwargs + ) + + def decorator(f: AnyCallable) -> Union[click.Group, G]: + cmd = make_group(f) + self.add_command(cmd, section=section) + return cmd + + return decorator
+ + @classmethod + def _default_group_class(cls) -> Optional[Type[click.Group]]: + if cls.group_class is None: + return None + if cls.group_class is type: + return cls + else: + return cast(Type[click.Group], cls.group_class)
+ + +# Why overloading? Refer to module docstring. +@overload # In this overload: "cls: None = None" +def command( + name: Optional[str] = None, + *, + aliases: Optional[Iterable[str]] = None, + cls: None = None, + context_settings: Optional[Dict[str, Any]] = None, + formatter_settings: Optional[Dict[str, Any]] = None, + help: Optional[str] = None, + short_help: Optional[str] = None, + epilog: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + align_option_groups: Optional[bool] = None, + show_constraints: Optional[bool] = None, + params: Optional[List[click.Parameter]] = None, +) -> Callable[[AnyCallable], Command]: + ... + + +@overload +def command( # In this overload: "cls: ClickCommand" + name: Optional[str] = None, + *, + aliases: Optional[Iterable[str]] = None, + cls: Type[C], + context_settings: Optional[Dict[str, Any]] = None, + help: Optional[str] = None, + short_help: Optional[str] = None, + epilog: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + params: Optional[List[click.Parameter]] = None, + **kwargs: Any +) -> Callable[[AnyCallable], C]: + ... + + +# noinspection PyIncorrectDocstring +def command( + name: Optional[str] = None, *, + aliases: Optional[Iterable[str]] = None, + cls: Optional[Type[C]] = None, + **kwargs: Any +) -> Callable[[AnyCallable], Union[Command, C]]: + """ + Return a decorator that creates a new command using the decorated function + as callback. + + The only differences with respect to ``click.command`` are: + + - the default command class is :class:`cloup.Command` + - supports constraints, provided that ``cls`` inherits from ``ConstraintMixin`` + like ``cloup.Command`` (the default) + - this function has detailed type hints and uses generics for the ``cls`` + argument and return type. + + Note that the following arguments are about Cloup-specific features and are + not supported by all ``click.Command``, so if you provide a custom ``cls`` + make sure you don't set these: + + - ``formatter_settings`` + - ``align_option_groups`` (``cls`` needs to inherit from ``OptionGroupMixin``) + - ``show_constraints`` (``cls`` needs to inherit ``ConstraintMixin``). + + .. versionchanged:: 0.10.0 + this function is now generic: the return type depends on what you provide + as ``cls`` argument. + + .. versionchanged:: 0.9.0 + all arguments but ``name`` are now keyword-only arguments. + + :param name: + the name of the command to use unless a group overrides it. + :param aliases: + alternative names for this command. If ``cls`` is not a Cloup command class, + aliases will be stored in the instantiated command by monkey-patching + and aliases won't be documented in the help page of the command. + :param cls: + the command class to instantiate. + :param context_settings: + an optional dictionary with defaults that are passed to the context object. + :param formatter_settings: + arguments for the formatter; you can use :meth:`HelpFormatter.settings` + to build this dictionary. + :param help: + the help string to use for this command. + :param epilog: + like the help string but it's printed at the end of the help page after + everything else. + :param short_help: + the short help to use for this command. This is shown on the command + listing of the parent command. + :param options_metavar: + metavar for options shown in the command's usage string. + :param add_help_option: + by default each command registers a ``--help`` option. + This can be disabled by this parameter. + :param no_args_is_help: + this controls what happens if no arguments are provided. This option is + disabled by default. If enabled this will add ``--help`` as argument if + no arguments are passed + :param hidden: + hide this command from help outputs. + :param deprecated: + issues a message indicating that the command is deprecated. + :param align_option_groups: + whether to align the columns of all option groups' help sections. + This is also available as a context setting having a lower priority + than this attribute. Given that this setting should be consistent + across all you commands, you should probably use the context + setting only. + :param show_constraints: + whether to include a "Constraint" section in the command help. This + is also available as a context setting having a lower priority than + this attribute. + :param params: + **(click >= 8.1.0)** a list of parameters (:class:`Argument` and + :class:`Option` instances). Params added with ``@option`` and ``@argument`` + are appended to the end of the list if given. + :param kwargs: + any other argument accepted by the instantiated command class (``cls``). + """ + if callable(name): + raise Exception( + f"you forgot parenthesis in the command decorator for `{name.__name__}`. " + f"While parenthesis are optional in Click >= 8.1, they are required in Cloup." + ) + + def decorator(f: AnyCallable) -> C: + if hasattr(f, '__cloup_constraints__'): + if cls and not issubclass(cls, ConstraintMixin): + raise TypeError( + f"a `Command` must inherit from `cloup.ConstraintMixin` to support " + f"constraints; `{cls}` doesn't") + constraints = tuple(reversed(f.__cloup_constraints__)) + del f.__cloup_constraints__ + kwargs['constraints'] = constraints + + cmd_cls = cls if cls is not None else Command + try: + cmd = cast(C, click.command(name, cls=cmd_cls, **kwargs)(f)) + if aliases: + cmd.aliases = list(aliases) # type: ignore + return cmd + except TypeError as error: + raise _process_unexpected_kwarg_error(error, _ARGS_INFO, cmd_cls) + + return decorator + + +@overload # Why overloading? Refer to module docstring. +def group( + name: Optional[str] = None, + *, + cls: None = None, + aliases: Optional[Iterable[str]] = None, + sections: Iterable[Section] = (), + align_sections: Optional[bool] = None, + invoke_without_command: bool = False, + no_args_is_help: bool = False, + context_settings: Optional[Dict[str, Any]] = None, + formatter_settings: Dict[str, Any] = {}, + help: Optional[str] = None, + short_help: Optional[str] = None, + epilog: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + subcommand_metavar: Optional[str] = None, + add_help_option: bool = True, + chain: bool = False, + hidden: bool = False, + deprecated: bool = False, + params: Optional[List[click.Parameter]] = None, +) -> Callable[[AnyCallable], Group]: + ... + + +@overload +def group( + name: Optional[str] = None, + *, + cls: Type[G], + aliases: Optional[Iterable[str]] = None, + invoke_without_command: bool = False, + no_args_is_help: bool = False, + context_settings: Optional[Dict[str, Any]] = None, + help: Optional[str] = None, + short_help: Optional[str] = None, + epilog: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + subcommand_metavar: Optional[str] = None, + add_help_option: bool = True, + chain: bool = False, + hidden: bool = False, + deprecated: bool = False, + params: Optional[List[click.Parameter]] = None, + **kwargs: Any +) -> Callable[[AnyCallable], G]: + ... + + +def group( + name: Optional[str] = None, *, cls: Optional[Type[G]] = None, **kwargs: Any +) -> Callable[[AnyCallable], click.Group]: + """ + Return a decorator that instantiates a ``Group`` (or a subclass of it) + using the decorated function as callback. + + .. versionchanged:: 0.10.0 + the ``cls`` argument can now be any ``click.Group`` (previously had to + be a ``cloup.Group``) and the type of the instantiated command matches + it (previously, the type was ``cloup.Group`` even if ``cls`` was a subclass + of it). + + .. versionchanged:: 0.9.0 + all arguments but ``name`` are now keyword-only arguments. + + :param name: + the name of the command to use unless a group overrides it. + :param cls: + the ``click.Group`` (sub)class to instantiate. This is ``cloup.Group`` + by default. Note that some of the arguments are only supported by + ``cloup.Group``. + :param sections: + a list of Section objects containing the subcommands of this ``Group``. + This argument is only supported by commands inheriting from + :class:`cloup.SectionMixin`. + :param align_sections: + whether to align the columns of all subcommands' help sections. + This is also available as a context setting having a lower priority + than this attribute. Given that this setting should be consistent + across all you commands, you should probably use the context + setting only. + :param context_settings: + an optional dictionary with defaults that are passed to the context object. + :param formatter_settings: + arguments for the formatter; you can use :meth:`HelpFormatter.settings` + to build this dictionary. + :param help: + the help string to use for this command. + :param short_help: + the short help to use for this command. This is shown on the command + listing of the parent command. + :param epilog: + like the help string but it's printed at the end of the help page after + everything else. + :param options_metavar: + metavar for options shown in the command's usage string. + :param add_help_option: + by default each command registers a ``--help`` option. + This can be disabled by this parameter. + :param hidden: + hide this command from help outputs. + :param deprecated: + issues a message indicating that the command is deprecated. + :param invoke_without_command: + this controls how the multi command itself is invoked. By default it's + only invoked if a subcommand is provided. + :param no_args_is_help: + this controls what happens if no arguments are provided. This option is + enabled by default if `invoke_without_command` is disabled or disabled + if it's enabled. If enabled this will add ``--help`` as argument if no + arguments are passed. + :param subcommand_metavar: + string used in the command's usage string to indicate the subcommand place. + :param chain: + if this is set to `True`, chaining of multiple subcommands is enabled. + This restricts the form of commands in that they cannot have optional + arguments but it allows multiple commands to be chained together. + :param params: + **(click >= 8.1.0)** a list of parameters (:class:`Argument` and + :class:`Option` instances). Params added with ``@option`` and ``@argument`` + are appended to the end of the list if given. + :param kwargs: + any other argument accepted by the instantiated command class. + """ + if cls is None: + return command(name=name, cls=Group, **kwargs) + elif issubclass(cls, click.Group): + return command(name=name, cls=cls, **kwargs) + else: + raise TypeError( + 'this decorator requires `cls` to be a `click.Group` (or a subclass)') + + +# Side stuff for better error messages + +class _ArgInfo(NamedTuple): + arg_name: str + requires: Type[Any] + supported_by: str = "" + + +_ARGS_INFO = { + info.arg_name: info for info in [ + _ArgInfo('formatter_settings', Command, "both `Command` and `Group`"), + _ArgInfo('align_option_groups', OptionGroupMixin, "both `Command` and `Group`"), + _ArgInfo('show_constraints', ConstraintMixin, "both `Command` and `Group`"), + _ArgInfo('align_sections', SectionMixin, "`Group`") + ] +} + + +def _process_unexpected_kwarg_error( + error: TypeError, args_info: Dict[str, _ArgInfo], cls: Type[Command] +) -> TypeError: + """Check if the developer tried to pass a Cloup-specific argument to a ``cls`` + that doesn't support it and if that's the case, augments the error message + to provide useful more info about the error.""" + import re + + message = str(error) + match = re.search('|'.join(arg_name for arg_name in args_info), message) + if match is None: + return error + arg = match.group() + info = args_info[arg] + extra_info = reindent(f"""\n + Hint: you set `cls={cls}` but this class doesn't support the argument `{arg}`. + In Cloup, this argument is supported by `{info.supported_by}` + via `{info.requires.__name__}`. + """, 4) + new_message = message + '\n' + extra_info + return TypeError(new_message) +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/_context.html b/_modules/cloup/_context.html new file mode 100644 index 000000000..e2e50bd30 --- /dev/null +++ b/_modules/cloup/_context.html @@ -0,0 +1,593 @@ + + + + + + + + cloup._context - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup._context

+from __future__ import annotations
+
+import warnings
+from functools import update_wrapper
+from typing import (
+    Any, Callable, cast, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING, overload,
+)
+
+import click
+
+import cloup
+from cloup._util import coalesce, pick_non_missing
+from cloup.formatting import HelpFormatter
+from cloup.typing import MISSING, Possibly
+
+if TYPE_CHECKING:
+    import typing_extensions as te
+
+    P = te.ParamSpec("P")
+
+R = TypeVar("R")
+
+
+@overload
+def get_current_context() -> "Context":
+    ...
+
+
+@overload
+def get_current_context(silent: bool = False) -> "Optional[Context]":
+    ...
+
+
+
[docs]def get_current_context(silent: bool = False) -> "Optional[Context]": + """Equivalent to :func:`click.get_current_context` but casts the returned + :class:`click.Context` object to :class:`cloup.Context` (which is safe when using + cloup commands classes and decorators).""" + return cast(Optional[Context], click.get_current_context(silent=silent))
+ + +
[docs]def pass_context(f: "Callable[te.Concatenate[Context, P], R]") -> "Callable[P, R]": + """Marks a callback as wanting to receive the current context object as first + argument. Equivalent to :func:`click.pass_context` but assumes the current context + is of type :class:`cloup.Context`.""" + + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> R: + return f(get_current_context(), *args, **kwargs) + + return update_wrapper(new_func, f)
+ + +def _warn_if_formatter_settings_conflict( + ctx_key: str, + formatter_key: str, + ctx_kwargs: Dict[str, Any], + formatter_settings: Dict[str, Any], +) -> None: + if ctx_kwargs.get(ctx_key) and formatter_settings.get(formatter_key): + from textwrap import dedent + formatter_arg = f'formatter_settings.{formatter_key}' + warnings.warn(dedent(f""" + You provided both {ctx_key} and {formatter_arg} as arguments of a Context. + Unless you have a particular reason, you should set only one of them.. + If you use both, {formatter_arg} will be used by the formatter. + You can suppress this warning by setting: + + cloup.warnings.formatter_settings_conflict = False + """)) + + +
[docs]class Context(click.Context): + """A custom context for Cloup. + + Look up :class:`click.Context` for the list of all arguments. + + .. versionadded:: 0.9.0 + added the ``check_constraints_consistency`` parameter. + + .. versionadded:: 0.8.0 + + :param ctx_args: + arguments forwarded to :class:`click.Context`. + :param align_option_groups: + if True, align the definition lists of all option groups of a command. + You can override this by setting the corresponding argument of ``Command`` + (but you probably shouldn't: be consistent). + :param align_sections: + if True, align the definition lists of all subcommands of a group. + You can override this by setting the corresponding argument of ``Group`` + (but you probably shouldn't: be consistent). + :param show_subcommand_aliases: + whether to show the aliases of subcommands in the help of a ``cloup.Group``. + :param show_constraints: + whether to include a "Constraint" section in the command help (if at + least one constraint is defined). + :param check_constraints_consistency: + enable additional checks for constraints which detects mistakes of the + developer (see :meth:`cloup.Constraint.check_consistency`). + :param formatter_settings: + keyword arguments forwarded to :class:`HelpFormatter` in ``make_formatter``. + This args are merged with those of the (eventual) parent context and then + merged again (being overridden) by those of the command. + **Tip**: use the static method :meth:`HelpFormatter.settings` to create this + dictionary, so that you can be guided by your IDE. + :param ctx_kwargs: + keyword arguments forwarded to :class:`click.Context`. + """ + formatter_class: Type[HelpFormatter] = HelpFormatter + + def __init__( + self, *ctx_args: Any, + align_option_groups: Optional[bool] = None, + align_sections: Optional[bool] = None, + show_subcommand_aliases: Optional[bool] = None, + show_constraints: Optional[bool] = None, + check_constraints_consistency: Optional[bool] = None, + formatter_settings: Dict[str, Any] = {}, + **ctx_kwargs: Any, + ): + super().__init__(*ctx_args, **ctx_kwargs) + + self.align_option_groups = coalesce( + align_option_groups, + getattr(self.parent, 'align_option_groups', None), + ) + self.align_sections = coalesce( + align_sections, + getattr(self.parent, 'align_sections', None), + ) + self.show_subcommand_aliases = coalesce( + show_subcommand_aliases, + getattr(self.parent, 'show_subcommand_aliases', None), + ) + self.show_constraints = coalesce( + show_constraints, + getattr(self.parent, 'show_constraints', None), + ) + self.check_constraints_consistency = coalesce( + check_constraints_consistency, + getattr(self.parent, 'check_constraints_consistency', None) + ) + + if cloup.warnings.formatter_settings_conflict: + _warn_if_formatter_settings_conflict( + 'terminal_width', 'width', ctx_kwargs, formatter_settings) + _warn_if_formatter_settings_conflict( + 'max_content_width', 'max_width', ctx_kwargs, formatter_settings) + + #: Keyword arguments for the HelpFormatter. Obtained by merging the options + #: of the parent context with the one passed to this context. Before creating + #: the help formatter, these options are merged with the (eventual) options + #: provided to the command (having higher priority). + self.formatter_settings = { + **getattr(self.parent, 'formatter_settings', {}), + **formatter_settings, + } + +
[docs] def get_formatter_settings(self) -> Dict[str, Any]: + return { + 'width': self.terminal_width, + 'max_width': self.max_content_width, + **self.formatter_settings, + **getattr(self.command, 'formatter_settings', {}) + }
+ +
[docs] def make_formatter(self) -> HelpFormatter: + opts = self.get_formatter_settings() + return self.formatter_class(**opts)
+ +
[docs] @staticmethod + def settings( + *, auto_envvar_prefix: Possibly[str] = MISSING, + default_map: Possibly[Dict[str, Any]] = MISSING, + terminal_width: Possibly[int] = MISSING, + max_content_width: Possibly[int] = MISSING, + resilient_parsing: Possibly[bool] = MISSING, + allow_extra_args: Possibly[bool] = MISSING, + allow_interspersed_args: Possibly[bool] = MISSING, + ignore_unknown_options: Possibly[bool] = MISSING, + help_option_names: Possibly[List[str]] = MISSING, + token_normalize_func: Possibly[Callable[[str], str]] = MISSING, + color: Possibly[bool] = MISSING, + show_default: Possibly[bool] = MISSING, + align_option_groups: Possibly[bool] = MISSING, + align_sections: Possibly[bool] = MISSING, + show_subcommand_aliases: Possibly[bool] = MISSING, + show_constraints: Possibly[bool] = MISSING, + check_constraints_consistency: Possibly[bool] = MISSING, + formatter_settings: Possibly[Dict[str, Any]] = MISSING, + ) -> Dict[str, Any]: + """Utility method for creating a ``context_settings`` dictionary. + + :param auto_envvar_prefix: + the prefix to use for automatic environment variables. If this is + `None` then reading from environment variables is disabled. This + does not affect manually set environment variables which are always + read. + :param default_map: + a dictionary (like object) with default values for parameters. + :param terminal_width: + the width of the terminal. The default is inherited from parent + context. If no context defines the terminal width then auto-detection + will be applied. + :param max_content_width: + the maximum width for content rendered by Click (this currently + only affects help pages). This defaults to 80 characters if not + overridden. In other words: even if the terminal is larger than + that, Click will not format things wider than 80 characters by + default. In addition to that, formatters might add some safety + mapping on the right. + :param resilient_parsing: + if this flag is enabled then Click will parse without any + interactivity or callback invocation. Default values will also be + ignored. This is useful for implementing things such as completion + support. + :param allow_extra_args: + if this is set to `True` then extra arguments at the end will not + raise an error and will be kept on the context. The default is to + inherit from the command. + :param allow_interspersed_args: + if this is set to `False` then options and arguments cannot be + mixed. The default is to inherit from the command. + :param ignore_unknown_options: + instructs click to ignore options it does not know and keeps them + for later processing. + :param help_option_names: + optionally a list of strings that define how the default help + parameter is named. The default is ``['--help']``. + :param token_normalize_func: + an optional function that is used to normalize tokens (options, + choices, etc.). This for instance can be used to implement + case-insensitive behavior. + :param color: + controls if the terminal supports ANSI colors or not. The default + is auto-detection. This is only needed if ANSI codes are used in + texts that Click prints which is by default not the case. This for + instance would affect help output. + :param show_default: Show defaults for all options. If not set, + defaults to the value from a parent context. Overrides an + option's ``show_default`` argument. + :param align_option_groups: + if True, align the definition lists of all option groups of a command. + You can override this by setting the corresponding argument of ``Command`` + (but you probably shouldn't: be consistent). + :param align_sections: + if True, align the definition lists of all subcommands of a group. + You can override this by setting the corresponding argument of ``Group`` + (but you probably shouldn't: be consistent). + :param show_subcommand_aliases: + whether to show the aliases of subcommands in the help of a ``cloup.Group``. + :param show_constraints: + whether to include a "Constraint" section in the command help (if at + least one constraint is defined). + :param check_constraints_consistency: + enable additional checks for constraints which detects mistakes of the + developer (see :meth:`cloup.Constraint.check_consistency`). + :param formatter_settings: + keyword arguments forwarded to :class:`HelpFormatter` in ``make_formatter``. + This args are merged with those of the (eventual) parent context and then + merged again (being overridden) by those of the command. + **Tip**: use the static method :meth:`HelpFormatter.settings` to create this + dictionary, so that you can be guided by your IDE. + """ + return pick_non_missing(locals())
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/_option_groups.html b/_modules/cloup/_option_groups.html new file mode 100644 index 000000000..7e785a0ed --- /dev/null +++ b/_modules/cloup/_option_groups.html @@ -0,0 +1,717 @@ + + + + + + + + cloup._option_groups - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup._option_groups

+"""
+Implements the "option groups" feature.
+"""
+from collections import defaultdict
+from typing import (
+    Any, Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, overload,
+)
+
+import click
+from click import Option, Parameter
+
+import cloup
+from cloup._params import option
+from cloup._util import first_bool, make_repr
+from cloup.constraints import Constraint
+from cloup.formatting import HelpSection, ensure_is_cloup_formatter
+from cloup.typing import Decorator, F
+
+
+
[docs]class OptionGroup: + def __init__(self, title: str, + help: Optional[str] = None, + constraint: Optional[Constraint] = None, + hidden: bool = False): + """Contains the information of an option group and identifies it. + Note that, as far as the clients of this library are concerned, an + ``OptionGroups`` acts as a "marker" for options, not as a container for + related options. When you call ``@optgroup.option(...)`` you are not + adding an option to a container, you are just adding an option marked + with this option group. + + .. versionadded:: 0.8.0 + The ``hidden`` parameter. + """ + if not title: + raise ValueError('name is a mandatory argument') # pragma: no cover + self.title = title + self.help = help + self._options: Sequence[click.Option] = [] + self.constraint = constraint + self.hidden = hidden + + @property + def options(self) -> Sequence[click.Option]: + return self._options + + @options.setter + def options(self, options: Iterable[click.Option]) -> None: + self._options = opts = tuple(options) + if self.hidden: + for opt in opts: + opt.hidden = True + elif all(opt.hidden for opt in opts): + self.hidden = True + +
[docs] def get_help_records(self, ctx: click.Context) -> List[Tuple[str, str]]: + if self.hidden: + return [] + return [ + opt.get_help_record(ctx) for opt in self if not opt.hidden # type: ignore + ] # get_help_record() should return None only if opt.hidden
+ +
[docs] def option(self, *param_decls: str, **attrs: Any) -> Callable[[F], F]: + """Refer to :func:`cloup.option`.""" + return option(*param_decls, group=self, **attrs)
+ + def __iter__(self) -> Iterator[click.Option]: + return iter(self.options) + + def __getitem__(self, i: int) -> click.Option: + return self.options[i] + + def __len__(self) -> int: + return len(self.options) + + def __repr__(self) -> str: + return make_repr(self, self.title, help=self.help, options=self.options) + + def __str__(self) -> str: + return make_repr( + self, self.title, options=[opt.name for opt in self.options])
+ + +def has_option_group(param: click.Parameter) -> bool: + return getattr(param, 'group', None) is not None + + +def get_option_group_of(param: click.Option) -> Optional[OptionGroup]: + return getattr(param, 'group', None) + + +# noinspection PyMethodMayBeStatic +
[docs]class OptionGroupMixin: + """Implements support for: + + - option groups + - the "Positional arguments" help section; this section is shown only if + at least one of your arguments has non-empty ``help``. + + .. important:: + In order to check the constraints defined on the option groups, + a command must inherits from :class:`cloup.ConstraintMixin` too! + + .. versionadded:: 0.14.0 + added the "Positional arguments" help section. + + .. versionchanged:: 0.8.0 + this mixin now relies on ``cloup.HelpFormatter`` to align help sections. + If a ``click.HelpFormatter`` is used with a ``TypeError`` is raised. + + .. versionchanged:: 0.8.0 + removed ``format_option_group``. Added ``get_default_option_group`` and + ``make_option_group_help_section``. + + .. versionadded:: 0.5.0 + """ + + def __init__( + self, *args: Any, align_option_groups: Optional[bool] = None, **kwargs: Any + ) -> None: + """ + :param align_option_groups: + whether to align the columns of all option groups' help sections. + This is also available as a context setting having a lower priority + than this attribute. Given that this setting should be consistent + across all you commands, you should probably use the context + setting only. + :param args: + positional arguments forwarded to the next class in the MRO + :param kwargs: + keyword arguments forwarded to the next class in the MRO + """ + super().__init__(*args, **kwargs) + + self.align_option_groups = align_option_groups + params = kwargs.get('params') or [] + arguments, option_groups, ungrouped_options = self._group_params(params) + + self.arguments = arguments + + self.option_groups = option_groups + """List of all option groups, except the "default option group".""" + + self.ungrouped_options = ungrouped_options + """List of options not explicitly assigned to an user-defined option group. + These options will be included in the "default option group". + **Note:** this list does not include options added automatically by Click + based on context settings, like the ``--help`` option; use the + :meth:`get_ungrouped_options` method if you need the real full list + (which needs a ``Context`` object).""" + + @staticmethod + def _group_params( + params: List[Parameter] + ) -> Tuple[List[click.Argument], List[OptionGroup], List[Option]]: + + options_by_group: Dict[OptionGroup, List[click.Option]] = defaultdict(list) + arguments: List[click.Argument] = [] + ungrouped_options: List[click.Option] = [] + for param in params: + if isinstance(param, click.Argument): + arguments.append(param) + elif isinstance(param, click.Option): + grp = get_option_group_of(param) + if grp is None: + ungrouped_options.append(param) + else: + options_by_group[grp].append(param) + + option_groups = list(options_by_group.keys()) + for group, options in options_by_group.items(): + group.options = options + + return arguments, option_groups, ungrouped_options + +
[docs] def get_ungrouped_options(self, ctx: click.Context) -> Sequence[click.Option]: + """Return options not explicitly assigned to an option group + (eventually including the ``--help`` option), i.e. options that will be + part of the "default option group".""" + help_option = ctx.command.get_help_option(ctx) + if help_option is not None: + return self.ungrouped_options + [help_option] + else: + return self.ungrouped_options
+ +
[docs] def get_argument_help_record( + self, arg: click.Argument, ctx: click.Context + ) -> Tuple[str, str]: + if isinstance(arg, cloup.Argument): + return arg.get_help_record(ctx) + return arg.make_metavar(), ""
+ +
[docs] def get_arguments_help_section(self, ctx: click.Context) -> Optional[HelpSection]: + args_with_help = (arg for arg in self.arguments if getattr(arg, "help", None)) + if not any(args_with_help): + return None + return HelpSection( + heading="Positional arguments", + definitions=[ + self.get_argument_help_record(arg, ctx) for arg in self.arguments + ], + )
+ +
[docs] def make_option_group_help_section( + self, group: OptionGroup, ctx: click.Context + ) -> HelpSection: + """Return a ``HelpSection`` for an ``OptionGroup``, i.e. an object containing + the title, the optional description and the options' definitions for + this option group. + + .. versionadded:: 0.8.0 + """ + return HelpSection( + heading=group.title, + definitions=group.get_help_records(ctx), + help=group.help, + constraint=group.constraint.help(ctx) if group.constraint else None + )
+ +
[docs] def must_align_option_groups( + self, ctx: Optional[click.Context], default: bool = True + ) -> bool: + """ + Return ``True`` if the help sections of all options groups should have + their columns aligned. + + .. versionadded:: 0.8.0 + """ + return first_bool( + self.align_option_groups, + getattr(ctx, 'align_option_groups', None), + default, + )
+ +
[docs] def get_default_option_group( + self, ctx: click.Context, is_the_only_visible_option_group: bool = False + ) -> OptionGroup: + """ + Return an ``OptionGroup`` instance for the options not explicitly + assigned to an option group, eventually including the ``--help`` option. + + .. versionadded:: 0.8.0 + """ + default_group = OptionGroup( + "Options" if is_the_only_visible_option_group else "Other options") + default_group.options = self.get_ungrouped_options(ctx) + return default_group
+ +
[docs] def format_params( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + formatter = ensure_is_cloup_formatter(formatter) + + visible_sections = [] + + # Positional arguments + positional_arguments_section = self.get_arguments_help_section(ctx) + if positional_arguments_section: + visible_sections.append(positional_arguments_section) + + # Option groups + option_group_sections = [ + self.make_option_group_help_section(group, ctx) + for group in self.option_groups + if not group.hidden + ] + default_group = self.get_default_option_group( + ctx, is_the_only_visible_option_group=not option_group_sections + ) + if not default_group.hidden: + option_group_sections.append( + self.make_option_group_help_section(default_group, ctx)) + + visible_sections += option_group_sections + + formatter.write_many_sections( + visible_sections, + aligned=self.must_align_option_groups(ctx), + )
+ + +@overload +def option_group( + title: str, + help: str, + *options: Decorator, + constraint: Optional[Constraint] = None, + hidden: bool = False, +) -> Callable[[F], F]: + ... + + +@overload +def option_group( + title: str, + *options: Decorator, + help: Optional[str] = None, + constraint: Optional[Constraint] = None, + hidden: bool = False, +) -> Callable[[F], F]: + ... + + +# noinspection PyIncorrectDocstring +
[docs]def option_group(title: str, *args: Any, **kwargs: Any) -> Callable[[F], F]: + """ + Return a decorator that annotates a function with an option group. + + The ``help`` argument is an optional description and can be provided either + as keyword argument or as 2nd positional argument after the ``name`` of + the group:: + + # help as keyword argument + @option_group(name, *options, help=None, ...) + + # help as 2nd positional argument + @option_group(name, help, *options, ...) + + .. versionchanged:: 0.9.0 + in order to support the decorator :func:`cloup.constrained_params`, + ``@option_group`` now allows each input decorators to add multiple + options. + + :param title: + title of the help section describing the option group. + :param help: + an optional description shown below the name; can be provided as keyword + argument or 2nd positional argument. + :param options: + an arbitrary number of decorators like ``click.option``, which attach + one or multiple options to the decorated command function. + :param constraint: + an optional instance of :class:`~cloup.constraints.Constraint` + (see :doc:`Constraints </pages/constraints>` for more info); + a description of the constraint will be shown between squared brackets + aside the option group title (or below it if too long). + :param hidden: + if ``True``, the option group and all its options are hidden from the help page + (all contained options will have their ``hidden`` attribute set to ``True``). + """ + if args and isinstance(args[0], str): + return _option_group(title, options=args[1:], help=args[0], **kwargs) + else: + return _option_group(title, options=args, **kwargs)
+ + +def _option_group( + title: str, + options: Sequence[Callable[[F], F]], + help: Optional[str] = None, + constraint: Optional[Constraint] = None, + hidden: bool = False, +) -> Callable[[F], F]: + if not isinstance(title, str): + raise TypeError( + 'the first argument of `@option_group` must be its title, a string; ' + 'you probably forgot it' + ) + + if not options: + raise ValueError('you must provide at least one option') + + def decorator(f: F) -> F: + opt_group = OptionGroup(title, help=help, constraint=constraint, hidden=hidden) + if not hasattr(f, '__click_params__'): + f.__click_params__ = [] # type: ignore + cli_params = f.__click_params__ # type: ignore + for add_option in reversed(options): + prev_len = len(cli_params) + add_option(f) + added_options = cli_params[prev_len:] + for new_option in added_options: + if not isinstance(new_option, Option): + raise TypeError( + "only parameter of type `Option` can be added to option groups") + existing_group = get_option_group_of(new_option) + if existing_group is not None: + raise ValueError( + f'Option "{new_option}" was first assigned to group ' + f'"{existing_group}" and then passed as argument to ' + f'`@option_group({title!r}, ...)`' + ) + new_option.group = opt_group # type: ignore + if hidden: + new_option.hidden = True + return f + + return decorator +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/_params.html b/_modules/cloup/_params.html new file mode 100644 index 000000000..ae885ee6c --- /dev/null +++ b/_modules/cloup/_params.html @@ -0,0 +1,390 @@ + + + + + + + + cloup._params - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup._params

+import click
+from click.decorators import _param_memo
+
+
+
[docs]class Argument(click.Argument): + """A :class:`click.Argument` with help text.""" + + def __init__(self, *args, help=None, **attrs): + super().__init__(*args, **attrs) + self.help = help + +
[docs] def get_help_record(self, ctx): + return self.make_metavar(), self.help or ""
+ + +
[docs]class Option(click.Option): + """A :class:`click.Option` with an extra field ``group`` of type ``OptionGroup``.""" + + def __init__(self, *args, group=None, **attrs): + super().__init__(*args, **attrs) + self.group = group
+ + +GroupedOption = Option +"""Alias of ``Option``.""" + + +
[docs]def argument(*param_decls, cls=None, **attrs): + ArgumentClass = cls or Argument + + def decorator(f): + _param_memo(f, ArgumentClass(param_decls, **attrs)) + return f + + return decorator
+ + +
[docs]def option(*param_decls, cls=None, group=None, **attrs): + """Attach an ``Option`` to the command. + Refer to :class:`click.Option` and :class:`click.Parameter` for more info + about the accepted parameters. + + In your IDE, you won't see arguments relating to shell completion, + because they are different in Click 7 and 8 (both supported by Cloup): + + - in Click 7, it's ``autocompletion`` + - in Click 8, it's ``shell_complete``. + + These arguments have different semantics, refer to Click's docs. + """ + OptionClass = cls or Option + + def decorator(f): + _param_memo(f, OptionClass(param_decls, **attrs)) + new_option = f.__click_params__[-1] + new_option.group = group + if group and group.hidden: + new_option.hidden = True + return f + + return decorator
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/_sections.html b/_modules/cloup/_sections.html new file mode 100644 index 000000000..baa444e21 --- /dev/null +++ b/_modules/cloup/_sections.html @@ -0,0 +1,590 @@ + + + + + + + + cloup._sections - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup._sections

+from collections import OrderedDict
+from typing import (
+    Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type, TypeVar, Union,
+)
+
+import click
+
+from cloup._util import first_bool, pick_not_none
+from cloup.formatting import HelpSection, ensure_is_cloup_formatter
+
+CommandType = TypeVar('CommandType', bound=Type[click.Command])
+Subcommands = Union[Iterable[click.Command], Dict[str, click.Command]]
+
+
+
[docs]class Section: + """ + A group of (sub)commands to show in the same help section of a + ``MultiCommand``. You can use sections with any `Command` that inherits + from :class:`SectionMixin`. + + .. versionchanged:: 0.6.0 + removed the deprecated old name ``GroupSection``. + + .. versionchanged:: 0.5.0 + introduced the new name ``Section`` and deprecated the old ``GroupSection``. + """ + + def __init__(self, title: str, + commands: Subcommands = (), + is_sorted: bool = False): # noqa + """ + :param title: + :param commands: sequence of commands or dict of commands keyed by name + :param is_sorted: + if True, ``list_commands()`` returns the commands in lexicographic order + """ + if not isinstance(title, str): + raise TypeError( + 'the first argument must be a string, the title; you probably forgot it') + self.title = title + self.is_sorted = is_sorted + self.commands: OrderedDict[str, click.Command] = OrderedDict() + if isinstance(commands, Sequence): + self.commands = OrderedDict() + for cmd in commands: + self.add_command(cmd) + elif isinstance(commands, dict): + self.commands = OrderedDict(commands) + else: + raise TypeError('argument `commands` must be a sequence of commands ' + 'or a dict of commands keyed by name') + +
[docs] @classmethod + def sorted(cls, title: str, commands: Subcommands = ()) -> 'Section': + return cls(title, commands, is_sorted=True)
+ +
[docs] def add_command(self, cmd: click.Command, name: Optional[str] = None) -> None: + name = name or cmd.name + if not name: + raise TypeError('missing command name') + if name in self.commands: + raise Exception(f'command "{name}" already exists') + self.commands[name] = cmd
+ +
[docs] def list_commands(self) -> List[Tuple[str, click.Command]]: + command_list = [(name, cmd) for name, cmd in self.commands.items() + if not cmd.hidden] + if self.is_sorted: + command_list.sort() + return command_list
+ + def __len__(self) -> int: + return len(self.commands) + + def __repr__(self) -> str: + return 'Section({}, is_sorted={})'.format(self.title, self.is_sorted)
+ + +
[docs]class SectionMixin: + """ + Adds to a :class:`click.MultiCommand` the possibility of organizing its subcommands + into multiple help sections. + + Sections can be specified in the following ways: + + #. passing a list of :class:`Section` objects to the constructor setting + the argument ``sections`` + #. using :meth:`add_section` to add a single section + #. using :meth:`add_command` with the argument `section` set + + Commands not assigned to any user-defined section are added to the + "default section", whose title is "Commands" or "Other commands" depending + on whether it is the only section or not. The default section is the last + shown section in the help and its commands are listed in lexicographic order. + + .. versionchanged:: 0.8.0 + this mixin now relies on ``cloup.HelpFormatter`` to align help sections. + If a ``click.HelpFormatter`` is used with a ``TypeError`` is raised. + + .. versionchanged:: 0.8.0 + removed ``format_section``. Added ``make_commands_help_section``. + + .. versionadded:: 0.5.0 + """ + + def __init__( + self, *args: Any, + commands: Optional[Dict[str, click.Command]] = None, + sections: Iterable[Section] = (), + align_sections: Optional[bool] = None, + **kwargs: Any, + ): + """ + :param align_sections: + whether to align the columns of all subcommands' help sections. + This is also available as a context setting having a lower priority + than this attribute. Given that this setting should be consistent + across all you commands, you should probably use the context + setting only. + :param args: + positional arguments forwarded to the next class in the MRO + :param kwargs: + keyword arguments forwarded to the next class in the MRO + """ + super().__init__(*args, commands=commands, **kwargs) # type: ignore + self.align_sections = align_sections + self._default_section = Section('__DEFAULT', commands=commands or []) + self._user_sections: List[Section] = [] + self._section_set = {self._default_section} + for section in sections: + self.add_section(section) + + def _add_command_to_section( + self, cmd: click.Command, + name: Optional[str] = None, + section: Optional[Section] = None + ) -> None: + """Add a command to the section (if specified) or to the default section.""" + name = name or cmd.name + if section is None: + section = self._default_section + section.add_command(cmd, name) + if section not in self._section_set: + self._user_sections.append(section) + self._section_set.add(section) + +
[docs] def add_section(self, section: Section) -> None: + """Add a :class:`Section` to this group. You can add the same + section object only a single time. + + See Also: + :meth:`section` + """ + if section in self._section_set: + raise ValueError(f'section "{section}" was already added') + self._user_sections.append(section) + self._section_set.add(section) + for name, cmd in section.commands.items(): + # It's important to call self.add_command() and not super().add_command() here + # otherwise subclasses' add_command() is not called. + self.add_command(cmd, name, fallback_to_default_section=False)
+ +
[docs] def section(self, title: str, *commands: click.Command, **attrs: Any) -> Section: + """Create a new :class:`Section`, adds it to this group and returns it.""" + section = Section(title, commands, **attrs) + self.add_section(section) + return section
+ +
[docs] def add_command( + self, cmd: click.Command, + name: Optional[str] = None, + section: Optional[Section] = None, + fallback_to_default_section: bool = True, + ) -> None: + """ + Add a subcommand to this ``Group``. + + **Implementation note:** ``fallback_to_default_section`` looks not very + clean but, even if it's not immediate to see (it wasn't for me), I chose + it over apparently cleaner options. + + :param cmd: + :param name: + :param section: + a ``Section`` instance. The command must not be in the section already. + :param fallback_to_default_section: + if ``section`` is None and this option is enabled, the command is added + to the "default section". If disabled, the command is not added to + any section unless ``section`` is provided. This is useful for + internal code and subclasses. Don't disable it unless you know what + you are doing. + """ + super().add_command(cmd, name) # type: ignore + if section or fallback_to_default_section: + self._add_command_to_section(cmd, name, section)
+ +
[docs] def list_sections( + self, ctx: click.Context, include_default_section: bool = True + ) -> List[Section]: + """ + Return the list of all sections in the "correct order". + + If ``include_default_section=True`` and the default section is non-empty, + it will be included at the end of the list. + """ + section_list = list(self._user_sections) + if include_default_section and len(self._default_section) > 0: + default_section = Section.sorted( + title='Other commands' if len(self._user_sections) > 0 else 'Commands', + commands=self._default_section.commands) + section_list.append(default_section) + return section_list
+ +
[docs] def format_subcommand_name( + self, ctx: click.Context, name: str, cmd: click.Command + ) -> str: + """Used to format the name of the subcommands. This method is useful + when you combine this extension with other click extensions that override + :meth:`format_commands`. Most of these, like click-default-group, just + add something to the name of the subcommands, which is exactly what this + method allows you to do without overriding bigger methods. + """ + return name
+ +
[docs] def make_commands_help_section( + self, ctx: click.Context, section: Section + ) -> Optional[HelpSection]: + visible_subcommands = section.list_commands() + if not visible_subcommands: + return None + return HelpSection( + heading=section.title, + definitions=[ + (self.format_subcommand_name(ctx, name, cmd), cmd.get_short_help_str) + for name, cmd in visible_subcommands + ] + )
+ +
[docs] def must_align_sections( + self, ctx: Optional[click.Context], default: bool = True + ) -> bool: + return first_bool( + self.align_sections, + getattr(ctx, 'align_sections', None), + default, + )
+ +
[docs] def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + formatter = ensure_is_cloup_formatter(formatter) + + subcommand_sections = self.list_sections(ctx) + help_sections = pick_not_none( + self.make_commands_help_section(ctx, section) + for section in subcommand_sections + ) + if not help_sections: + return + + formatter.write_many_sections( + help_sections, aligned=self.must_align_sections(ctx) + )
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/constraints/_support.html b/_modules/cloup/constraints/_support.html new file mode 100644 index 000000000..9b0b6a287 --- /dev/null +++ b/_modules/cloup/constraints/_support.html @@ -0,0 +1,556 @@ + + + + + + + + cloup.constraints._support - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup.constraints._support

+from typing import (
+    Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Sequence,
+    TYPE_CHECKING, Tuple, Union,
+)
+
+import click
+
+from ._core import Constraint
+from .common import join_param_labels
+from .._util import first_bool
+from ..typing import Decorator, F
+
+if TYPE_CHECKING:
+    from cloup import HelpFormatter, OptionGroup
+
+
+class BoundConstraintSpec(NamedTuple):
+    """A NamedTuple storing a ``Constraint`` and the **names of the parameters**
+    it has to check."""
+    constraint: Constraint
+    param_names: Union[Sequence[str]]
+
+    def resolve_params(self, cmd: 'ConstraintMixin') -> 'BoundConstraint':
+        return BoundConstraint(
+            self.constraint,
+            cmd.get_params_by_name(self.param_names)
+        )
+
+
+def _constraint_memo(
+    f: Any, constr: Union[BoundConstraintSpec, 'BoundConstraint']
+) -> None:
+    if not hasattr(f, '__cloup_constraints__'):
+        f.__cloup_constraints__ = []
+    f.__cloup_constraints__.append(constr)
+
+
+
[docs]def constraint(constr: Constraint, params: Iterable[str]) -> Callable[[F], F]: + """Register a constraint on a list of parameters specified by (destination) name + (e.g. the default name of ``--input-file`` is ``input_file``).""" + spec = BoundConstraintSpec(constr, tuple(params)) + + def decorator(f: F) -> F: + _constraint_memo(f, spec) + return f + + return decorator
+ + +
[docs]def constrained_params( + constr: Constraint, + *param_adders: Decorator, +) -> Callable[[F], F]: + """ + Return a decorator that adds the given parameters and applies a constraint + to them. Equivalent to:: + + @param_adders[0] + ... + @param_adders[-1] + @constraint(constr, <param names>) + + This decorator saves you to manually (re)type the parameter names. + It can also be used inside ``@option_group``. + + Instead of using this decorator, you can also call the constraint itself:: + + @constr(*param_adders) + + but remember that: + + - Python 3.9 is the first that allows arbitrary expressions on the right of ``@``; + - using a long conditional/composite constraint as decorator may be less + readable. + + In these cases, you may consider using ``@constrained_params``. + + .. versionadded:: 0.9.0 + + :param constr: an instance of :class:`Constraint` + :param param_adders: + function decorators, each attaching a single parameter to the decorated + function. + """ + + def decorator(f: F) -> F: + reversed_params = [] + for add_param in reversed(param_adders): + add_param(f) + param = f.__click_params__[-1] # type: ignore + reversed_params.append(param) + bound_constr = BoundConstraint(constr, tuple(reversed_params[::-1])) + _constraint_memo(f, bound_constr) + return f + + return decorator
+ + +class BoundConstraint(NamedTuple): + """Internal utility ``NamedTuple`` that represents a ``Constraint`` + bound to a collection of ``click.Parameter`` instances. + Note: this is not a subclass of Constraint.""" + + constraint: Constraint + params: Sequence[click.Parameter] + + def check_consistency(self) -> None: + self.constraint.check_consistency(self.params) + + def check_values(self, ctx: click.Context) -> None: + self.constraint.check_values(self.params, ctx) + + def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: + constr_help = self.constraint.help(ctx) + if not constr_help: + return None + param_list = '{%s}' % join_param_labels(self.params) + return param_list, constr_help + + +
[docs]class ConstraintMixin: + """Provides support for constraints.""" + + def __init__( + self, *args: Any, + constraints: Sequence[Union[BoundConstraintSpec, BoundConstraint]] = (), + show_constraints: Optional[bool] = None, + **kwargs: Any, + ): + """ + :param constraints: + sequence of constraints bound to specific groups of parameters. + Note that constraints applied to option groups are collected from + the option groups themselves, so they don't need to be included in + this argument. + :param show_constraints: + whether to include a "Constraint" section in the command help. This + is also available as a context setting having a lower priority than + this attribute. + :param args: + positional arguments forwarded to the next class in the MRO + :param kwargs: + keyword arguments forwarded to the next class in the MRO + """ + super().__init__(*args, **kwargs) + + self.show_constraints = show_constraints + + # This allows constraints to efficiently access parameters by name + self._params_by_name: Dict[str, click.Parameter] = { + param.name: param for param in self.params # type: ignore + } + + # Collect constraints applied to option groups and bind them to the + # corresponding Option instances + option_groups: Tuple[OptionGroup, ...] = getattr(self, 'option_groups', tuple()) + self.optgroup_constraints = tuple( + BoundConstraint(grp.constraint, grp.options) + for grp in option_groups + if grp.constraint is not None + ) + """Constraints applied to ``OptionGroup`` instances.""" + + # Bind constraints defined via @constraint to click.Parameter instances + self.param_constraints: Tuple[BoundConstraint, ...] = tuple( + ( + constr if isinstance(constr, BoundConstraint) + else constr.resolve_params(self) + ) + for constr in constraints + ) + """Constraints registered using ``@constraint`` (or equivalent method).""" + + self.all_constraints = self.optgroup_constraints + self.param_constraints + """All constraints applied to parameter/option groups of this command.""" + +
[docs] def parse_args(self, ctx: click.Context, args: List[str]) -> List[str]: + # Check constraints' consistency *before* parsing + if not ctx.resilient_parsing and Constraint.must_check_consistency(ctx): + for constr in self.all_constraints: + constr.check_consistency() + + args = super().parse_args(ctx, args) # type: ignore + + # Skip constraints checking if the user wants to see --help for subcommand + # or if resilient parsing is enabled + should_show_subcommand_help = isinstance(ctx.command, click.Group) and any( + help_flag in args for help_flag in ctx.help_option_names + ) + if ctx.resilient_parsing or should_show_subcommand_help: + return args + + # Check constraints + for constr in self.all_constraints: + constr.check_values(ctx) + return args
+ +
[docs] def get_param_by_name(self, name: str) -> click.Parameter: + try: + return self._params_by_name[name] + except KeyError: + raise KeyError(f"there's no CLI parameter named '{name}'")
+ +
[docs] def get_params_by_name(self, names: Iterable[str]) -> Sequence[click.Parameter]: + return tuple(self.get_param_by_name(name) for name in names)
+ +
[docs] def format_constraints(self, ctx: click.Context, formatter: "HelpFormatter") -> None: + records_gen = (constr.get_help_record(ctx) for constr in self.param_constraints) + records = [rec for rec in records_gen if rec is not None] + if records: + with formatter.section('Constraints'): + formatter.write_dl(records)
+ +
[docs] def must_show_constraints(self, ctx: click.Context) -> bool: + # By default, don't show constraints + return first_bool( + self.show_constraints, + getattr(ctx, "show_constraints", None), + False, + )
+ + +def ensure_constraints_support(command: click.Command) -> ConstraintMixin: + if isinstance(command, ConstraintMixin): + return command + raise TypeError( + 'a Command must inherits from ConstraintMixin to support constraints') +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/formatting/_formatter.html b/_modules/cloup/formatting/_formatter.html new file mode 100644 index 000000000..fc0f0fd56 --- /dev/null +++ b/_modules/cloup/formatting/_formatter.html @@ -0,0 +1,739 @@ + + + + + + + + cloup.formatting._formatter - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup.formatting._formatter

+import dataclasses as dc
+import inspect
+import shutil
+import textwrap
+from itertools import chain
+from typing import (
+    Any, Callable, Dict, Iterable, Iterator, Optional, Sequence, TYPE_CHECKING,
+    Tuple, Union,
+)
+
+from cloup._util import click_version_ge_8_1
+from cloup.formatting._util import unstyled_len
+
+if TYPE_CHECKING:
+    from .sep import RowSepPolicy, SepGenerator
+
+import click
+from click.formatting import wrap_text
+
+from cloup._util import (
+    check_positive_int, identity, indent_lines, make_repr,
+    pick_non_missing,
+)
+from ..typing import MISSING, Possibly
+from cloup.styling import HelpTheme, IStyle
+
+Definition = Tuple[str, Union[str, Callable[[int], str]]]
+
+
+
[docs]@dc.dataclass() +class HelpSection: + """A container for a help section data.""" + heading: str + """Help section title.""" + + definitions: Sequence[Definition] + """Rows with 2 columns each. The 2nd element of each row can also be a function + taking an integer (the available width for the 2nd column) and returning a string.""" + + help: Optional[str] = None + """(Optional) long description of the section.""" + + constraint: Optional[str] = None + """(Optional) option group constraint description."""
+ + +# noinspection PyMethodMayBeStatic +
[docs]class HelpFormatter(click.HelpFormatter): + """ + A custom help formatter. Features include: + + - more attributes for controlling the output of the formatter + - a ``col1_width`` parameter in :meth:`write_dl` that allows Cloup to align + multiple definition lists without resorting to hacks + - a "linear layout" for definition lists that kicks in when the available + terminal width is not enough for the standard 2-column layout + (see argument ``col2_min_width``) + - the first column width, when not explicitly given in ``write_dl`` is + computed excluding the rows that exceed ``col1_max_width`` + (called ``col_max`` in ``write_dl`` for compatibility with Click). + + .. versionchanged:: 0.9.0 + the ``row_sep`` parameter now: + + - is set to ``None`` by default and ``row_sep=""`` corresponds to an + empty line between rows + - must not ends with ``\\n``; the formatter writes a newline just after + it (when it's not ``None``), so a newline at the end is always enforced + - accepts instances of :class:`~cloup.formatting.sep.SepGenerator` and + :class:`~cloup.formatting.sep.RowSepPolicy`. + + .. versionadded:: 0.8.0 + + :param indent_increment: + width of each indentation increment. + :param width: + content line width, excluding the newline character; by default it's + initialized to ``min(terminal_width - 1, max_width)`` where + ``max_width`` is another argument. + :param max_width: + maximum content line width (equivalent to ``Context.max_content_width``). + Used to compute ``width`` when it is not provided, ignored otherwise. + :param col1_max_width: + the maximum width of the first column of a definition list; as in Click, + if the text of a row exceeds this threshold, the 2nd column is printed + on a new line. + :param col2_min_width: + the minimum width for the second column of a definition list; if the + available space is less than this value, the formatter switches from the + standard 2-column layout to the "linear layout" (that this decision + is taken for each definition list). If you want to always use the linear + layout, you can set this argument to a very high number (or ``math.inf``). + If you never want it (not recommended), you can set this argument to zero. + :param col_spacing: + the number of spaces between the column boundaries of a definition list. + :param row_sep: + an "extra" separator to insert between the rows of a definition list (in + addition to the normal newline between definitions). If you want an empty + line between rows, pass ``row_sep=""``. + Read :ref:`Row separators <row-separators>` for more. + :param theme: + an :class:`~cloup.HelpTheme` instance specifying how to style the various + elements of the help page. + """ + + def __init__( + self, indent_increment: int = 2, + width: Optional[int] = None, + max_width: Optional[int] = None, + col1_max_width: int = 30, + col2_min_width: int = 35, + col_spacing: int = 2, + row_sep: Union[None, str, 'SepGenerator', 'RowSepPolicy'] = None, + theme: HelpTheme = HelpTheme(), + ): + check_positive_int(col1_max_width, 'col1_max_width') + check_positive_int(col_spacing, 'col_spacing') + if isinstance(row_sep, str) and row_sep.endswith('\n'): + raise ValueError( + "since v0.9, row_sep must not end with '\\n'. The formatter writes " + "a '\\n' after it; no other newline is allowed.\n" + "If you want an empty line between rows, set row_sep=''.") + + max_width = max_width or 80 + # We subtract 1 to the terminal width to leave space for the new line character. + # Otherwise, when we write a line that is long exactly terminal_size (without \n) + # the \n is printed on a new terminal line, leading to a useless empty line. + width = ( + width + or click.formatting.FORCED_WIDTH + or min(max_width, shutil.get_terminal_size((80, 100)).columns - 1) + ) + super().__init__( + width=width, max_width=max_width, indent_increment=indent_increment + ) + self.width: int = width + self.col1_max_width = col1_max_width + self.col2_min_width = col2_min_width + self.col_spacing = col_spacing + self.theme = theme + self.row_sep = row_sep + +
[docs] @staticmethod + def settings( + *, width: Possibly[Optional[int]] = MISSING, + max_width: Possibly[Optional[int]] = MISSING, + indent_increment: Possibly[int] = MISSING, + col1_max_width: Possibly[int] = MISSING, + col2_min_width: Possibly[int] = MISSING, + col_spacing: Possibly[int] = MISSING, + row_sep: Possibly[Union[None, str, 'SepGenerator', 'RowSepPolicy']] = MISSING, + theme: Possibly[HelpTheme] = MISSING, + ) -> Dict[str, Any]: + """A utility method for creating a ``formatter_settings`` dictionary to + pass as context settings or command attribute. This method exists for + one only reason: it enables auto-complete for formatter options, thus + improving the developer experience. + + Parameters are described in :class:`HelpFormatter`. + """ + return pick_non_missing(locals())
+ + @property + def available_width(self) -> int: + return self.width - self.current_indent + +
[docs] def write(self, *strings: str) -> None: + self.buffer += strings
+ +
[docs] def write_usage( + self, prog: str, args: str = "", prefix: Optional[str] = None + ) -> None: + prefix = "Usage:" if prefix is None else prefix + prefix = self.theme.heading(prefix) + " " + prog = self.theme.invoked_command(prog) + super().write_usage(prog, args, prefix)
+ +
[docs] def write_aliases(self, aliases: Sequence[str]) -> None: + self.write_heading("Aliases", newline=False) + alias_list = ", ".join(self.theme.col1(alias) for alias in aliases) + self.write(f" {alias_list}\n")
+ +
[docs] def write_command_help_text(self, cmd: click.Command) -> None: + help_text = cmd.help or "" + if help_text and click_version_ge_8_1: + help_text = inspect.cleandoc(help_text).partition("\f")[0] + if cmd.deprecated: + # Use the same label as Click: + # https://github.com/pallets/click/blob/b0538df/src/click/core.py#L1331 + help_text = "(Deprecated) " + help_text + if help_text: + self.write_paragraph() + with self.indentation(): + self.write_text(help_text, style=self.theme.command_help)
+ +
[docs] def write_heading(self, heading: str, newline: bool = True) -> None: + if self.current_indent: + self.write(" " * self.current_indent) + self.write(self.theme.heading(heading + ":")) + if newline: + self.write('\n')
+ +
[docs] def write_many_sections( + self, sections: Sequence[HelpSection], + aligned: bool = True, + ) -> None: + if aligned: + return self.write_aligned_sections(sections) + for s in sections: + self.write_section(s)
+ +
[docs] def write_aligned_sections(self, sections: Sequence[HelpSection]) -> None: + """Write multiple aligned definition lists.""" + all_rows = chain.from_iterable(dl.definitions for dl in sections) + col1_width = self.compute_col1_width(all_rows, self.col1_max_width) + for s in sections: + self.write_section(s, col1_width=col1_width)
+ +
[docs] def write_section(self, s: HelpSection, col1_width: Optional[int] = None) -> None: + theme = self.theme + self.write("\n") + self.write_heading(s.heading, newline=not s.constraint) + if s.constraint: + constraint_text = f'[{s.constraint}]' + available_width = self.available_width - len(s.heading) - len(': ') + if len(constraint_text) <= available_width: + self.write(" ", theme.constraint(constraint_text), "\n") + else: + self.write("\n") + with self.indentation(): + self.write_text(constraint_text, theme.constraint) + + with self.indentation(): + if s.help: + self.write_text(s.help, theme.section_help) + self.write_dl(s.definitions, col1_width=col1_width)
+ +
[docs] def write_text(self, text: str, style: IStyle = identity) -> None: + wrapped = wrap_text( + text, self.width - self.current_indent, preserve_paragraphs=True) + if style is identity: + wrapped_text = textwrap.indent(wrapped, prefix=' ' * self.current_indent) + else: + styled_lines = map(style, wrapped.splitlines()) + lines = indent_lines(styled_lines, width=self.current_indent) + wrapped_text = "\n".join(lines) + self.write(wrapped_text, "\n")
+ +
[docs] def compute_col1_width(self, rows: Iterable[Definition], max_width: int) -> int: + col1_lengths = (unstyled_len(r[0]) for r in rows) + lengths_under_limit = (length for length in col1_lengths if length <= max_width) + return max(lengths_under_limit, default=0)
+ +
[docs] def write_dl( + self, rows: Sequence[Definition], + col_max: Optional[int] = None, # default changed to None wrt parent class + col_spacing: Optional[int] = None, # default changed to None wrt parent class + col1_width: Optional[int] = None, + ) -> None: + """Write a definition list into the buffer. This is how options + and commands are usually formatted. + + If there's enough space, definition lists are rendered as a 2-column + pseudo-table: if the first column text of a row doesn't fit in the + provided/computed ``col1_width``, the 2nd column is printed on the + following line. + + If the available space for the 2nd column is below ``self.col2_min_width``, + the 2nd "column" is always printed below the 1st, indented with a minimum + of 3 spaces (or one ``indent_increment`` if that's greater than 3). + + :param rows: + a list of two item tuples for the terms and values. + :param col_max: + the maximum width for the 1st column of a definition list; this + argument is here to not break compatibility with Click; if provided, + it overrides the attribute ``self.col1_max_width``. + :param col_spacing: + number of spaces between the first and second column; + this argument is here to not break compatibility with Click; + if provided, it overrides ``self.col_spacing``. + :param col1_width: + the width to use for the first column; if not provided, it's + computed as the length of the longest string under ``self.col1_max_width``; + useful when you need to align multiple definition lists. + """ + # |<----------------------- width ------------------------>| + # | |<---------- available_width ---------->| + # | current_indent | col1_width | col_spacing | col2_width | + + col1_max_width = min( + col_max or self.col1_max_width, + self.available_width, + ) + col1_width = min( + col1_width or self.compute_col1_width(rows, col1_max_width), + col1_max_width, + ) + col_spacing = col_spacing or self.col_spacing + col2_width = self.available_width - col1_width - col_spacing + + if col2_width < self.col2_min_width: + self.write_linear_dl(rows) + else: + self.write_tabular_dl(rows, col1_width, col_spacing, col2_width)
+ + def _get_row_sep_for( + self, text_rows: Sequence[Sequence[str]], + col_widths: Sequence[int], + col_spacing: int, + ) -> Optional[str]: + if self.row_sep is None or isinstance(self.row_sep, str): + return self.row_sep + + from .sep import RowSepPolicy + if isinstance(self.row_sep, RowSepPolicy): + return self.row_sep(text_rows, col_widths, col_spacing) + elif callable(self.row_sep): # RowSepPolicy is callable; keep this for last + return self.row_sep(self.available_width) + else: + raise TypeError('row_sep') + +
[docs] def write_tabular_dl( + self, rows: Sequence[Definition], + col1_width: int, col_spacing: int, col2_width: int, + ) -> None: + """Format a definition list as a 2-column "pseudo-table". If the first + column of a row exceeds ``col1_width``, the 2nd column is written on + the subsequent line. This is the standard way of formatting definition + lists and it's the default if there's enough space.""" + + col1_plus_spacing = col1_width + col_spacing + col2_indentation = " " * ( + self.current_indent + max(self.indent_increment, col1_plus_spacing) + ) + indentation = " " * self.current_indent + + # Note: iter_defs() resolves eventual callables in row[1] + text_rows = list(iter_defs(rows, col2_width)) + row_sep = self._get_row_sep_for(text_rows, (col1_width, col2_width), col_spacing) + col1_styler, col2_styler = self.theme.col1, self.theme.col2 + + def write_row(row: Tuple[str, str]) -> None: + first, second = row + self.write(indentation, col1_styler(first)) + if not second: + self.write("\n") + else: + first_display_length = unstyled_len(first) + if first_display_length <= col1_width: + spaces_to_col2 = col1_plus_spacing - first_display_length + self.write(" " * spaces_to_col2) + else: + self.write("\n", col2_indentation) + + if len(second) <= col2_width: + self.write(col2_styler(second), "\n") + else: + wrapped_text = wrap_text(second, col2_width, preserve_paragraphs=True) + lines = [col2_styler(line) for line in wrapped_text.splitlines()] + self.write(lines[0], "\n") + for line in lines[1:]: + self.write(col2_indentation, line, "\n") + + write_row(text_rows[0]) + for row in text_rows[1:]: + if row_sep is not None: + self.write(indentation, row_sep, "\n") + write_row(row)
+ +
[docs] def write_linear_dl(self, dl: Sequence[Definition]) -> None: + """Format a definition list as a "linear list". This is the default when + the available width for the definitions (2nd column) is below + ``self.col2_min_width``.""" + help_extra_indent = max(3, self.indent_increment) + help_total_indent = self.current_indent + help_extra_indent + help_max_width = self.width - help_total_indent + current_indentation = " " * self.current_indent + + col1_styler = self.theme.col1 + col2_styler = self.theme.col2 + + for names, help in iter_defs(dl, help_max_width): + self.write(current_indentation + col1_styler(names) + '\n') + if help: + self.current_indent += help_extra_indent + self.write_text(help, col2_styler) + self.current_indent -= help_extra_indent + self.write("\n") + self.buffer.pop() # pop last newline
+ +
[docs] def write_epilog(self, epilog: str) -> None: + self.write_text(epilog, self.theme.epilog)
+ + def __repr__(self) -> str: + return make_repr( + self, width=self.width, indent_increment=self.indent_increment, + col1_max_width=self.col1_max_width, col_spacing=self.col_spacing + )
+ + +def iter_defs(rows: Iterable[Definition], col2_width: int) -> Iterator[Tuple[str, str]]: + for row in rows: + if len(row) == 1: + yield row[0], '' + elif len(row) == 2: + second = row[1](col2_width) if callable(row[1]) else row[1] + yield row[0], second + else: + raise ValueError(f'invalid row length: {len(row)}') +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/styling.html b/_modules/cloup/styling.html new file mode 100644 index 000000000..e1752a0a7 --- /dev/null +++ b/_modules/cloup/styling.html @@ -0,0 +1,539 @@ + + + + + + + + cloup.styling - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup.styling

+"""
+This module contains components that specifically address the styling and theming
+of the ``--help`` output.
+"""
+import dataclasses
+import dataclasses as dc
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, Optional
+
+import click
+
+from cloup._util import FrozenSpace, click_version_tuple, delete_keys, identity
+from cloup.typing import MISSING, Possibly
+
+IStyle = Callable[[str], str]
+"""A callable that takes a string and returns a styled version of it."""
+
+
+
[docs]@dataclass(frozen=True) +class HelpTheme: + """A collection of styles for several elements of the help page. + + A "style" is just a function or a callable that takes a string and returns + a styled version of it. This means you can use your favorite styling/color + library (like rich, colorful etc). Nonetheless, given that Click has some + basic styling functionality built-in, Cloup provides the :class:`Style` + class, which is a wrapper of the ``click.style`` function. + + :param invoked_command: + Style of the invoked command name (in Usage). + :param command_help: + Style of the invoked command description (below Usage). + :param heading: + Style of help section headings. + :param constraint: + Style of an option group constraint description. + :param section_help: + Style of the help text of a section (the optional paragraph below the heading). + :param col1: + Style of the first column of a definition list (options and command names). + :param col2: + Style of the second column of a definition list (help text). + :param epilog: + Style of the epilog. + :param alias: + Style of subcommand aliases in a definition lists. + :param alias_secondary: + Style of separator and eventual parenthesis/brackets in subcommand alias lists. + If not provided, the ``alias`` style will be used. + """ + + invoked_command: IStyle = identity + """Style of the invoked command name (in Usage).""" + + command_help: IStyle = identity + """Style of the invoked command description (below Usage).""" + + heading: IStyle = identity + """Style of help section headings.""" + + constraint: IStyle = identity + """Style of an option group constraint description.""" + + section_help: IStyle = identity + """Style of the help text of a section (the optional paragraph below the heading).""" + + col1: IStyle = identity + """Style of the first column of a definition list (options and command names).""" + + col2: IStyle = identity + """Style of the second column of a definition list (help text).""" + + alias: IStyle = identity + """Style of subcommand aliases in a definition lists.""" + + alias_secondary: Optional[IStyle] = None + """Style of separator and eventual parenthesis/brackets in subcommand alias lists. + If not provided, the ``alias`` style will be used.""" + + epilog: IStyle = identity + """Style of the epilog.""" + +
[docs] def with_( + self, invoked_command: Optional[IStyle] = None, + command_help: Optional[IStyle] = None, + heading: Optional[IStyle] = None, + constraint: Optional[IStyle] = None, + section_help: Optional[IStyle] = None, + col1: Optional[IStyle] = None, + col2: Optional[IStyle] = None, + alias: Optional[IStyle] = None, + alias_secondary: Possibly[Optional[IStyle]] = MISSING, + epilog: Optional[IStyle] = None, + ) -> 'HelpTheme': + kwargs = {key: val for key, val in locals().items() if val is not None} + if alias_secondary is MISSING: + del kwargs["alias_secondary"] + kwargs.pop('self') + if kwargs: + return dataclasses.replace(self, **kwargs) + return self
+ +
[docs] @staticmethod + def dark() -> "HelpTheme": + """A theme assuming a dark terminal background color.""" + return HelpTheme( + invoked_command=Style(fg='bright_yellow'), + heading=Style(fg='bright_white', bold=True), + constraint=Style(fg='magenta'), + col1=Style(fg='bright_yellow'), + alias=Style(fg='yellow'), + alias_secondary=Style(fg='white'), + )
+ +
[docs] @staticmethod + def light() -> "HelpTheme": + """A theme assuming a light terminal background color.""" + return HelpTheme( + invoked_command=Style(fg='yellow'), + heading=Style(fg='bright_blue'), + constraint=Style(fg='red'), + col1=Style(fg='yellow'), + )
+ + +
[docs]@dc.dataclass(frozen=True) +class Style: + """Wraps :func:`click.style` for a better integration with :class:`HelpTheme`. + + Available colors are defined as static constants in :class:`Color`. + + Arguments are set to ``None`` by default. Passing ``False`` to boolean args + or ``Color.reset`` as color causes a reset code to be inserted. + + With respect to :func:`click.style`, this class: + + - has an argument less, ``reset``, which is always ``True`` + - add the ``text_transform``. + + .. warning:: + The arguments ``overline``, ``italic`` and ``strikethrough`` are only + supported in Click 8 and will be ignored if you are using Click 7. + + :param fg: foreground color + :param bg: background color + :param bold: + :param dim: + :param underline: + :param overline: + :param italic: + :param blink: + :param reverse: + :param strikethrough: + :param text_transform: + a generic string transformation; useful to apply functions like ``str.upper`` + + .. versionadded:: 0.8.0 + """ + fg: Optional[str] = None + bg: Optional[str] = None + bold: Optional[bool] = None + dim: Optional[bool] = None + underline: Optional[bool] = None + overline: Optional[bool] = None + italic: Optional[bool] = None + blink: Optional[bool] = None + reverse: Optional[bool] = None + strikethrough: Optional[bool] = None + text_transform: Optional[IStyle] = None + + _style_kwargs: Optional[Dict[str, Any]] = dc.field(init=False, default=None) + + def __call__(self, text: str) -> str: + if self._style_kwargs is None: + kwargs = dc.asdict(self) + delete_keys(kwargs, ['text_transform', '_style_kwargs']) + if int(click_version_tuple[0]) < 8: + # These arguments are not supported in Click < 8. Ignore them. + delete_keys(kwargs, ['overline', 'italic', 'strikethrough']) + object.__setattr__(self, '_style_kwargs', kwargs) + else: + kwargs = self._style_kwargs + + if self.text_transform: + text = self.text_transform(text) + return click.style(text, **kwargs)
+ + +
[docs]class Color(FrozenSpace): + """Colors accepted by :class:`Style` and :func:`click.style`.""" + black = "black" + red = "red" + green = "green" + yellow = "yellow" + blue = "blue" + magenta = "magenta" + cyan = "cyan" + white = "white" + reset = "reset" + bright_black = "bright_black" + bright_red = "bright_red" + bright_green = "bright_green" + bright_yellow = "bright_yellow" + bright_blue = "bright_blue" + bright_magenta = "bright_magenta" + bright_cyan = "bright_cyan" + bright_white = "bright_white"
+ + +DEFAULT_THEME = HelpTheme() +
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/cloup/types.html b/_modules/cloup/types.html new file mode 100644 index 000000000..b766fd87f --- /dev/null +++ b/_modules/cloup/types.html @@ -0,0 +1,378 @@ + + + + + + + + cloup.types - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for cloup.types

+"""
+Parameter types and "shortcuts" for creating commonly used types.
+"""
+import pathlib
+
+import click
+
+
+
[docs]def path( + *, + path_type: type = pathlib.Path, + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, +) -> click.Path: + """Shortcut for :class:`click.Path` with ``path_type=pathlib.Path``.""" + return click.Path(**locals())
+ + +
[docs]def dir_path( + *, + path_type: type = pathlib.Path, + exists: bool = False, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, +) -> click.Path: + """Shortcut for :class:`click.Path` with + ``file_okay=False, path_type=pathlib.Path``.""" + return click.Path(**locals(), file_okay=False)
+ + +
[docs]def file_path( + *, + path_type: type = pathlib.Path, + exists: bool = False, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, +) -> click.Path: + """Shortcut for :class:`click.Path` with + ``dir_okay=False, path_type=pathlib.Path``.""" + return click.Path(**locals(), dir_okay=False)
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/functools.html b/_modules/functools.html new file mode 100644 index 000000000..e66dd312c --- /dev/null +++ b/_modules/functools.html @@ -0,0 +1,1335 @@ + + + + + + + + functools - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for functools

+"""functools.py - Tools for working with functions and callable objects
+"""
+# Python module wrapper for _functools C module
+# to allow utilities written in Python to be added
+# to the functools module.
+# Written by Nick Coghlan <ncoghlan at gmail.com>,
+# Raymond Hettinger <python at rcn.com>,
+# and Łukasz Langa <lukasz at langa.pl>.
+#   Copyright (C) 2006-2013 Python Software Foundation.
+# See C source code for _functools credits/copyright
+
+__all__ = ['update_wrapper', 'wraps', 'WRAPPER_ASSIGNMENTS', 'WRAPPER_UPDATES',
+           'total_ordering', 'cache', 'cmp_to_key', 'lru_cache', 'reduce',
+           'partial', 'partialmethod', 'singledispatch', 'singledispatchmethod',
+           'cached_property']
+
+from abc import get_cache_token
+from collections import namedtuple
+# import types, weakref  # Deferred to single_dispatch()
+from reprlib import recursive_repr
+from _thread import RLock
+from types import GenericAlias
+
+
+################################################################################
+### update_wrapper() and wraps() decorator
+################################################################################
+
+# update_wrapper() and wraps() are tools to help write
+# wrapper functions that can handle naive introspection
+
+WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
+                       '__annotations__', '__type_params__')
+WRAPPER_UPDATES = ('__dict__',)
+def update_wrapper(wrapper,
+                   wrapped,
+                   assigned = WRAPPER_ASSIGNMENTS,
+                   updated = WRAPPER_UPDATES):
+    """Update a wrapper function to look like the wrapped function
+
+       wrapper is the function to be updated
+       wrapped is the original function
+       assigned is a tuple naming the attributes assigned directly
+       from the wrapped function to the wrapper function (defaults to
+       functools.WRAPPER_ASSIGNMENTS)
+       updated is a tuple naming the attributes of the wrapper that
+       are updated with the corresponding attribute from the wrapped
+       function (defaults to functools.WRAPPER_UPDATES)
+    """
+    for attr in assigned:
+        try:
+            value = getattr(wrapped, attr)
+        except AttributeError:
+            pass
+        else:
+            setattr(wrapper, attr, value)
+    for attr in updated:
+        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
+    # Issue #17482: set __wrapped__ last so we don't inadvertently copy it
+    # from the wrapped function when updating __dict__
+    wrapper.__wrapped__ = wrapped
+    # Return the wrapper so this can be used as a decorator via partial()
+    return wrapper
+
+def wraps(wrapped,
+          assigned = WRAPPER_ASSIGNMENTS,
+          updated = WRAPPER_UPDATES):
+    """Decorator factory to apply update_wrapper() to a wrapper function
+
+       Returns a decorator that invokes update_wrapper() with the decorated
+       function as the wrapper argument and the arguments to wraps() as the
+       remaining arguments. Default arguments are as for update_wrapper().
+       This is a convenience function to simplify applying partial() to
+       update_wrapper().
+    """
+    return partial(update_wrapper, wrapped=wrapped,
+                   assigned=assigned, updated=updated)
+
+
+################################################################################
+### total_ordering class decorator
+################################################################################
+
+# The total ordering functions all invoke the root magic method directly
+# rather than using the corresponding operator.  This avoids possible
+# infinite recursion that could occur when the operator dispatch logic
+# detects a NotImplemented result and then calls a reflected method.
+
+def _gt_from_lt(self, other):
+    'Return a > b.  Computed by @total_ordering from (not a < b) and (a != b).'
+    op_result = type(self).__lt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result and self != other
+
+def _le_from_lt(self, other):
+    'Return a <= b.  Computed by @total_ordering from (a < b) or (a == b).'
+    op_result = type(self).__lt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result or self == other
+
+def _ge_from_lt(self, other):
+    'Return a >= b.  Computed by @total_ordering from (not a < b).'
+    op_result = type(self).__lt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+def _ge_from_le(self, other):
+    'Return a >= b.  Computed by @total_ordering from (not a <= b) or (a == b).'
+    op_result = type(self).__le__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result or self == other
+
+def _lt_from_le(self, other):
+    'Return a < b.  Computed by @total_ordering from (a <= b) and (a != b).'
+    op_result = type(self).__le__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result and self != other
+
+def _gt_from_le(self, other):
+    'Return a > b.  Computed by @total_ordering from (not a <= b).'
+    op_result = type(self).__le__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+def _lt_from_gt(self, other):
+    'Return a < b.  Computed by @total_ordering from (not a > b) and (a != b).'
+    op_result = type(self).__gt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result and self != other
+
+def _ge_from_gt(self, other):
+    'Return a >= b.  Computed by @total_ordering from (a > b) or (a == b).'
+    op_result = type(self).__gt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result or self == other
+
+def _le_from_gt(self, other):
+    'Return a <= b.  Computed by @total_ordering from (not a > b).'
+    op_result = type(self).__gt__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+def _le_from_ge(self, other):
+    'Return a <= b.  Computed by @total_ordering from (not a >= b) or (a == b).'
+    op_result = type(self).__ge__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result or self == other
+
+def _gt_from_ge(self, other):
+    'Return a > b.  Computed by @total_ordering from (a >= b) and (a != b).'
+    op_result = type(self).__ge__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return op_result and self != other
+
+def _lt_from_ge(self, other):
+    'Return a < b.  Computed by @total_ordering from (not a >= b).'
+    op_result = type(self).__ge__(self, other)
+    if op_result is NotImplemented:
+        return op_result
+    return not op_result
+
+_convert = {
+    '__lt__': [('__gt__', _gt_from_lt),
+               ('__le__', _le_from_lt),
+               ('__ge__', _ge_from_lt)],
+    '__le__': [('__ge__', _ge_from_le),
+               ('__lt__', _lt_from_le),
+               ('__gt__', _gt_from_le)],
+    '__gt__': [('__lt__', _lt_from_gt),
+               ('__ge__', _ge_from_gt),
+               ('__le__', _le_from_gt)],
+    '__ge__': [('__le__', _le_from_ge),
+               ('__gt__', _gt_from_ge),
+               ('__lt__', _lt_from_ge)]
+}
+
+def total_ordering(cls):
+    """Class decorator that fills in missing ordering methods"""
+    # Find user-defined comparisons (not those inherited from object).
+    roots = {op for op in _convert if getattr(cls, op, None) is not getattr(object, op, None)}
+    if not roots:
+        raise ValueError('must define at least one ordering operation: < > <= >=')
+    root = max(roots)       # prefer __lt__ to __le__ to __gt__ to __ge__
+    for opname, opfunc in _convert[root]:
+        if opname not in roots:
+            opfunc.__name__ = opname
+            setattr(cls, opname, opfunc)
+    return cls
+
+
+################################################################################
+### cmp_to_key() function converter
+################################################################################
+
+def cmp_to_key(mycmp):
+    """Convert a cmp= function into a key= function"""
+    class K(object):
+        __slots__ = ['obj']
+        def __init__(self, obj):
+            self.obj = obj
+        def __lt__(self, other):
+            return mycmp(self.obj, other.obj) < 0
+        def __gt__(self, other):
+            return mycmp(self.obj, other.obj) > 0
+        def __eq__(self, other):
+            return mycmp(self.obj, other.obj) == 0
+        def __le__(self, other):
+            return mycmp(self.obj, other.obj) <= 0
+        def __ge__(self, other):
+            return mycmp(self.obj, other.obj) >= 0
+        __hash__ = None
+    return K
+
+try:
+    from _functools import cmp_to_key
+except ImportError:
+    pass
+
+
+################################################################################
+### reduce() sequence to a single item
+################################################################################
+
+_initial_missing = object()
+
+def reduce(function, sequence, initial=_initial_missing):
+    """
+    reduce(function, iterable[, initial]) -> value
+
+    Apply a function of two arguments cumulatively to the items of a sequence
+    or iterable, from left to right, so as to reduce the iterable to a single
+    value.  For example, reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates
+    ((((1+2)+3)+4)+5).  If initial is present, it is placed before the items
+    of the iterable in the calculation, and serves as a default when the
+    iterable is empty.
+    """
+
+    it = iter(sequence)
+
+    if initial is _initial_missing:
+        try:
+            value = next(it)
+        except StopIteration:
+            raise TypeError(
+                "reduce() of empty iterable with no initial value") from None
+    else:
+        value = initial
+
+    for element in it:
+        value = function(value, element)
+
+    return value
+
+try:
+    from _functools import reduce
+except ImportError:
+    pass
+
+
+################################################################################
+### partial() argument application
+################################################################################
+
+# Purely functional, no descriptor behaviour
+class partial:
+    """New function with partial application of the given arguments
+    and keywords.
+    """
+
+    __slots__ = "func", "args", "keywords", "__dict__", "__weakref__"
+
+    def __new__(cls, func, /, *args, **keywords):
+        if not callable(func):
+            raise TypeError("the first argument must be callable")
+
+        if hasattr(func, "func"):
+            args = func.args + args
+            keywords = {**func.keywords, **keywords}
+            func = func.func
+
+        self = super(partial, cls).__new__(cls)
+
+        self.func = func
+        self.args = args
+        self.keywords = keywords
+        return self
+
+    def __call__(self, /, *args, **keywords):
+        keywords = {**self.keywords, **keywords}
+        return self.func(*self.args, *args, **keywords)
+
+    @recursive_repr()
+    def __repr__(self):
+        qualname = type(self).__qualname__
+        args = [repr(self.func)]
+        args.extend(repr(x) for x in self.args)
+        args.extend(f"{k}={v!r}" for (k, v) in self.keywords.items())
+        if type(self).__module__ == "functools":
+            return f"functools.{qualname}({', '.join(args)})"
+        return f"{qualname}({', '.join(args)})"
+
+    def __reduce__(self):
+        return type(self), (self.func,), (self.func, self.args,
+               self.keywords or None, self.__dict__ or None)
+
+    def __setstate__(self, state):
+        if not isinstance(state, tuple):
+            raise TypeError("argument to __setstate__ must be a tuple")
+        if len(state) != 4:
+            raise TypeError(f"expected 4 items in state, got {len(state)}")
+        func, args, kwds, namespace = state
+        if (not callable(func) or not isinstance(args, tuple) or
+           (kwds is not None and not isinstance(kwds, dict)) or
+           (namespace is not None and not isinstance(namespace, dict))):
+            raise TypeError("invalid partial state")
+
+        args = tuple(args) # just in case it's a subclass
+        if kwds is None:
+            kwds = {}
+        elif type(kwds) is not dict: # XXX does it need to be *exactly* dict?
+            kwds = dict(kwds)
+        if namespace is None:
+            namespace = {}
+
+        self.__dict__ = namespace
+        self.func = func
+        self.args = args
+        self.keywords = kwds
+
+try:
+    from _functools import partial
+except ImportError:
+    pass
+
+# Descriptor version
+class partialmethod(object):
+    """Method descriptor with partial application of the given arguments
+    and keywords.
+
+    Supports wrapping existing descriptors and handles non-descriptor
+    callables as instance methods.
+    """
+
+    def __init__(self, func, /, *args, **keywords):
+        if not callable(func) and not hasattr(func, "__get__"):
+            raise TypeError("{!r} is not callable or a descriptor"
+                                 .format(func))
+
+        # func could be a descriptor like classmethod which isn't callable,
+        # so we can't inherit from partial (it verifies func is callable)
+        if isinstance(func, partialmethod):
+            # flattening is mandatory in order to place cls/self before all
+            # other arguments
+            # it's also more efficient since only one function will be called
+            self.func = func.func
+            self.args = func.args + args
+            self.keywords = {**func.keywords, **keywords}
+        else:
+            self.func = func
+            self.args = args
+            self.keywords = keywords
+
+    def __repr__(self):
+        args = ", ".join(map(repr, self.args))
+        keywords = ", ".join("{}={!r}".format(k, v)
+                                 for k, v in self.keywords.items())
+        format_string = "{module}.{cls}({func}, {args}, {keywords})"
+        return format_string.format(module=self.__class__.__module__,
+                                    cls=self.__class__.__qualname__,
+                                    func=self.func,
+                                    args=args,
+                                    keywords=keywords)
+
+    def _make_unbound_method(self):
+        def _method(cls_or_self, /, *args, **keywords):
+            keywords = {**self.keywords, **keywords}
+            return self.func(cls_or_self, *self.args, *args, **keywords)
+        _method.__isabstractmethod__ = self.__isabstractmethod__
+        _method._partialmethod = self
+        return _method
+
+    def __get__(self, obj, cls=None):
+        get = getattr(self.func, "__get__", None)
+        result = None
+        if get is not None:
+            new_func = get(obj, cls)
+            if new_func is not self.func:
+                # Assume __get__ returning something new indicates the
+                # creation of an appropriate callable
+                result = partial(new_func, *self.args, **self.keywords)
+                try:
+                    result.__self__ = new_func.__self__
+                except AttributeError:
+                    pass
+        if result is None:
+            # If the underlying descriptor didn't do anything, treat this
+            # like an instance method
+            result = self._make_unbound_method().__get__(obj, cls)
+        return result
+
+    @property
+    def __isabstractmethod__(self):
+        return getattr(self.func, "__isabstractmethod__", False)
+
+    __class_getitem__ = classmethod(GenericAlias)
+
+
+# Helper functions
+
+def _unwrap_partial(func):
+    while isinstance(func, partial):
+        func = func.func
+    return func
+
+################################################################################
+### LRU Cache function decorator
+################################################################################
+
+_CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"])
+
+class _HashedSeq(list):
+    """ This class guarantees that hash() will be called no more than once
+        per element.  This is important because the lru_cache() will hash
+        the key multiple times on a cache miss.
+
+    """
+
+    __slots__ = 'hashvalue'
+
+    def __init__(self, tup, hash=hash):
+        self[:] = tup
+        self.hashvalue = hash(tup)
+
+    def __hash__(self):
+        return self.hashvalue
+
+def _make_key(args, kwds, typed,
+             kwd_mark = (object(),),
+             fasttypes = {int, str},
+             tuple=tuple, type=type, len=len):
+    """Make a cache key from optionally typed positional and keyword arguments
+
+    The key is constructed in a way that is flat as possible rather than
+    as a nested structure that would take more memory.
+
+    If there is only a single argument and its data type is known to cache
+    its hash value, then that argument is returned without a wrapper.  This
+    saves space and improves lookup speed.
+
+    """
+    # All of code below relies on kwds preserving the order input by the user.
+    # Formerly, we sorted() the kwds before looping.  The new way is *much*
+    # faster; however, it means that f(x=1, y=2) will now be treated as a
+    # distinct call from f(y=2, x=1) which will be cached separately.
+    key = args
+    if kwds:
+        key += kwd_mark
+        for item in kwds.items():
+            key += item
+    if typed:
+        key += tuple(type(v) for v in args)
+        if kwds:
+            key += tuple(type(v) for v in kwds.values())
+    elif len(key) == 1 and type(key[0]) in fasttypes:
+        return key[0]
+    return _HashedSeq(key)
+
+def lru_cache(maxsize=128, typed=False):
+    """Least-recently-used cache decorator.
+
+    If *maxsize* is set to None, the LRU features are disabled and the cache
+    can grow without bound.
+
+    If *typed* is True, arguments of different types will be cached separately.
+    For example, f(3.0) and f(3) will be treated as distinct calls with
+    distinct results.
+
+    Arguments to the cached function must be hashable.
+
+    View the cache statistics named tuple (hits, misses, maxsize, currsize)
+    with f.cache_info().  Clear the cache and statistics with f.cache_clear().
+    Access the underlying function with f.__wrapped__.
+
+    See:  https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)
+
+    """
+
+    # Users should only access the lru_cache through its public API:
+    #       cache_info, cache_clear, and f.__wrapped__
+    # The internals of the lru_cache are encapsulated for thread safety and
+    # to allow the implementation to change (including a possible C version).
+
+    if isinstance(maxsize, int):
+        # Negative maxsize is treated as 0
+        if maxsize < 0:
+            maxsize = 0
+    elif callable(maxsize) and isinstance(typed, bool):
+        # The user_function was passed in directly via the maxsize argument
+        user_function, maxsize = maxsize, 128
+        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
+        wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
+        return update_wrapper(wrapper, user_function)
+    elif maxsize is not None:
+        raise TypeError(
+            'Expected first argument to be an integer, a callable, or None')
+
+    def decorating_function(user_function):
+        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
+        wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
+        return update_wrapper(wrapper, user_function)
+
+    return decorating_function
+
+def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo):
+    # Constants shared by all lru cache instances:
+    sentinel = object()          # unique object used to signal cache misses
+    make_key = _make_key         # build a key from the function arguments
+    PREV, NEXT, KEY, RESULT = 0, 1, 2, 3   # names for the link fields
+
+    cache = {}
+    hits = misses = 0
+    full = False
+    cache_get = cache.get    # bound method to lookup a key or return None
+    cache_len = cache.__len__  # get cache size without calling len()
+    lock = RLock()           # because linkedlist updates aren't threadsafe
+    root = []                # root of the circular doubly linked list
+    root[:] = [root, root, None, None]     # initialize by pointing to self
+
+    if maxsize == 0:
+
+        def wrapper(*args, **kwds):
+            # No caching -- just a statistics update
+            nonlocal misses
+            misses += 1
+            result = user_function(*args, **kwds)
+            return result
+
+    elif maxsize is None:
+
+        def wrapper(*args, **kwds):
+            # Simple caching without ordering or size limit
+            nonlocal hits, misses
+            key = make_key(args, kwds, typed)
+            result = cache_get(key, sentinel)
+            if result is not sentinel:
+                hits += 1
+                return result
+            misses += 1
+            result = user_function(*args, **kwds)
+            cache[key] = result
+            return result
+
+    else:
+
+        def wrapper(*args, **kwds):
+            # Size limited caching that tracks accesses by recency
+            nonlocal root, hits, misses, full
+            key = make_key(args, kwds, typed)
+            with lock:
+                link = cache_get(key)
+                if link is not None:
+                    # Move the link to the front of the circular queue
+                    link_prev, link_next, _key, result = link
+                    link_prev[NEXT] = link_next
+                    link_next[PREV] = link_prev
+                    last = root[PREV]
+                    last[NEXT] = root[PREV] = link
+                    link[PREV] = last
+                    link[NEXT] = root
+                    hits += 1
+                    return result
+                misses += 1
+            result = user_function(*args, **kwds)
+            with lock:
+                if key in cache:
+                    # Getting here means that this same key was added to the
+                    # cache while the lock was released.  Since the link
+                    # update is already done, we need only return the
+                    # computed result and update the count of misses.
+                    pass
+                elif full:
+                    # Use the old root to store the new key and result.
+                    oldroot = root
+                    oldroot[KEY] = key
+                    oldroot[RESULT] = result
+                    # Empty the oldest link and make it the new root.
+                    # Keep a reference to the old key and old result to
+                    # prevent their ref counts from going to zero during the
+                    # update. That will prevent potentially arbitrary object
+                    # clean-up code (i.e. __del__) from running while we're
+                    # still adjusting the links.
+                    root = oldroot[NEXT]
+                    oldkey = root[KEY]
+                    oldresult = root[RESULT]
+                    root[KEY] = root[RESULT] = None
+                    # Now update the cache dictionary.
+                    del cache[oldkey]
+                    # Save the potentially reentrant cache[key] assignment
+                    # for last, after the root and links have been put in
+                    # a consistent state.
+                    cache[key] = oldroot
+                else:
+                    # Put result in a new link at the front of the queue.
+                    last = root[PREV]
+                    link = [last, root, key, result]
+                    last[NEXT] = root[PREV] = cache[key] = link
+                    # Use the cache_len bound method instead of the len() function
+                    # which could potentially be wrapped in an lru_cache itself.
+                    full = (cache_len() >= maxsize)
+            return result
+
+    def cache_info():
+        """Report cache statistics"""
+        with lock:
+            return _CacheInfo(hits, misses, maxsize, cache_len())
+
+    def cache_clear():
+        """Clear the cache and cache statistics"""
+        nonlocal hits, misses, full
+        with lock:
+            cache.clear()
+            root[:] = [root, root, None, None]
+            hits = misses = 0
+            full = False
+
+    wrapper.cache_info = cache_info
+    wrapper.cache_clear = cache_clear
+    return wrapper
+
+try:
+    from _functools import _lru_cache_wrapper
+except ImportError:
+    pass
+
+
+################################################################################
+### cache -- simplified access to the infinity cache
+################################################################################
+
+def cache(user_function, /):
+    'Simple lightweight unbounded cache.  Sometimes called "memoize".'
+    return lru_cache(maxsize=None)(user_function)
+
+
+################################################################################
+### singledispatch() - single-dispatch generic function decorator
+################################################################################
+
+def _c3_merge(sequences):
+    """Merges MROs in *sequences* to a single MRO using the C3 algorithm.
+
+    Adapted from https://www.python.org/download/releases/2.3/mro/.
+
+    """
+    result = []
+    while True:
+        sequences = [s for s in sequences if s]   # purge empty sequences
+        if not sequences:
+            return result
+        for s1 in sequences:   # find merge candidates among seq heads
+            candidate = s1[0]
+            for s2 in sequences:
+                if candidate in s2[1:]:
+                    candidate = None
+                    break      # reject the current head, it appears later
+            else:
+                break
+        if candidate is None:
+            raise RuntimeError("Inconsistent hierarchy")
+        result.append(candidate)
+        # remove the chosen candidate
+        for seq in sequences:
+            if seq[0] == candidate:
+                del seq[0]
+
+def _c3_mro(cls, abcs=None):
+    """Computes the method resolution order using extended C3 linearization.
+
+    If no *abcs* are given, the algorithm works exactly like the built-in C3
+    linearization used for method resolution.
+
+    If given, *abcs* is a list of abstract base classes that should be inserted
+    into the resulting MRO. Unrelated ABCs are ignored and don't end up in the
+    result. The algorithm inserts ABCs where their functionality is introduced,
+    i.e. issubclass(cls, abc) returns True for the class itself but returns
+    False for all its direct base classes. Implicit ABCs for a given class
+    (either registered or inferred from the presence of a special method like
+    __len__) are inserted directly after the last ABC explicitly listed in the
+    MRO of said class. If two implicit ABCs end up next to each other in the
+    resulting MRO, their ordering depends on the order of types in *abcs*.
+
+    """
+    for i, base in enumerate(reversed(cls.__bases__)):
+        if hasattr(base, '__abstractmethods__'):
+            boundary = len(cls.__bases__) - i
+            break   # Bases up to the last explicit ABC are considered first.
+    else:
+        boundary = 0
+    abcs = list(abcs) if abcs else []
+    explicit_bases = list(cls.__bases__[:boundary])
+    abstract_bases = []
+    other_bases = list(cls.__bases__[boundary:])
+    for base in abcs:
+        if issubclass(cls, base) and not any(
+                issubclass(b, base) for b in cls.__bases__
+            ):
+            # If *cls* is the class that introduces behaviour described by
+            # an ABC *base*, insert said ABC to its MRO.
+            abstract_bases.append(base)
+    for base in abstract_bases:
+        abcs.remove(base)
+    explicit_c3_mros = [_c3_mro(base, abcs=abcs) for base in explicit_bases]
+    abstract_c3_mros = [_c3_mro(base, abcs=abcs) for base in abstract_bases]
+    other_c3_mros = [_c3_mro(base, abcs=abcs) for base in other_bases]
+    return _c3_merge(
+        [[cls]] +
+        explicit_c3_mros + abstract_c3_mros + other_c3_mros +
+        [explicit_bases] + [abstract_bases] + [other_bases]
+    )
+
+def _compose_mro(cls, types):
+    """Calculates the method resolution order for a given class *cls*.
+
+    Includes relevant abstract base classes (with their respective bases) from
+    the *types* iterable. Uses a modified C3 linearization algorithm.
+
+    """
+    bases = set(cls.__mro__)
+    # Remove entries which are already present in the __mro__ or unrelated.
+    def is_related(typ):
+        return (typ not in bases and hasattr(typ, '__mro__')
+                                 and not isinstance(typ, GenericAlias)
+                                 and issubclass(cls, typ))
+    types = [n for n in types if is_related(n)]
+    # Remove entries which are strict bases of other entries (they will end up
+    # in the MRO anyway.
+    def is_strict_base(typ):
+        for other in types:
+            if typ != other and typ in other.__mro__:
+                return True
+        return False
+    types = [n for n in types if not is_strict_base(n)]
+    # Subclasses of the ABCs in *types* which are also implemented by
+    # *cls* can be used to stabilize ABC ordering.
+    type_set = set(types)
+    mro = []
+    for typ in types:
+        found = []
+        for sub in typ.__subclasses__():
+            if sub not in bases and issubclass(cls, sub):
+                found.append([s for s in sub.__mro__ if s in type_set])
+        if not found:
+            mro.append(typ)
+            continue
+        # Favor subclasses with the biggest number of useful bases
+        found.sort(key=len, reverse=True)
+        for sub in found:
+            for subcls in sub:
+                if subcls not in mro:
+                    mro.append(subcls)
+    return _c3_mro(cls, abcs=mro)
+
+def _find_impl(cls, registry):
+    """Returns the best matching implementation from *registry* for type *cls*.
+
+    Where there is no registered implementation for a specific type, its method
+    resolution order is used to find a more generic implementation.
+
+    Note: if *registry* does not contain an implementation for the base
+    *object* type, this function may return None.
+
+    """
+    mro = _compose_mro(cls, registry.keys())
+    match = None
+    for t in mro:
+        if match is not None:
+            # If *match* is an implicit ABC but there is another unrelated,
+            # equally matching implicit ABC, refuse the temptation to guess.
+            if (t in registry and t not in cls.__mro__
+                              and match not in cls.__mro__
+                              and not issubclass(match, t)):
+                raise RuntimeError("Ambiguous dispatch: {} or {}".format(
+                    match, t))
+            break
+        if t in registry:
+            match = t
+    return registry.get(match)
+
+def singledispatch(func):
+    """Single-dispatch generic function decorator.
+
+    Transforms a function into a generic function, which can have different
+    behaviours depending upon the type of its first argument. The decorated
+    function acts as the default implementation, and additional
+    implementations can be registered using the register() attribute of the
+    generic function.
+    """
+    # There are many programs that use functools without singledispatch, so we
+    # trade-off making singledispatch marginally slower for the benefit of
+    # making start-up of such applications slightly faster.
+    import types, weakref
+
+    registry = {}
+    dispatch_cache = weakref.WeakKeyDictionary()
+    cache_token = None
+
+    def dispatch(cls):
+        """generic_func.dispatch(cls) -> <function implementation>
+
+        Runs the dispatch algorithm to return the best available implementation
+        for the given *cls* registered on *generic_func*.
+
+        """
+        nonlocal cache_token
+        if cache_token is not None:
+            current_token = get_cache_token()
+            if cache_token != current_token:
+                dispatch_cache.clear()
+                cache_token = current_token
+        try:
+            impl = dispatch_cache[cls]
+        except KeyError:
+            try:
+                impl = registry[cls]
+            except KeyError:
+                impl = _find_impl(cls, registry)
+            dispatch_cache[cls] = impl
+        return impl
+
+    def _is_union_type(cls):
+        from typing import get_origin, Union
+        return get_origin(cls) in {Union, types.UnionType}
+
+    def _is_valid_dispatch_type(cls):
+        if isinstance(cls, type):
+            return True
+        from typing import get_args
+        return (_is_union_type(cls) and
+                all(isinstance(arg, type) for arg in get_args(cls)))
+
+    def register(cls, func=None):
+        """generic_func.register(cls, func) -> func
+
+        Registers a new implementation for the given *cls* on a *generic_func*.
+
+        """
+        nonlocal cache_token
+        if _is_valid_dispatch_type(cls):
+            if func is None:
+                return lambda f: register(cls, f)
+        else:
+            if func is not None:
+                raise TypeError(
+                    f"Invalid first argument to `register()`. "
+                    f"{cls!r} is not a class or union type."
+                )
+            ann = getattr(cls, '__annotations__', {})
+            if not ann:
+                raise TypeError(
+                    f"Invalid first argument to `register()`: {cls!r}. "
+                    f"Use either `@register(some_class)` or plain `@register` "
+                    f"on an annotated function."
+                )
+            func = cls
+
+            # only import typing if annotation parsing is necessary
+            from typing import get_type_hints
+            argname, cls = next(iter(get_type_hints(func).items()))
+            if not _is_valid_dispatch_type(cls):
+                if _is_union_type(cls):
+                    raise TypeError(
+                        f"Invalid annotation for {argname!r}. "
+                        f"{cls!r} not all arguments are classes."
+                    )
+                else:
+                    raise TypeError(
+                        f"Invalid annotation for {argname!r}. "
+                        f"{cls!r} is not a class."
+                    )
+
+        if _is_union_type(cls):
+            from typing import get_args
+
+            for arg in get_args(cls):
+                registry[arg] = func
+        else:
+            registry[cls] = func
+        if cache_token is None and hasattr(cls, '__abstractmethods__'):
+            cache_token = get_cache_token()
+        dispatch_cache.clear()
+        return func
+
+    def wrapper(*args, **kw):
+        if not args:
+            raise TypeError(f'{funcname} requires at least '
+                            '1 positional argument')
+
+        return dispatch(args[0].__class__)(*args, **kw)
+
+    funcname = getattr(func, '__name__', 'singledispatch function')
+    registry[object] = func
+    wrapper.register = register
+    wrapper.dispatch = dispatch
+    wrapper.registry = types.MappingProxyType(registry)
+    wrapper._clear_cache = dispatch_cache.clear
+    update_wrapper(wrapper, func)
+    return wrapper
+
+
+# Descriptor version
+class singledispatchmethod:
+    """Single-dispatch generic method descriptor.
+
+    Supports wrapping existing descriptors and handles non-descriptor
+    callables as instance methods.
+    """
+
+    def __init__(self, func):
+        if not callable(func) and not hasattr(func, "__get__"):
+            raise TypeError(f"{func!r} is not callable or a descriptor")
+
+        self.dispatcher = singledispatch(func)
+        self.func = func
+
+    def register(self, cls, method=None):
+        """generic_method.register(cls, func) -> func
+
+        Registers a new implementation for the given *cls* on a *generic_method*.
+        """
+        return self.dispatcher.register(cls, func=method)
+
+    def __get__(self, obj, cls=None):
+        def _method(*args, **kwargs):
+            method = self.dispatcher.dispatch(args[0].__class__)
+            return method.__get__(obj, cls)(*args, **kwargs)
+
+        _method.__isabstractmethod__ = self.__isabstractmethod__
+        _method.register = self.register
+        update_wrapper(_method, self.func)
+        return _method
+
+    @property
+    def __isabstractmethod__(self):
+        return getattr(self.func, '__isabstractmethod__', False)
+
+
+################################################################################
+### cached_property() - property result cached as instance attribute
+################################################################################
+
+_NOT_FOUND = object()
+
+class cached_property:
+    def __init__(self, func):
+        self.func = func
+        self.attrname = None
+        self.__doc__ = func.__doc__
+
+    def __set_name__(self, owner, name):
+        if self.attrname is None:
+            self.attrname = name
+        elif name != self.attrname:
+            raise TypeError(
+                "Cannot assign the same cached_property to two different names "
+                f"({self.attrname!r} and {name!r})."
+            )
+
+    def __get__(self, instance, owner=None):
+        if instance is None:
+            return self
+        if self.attrname is None:
+            raise TypeError(
+                "Cannot use cached_property instance without calling __set_name__ on it.")
+        try:
+            cache = instance.__dict__
+        except AttributeError:  # not all objects have __dict__ (e.g. class defines slots)
+            msg = (
+                f"No '__dict__' attribute on {type(instance).__name__!r} "
+                f"instance to cache {self.attrname!r} property."
+            )
+            raise TypeError(msg) from None
+        val = cache.get(self.attrname, _NOT_FOUND)
+        if val is _NOT_FOUND:
+            val = self.func(instance)
+            try:
+                cache[self.attrname] = val
+            except TypeError:
+                msg = (
+                    f"The '__dict__' attribute on {type(instance).__name__!r} instance "
+                    f"does not support item assignment for caching {self.attrname!r} property."
+                )
+                raise TypeError(msg) from None
+        return val
+
+    __class_getitem__ = classmethod(GenericAlias)
+
+
+
+
+ + +
+
+ + Made with + Furo +
+ Last updated on 2024-05-12
+
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_modules/index.html b/_modules/index.html new file mode 100644 index 000000000..5d6d16ba1 --- /dev/null +++ b/_modules/index.html @@ -0,0 +1,376 @@ + + + + + + + + Overview: module code - Click Extra + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark mode + + + + + + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/_sources/changelog.md.txt b/_sources/changelog.md.txt new file mode 100644 index 000000000..2a989f6d4 --- /dev/null +++ b/_sources/changelog.md.txt @@ -0,0 +1,2 @@ +```{include} ../changelog.md +``` diff --git a/_sources/click_extra.rst.txt b/_sources/click_extra.rst.txt new file mode 100644 index 000000000..26fcc6874 --- /dev/null +++ b/_sources/click_extra.rst.txt @@ -0,0 +1,138 @@ +click\_extra package +==================== + +.. automodule:: click_extra + :members: + :undoc-members: + :show-inheritance: + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + click_extra.tests + +Submodules +---------- + +click\_extra.colorize module +---------------------------- + +.. automodule:: click_extra.colorize + :members: + :undoc-members: + :show-inheritance: + +click\_extra.commands module +---------------------------- + +.. automodule:: click_extra.commands + :members: + :undoc-members: + :show-inheritance: + +click\_extra.config module +-------------------------- + +.. automodule:: click_extra.config + :members: + :undoc-members: + :show-inheritance: + +click\_extra.decorators module +------------------------------ + +.. automodule:: click_extra.decorators + :members: + :undoc-members: + :show-inheritance: + +click\_extra.docs\_update module +-------------------------------- + +.. automodule:: click_extra.docs_update + :members: + :undoc-members: + :show-inheritance: + +click\_extra.logging module +--------------------------- + +.. automodule:: click_extra.logging + :members: + :undoc-members: + :show-inheritance: + +click\_extra.parameters module +------------------------------ + +.. automodule:: click_extra.parameters + :members: + :undoc-members: + :show-inheritance: + +click\_extra.platforms module +----------------------------- + +.. automodule:: click_extra.platforms + :members: + :undoc-members: + :show-inheritance: + +click\_extra.pygments module +---------------------------- + +.. automodule:: click_extra.pygments + :members: + :undoc-members: + :show-inheritance: + +click\_extra.sphinx module +-------------------------- + +.. automodule:: click_extra.sphinx + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tabulate module +---------------------------- + +.. automodule:: click_extra.tabulate + :members: + :undoc-members: + :show-inheritance: + +click\_extra.telemetry module +----------------------------- + +.. automodule:: click_extra.telemetry + :members: + :undoc-members: + :show-inheritance: + +click\_extra.testing module +--------------------------- + +.. automodule:: click_extra.testing + :members: + :undoc-members: + :show-inheritance: + +click\_extra.timer module +------------------------- + +.. automodule:: click_extra.timer + :members: + :undoc-members: + :show-inheritance: + +click\_extra.version module +--------------------------- + +.. automodule:: click_extra.version + :members: + :undoc-members: + :show-inheritance: diff --git a/_sources/click_extra.tests.rst.txt b/_sources/click_extra.tests.rst.txt new file mode 100644 index 000000000..cc9f27112 --- /dev/null +++ b/_sources/click_extra.tests.rst.txt @@ -0,0 +1,114 @@ +click\_extra.tests package +========================== + +.. automodule:: click_extra.tests + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +click\_extra.tests.conftest module +---------------------------------- + +.. automodule:: click_extra.tests.conftest + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_colorize module +---------------------------------------- + +.. automodule:: click_extra.tests.test_colorize + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_commands module +---------------------------------------- + +.. automodule:: click_extra.tests.test_commands + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_config module +-------------------------------------- + +.. automodule:: click_extra.tests.test_config + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_logging module +--------------------------------------- + +.. automodule:: click_extra.tests.test_logging + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_parameters module +------------------------------------------ + +.. automodule:: click_extra.tests.test_parameters + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_platforms module +----------------------------------------- + +.. automodule:: click_extra.tests.test_platforms + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_pygments module +---------------------------------------- + +.. automodule:: click_extra.tests.test_pygments + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_tabulate module +---------------------------------------- + +.. automodule:: click_extra.tests.test_tabulate + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_telemetry module +----------------------------------------- + +.. automodule:: click_extra.tests.test_telemetry + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_testing module +--------------------------------------- + +.. automodule:: click_extra.tests.test_testing + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_timer module +------------------------------------- + +.. automodule:: click_extra.tests.test_timer + :members: + :undoc-members: + :show-inheritance: + +click\_extra.tests.test\_version module +--------------------------------------- + +.. automodule:: click_extra.tests.test_version + :members: + :undoc-members: + :show-inheritance: diff --git a/_sources/code-of-conduct.md.txt b/_sources/code-of-conduct.md.txt new file mode 100644 index 000000000..aa2e2764c --- /dev/null +++ b/_sources/code-of-conduct.md.txt @@ -0,0 +1,7 @@ +# Code of conduct + +```{include} ../.github/code-of-conduct.md +--- +start-line: 2 +--- +``` diff --git a/_sources/colorize.md.txt b/_sources/colorize.md.txt new file mode 100644 index 000000000..29075e0c0 --- /dev/null +++ b/_sources/colorize.md.txt @@ -0,0 +1,148 @@ +# Colored help + +Extend +[Cloup's own help formatter and theme](https://cloup.readthedocs.io/en/stable/pages/formatting.html#help-formatting-and-themes) +to add colorization of: + +- Options + +- Choices + +- Metavars + +- Cli name + +- Sub-commands + +- Command aliases + +- Long and short options + +- Choices + +- Metavars + +- Environment variables + +- Defaults + +```{todo} +Write examples and tutorial. +``` + +## Why not use `rich-click`? + +[`rich-click`](https://github.com/ewels/rich-click) is a good project that aims to integrate [Rich](https://github.com/Textualize/rich) with Click. Like Click Extra, it provides a ready-to-use help formatter for Click. + +But contrary to Click Extra, the [help screen is rendered within a table](https://github.com/ewels/rich-click#styling), which takes the whole width of the terminal. This is not ideal if you try to print the output of a command somewhere else. + +The typical use-case is users reporting issues on GitHub, and pasting the output of a command in the issue description. If the output is too wide, it will be akwardly wrapped, or [adds a horizontal scrollbar](https://github.com/callowayproject/bump-my-version/pull/23#issuecomment-1602007874) to the page. + +Without a table imposing a maximal width, the help screens from Click Extra will be rendered with the minimal width of the text, and will be more readable. + +```{hint} +This is just a matter of preference, as nothing prevents you to use both `rich-click` and Click Extra in the same project, and get the best from both. +``` + +## `color_option` + +```{todo} +Write examples and tutorial. +``` + +## `help_option` + +```{todo} +Write examples and tutorial. +``` + +## Colors and styles + +Here is a little CLI to demonstrate the rendering of colors and styles, based on [`cloup.styling.Style`](https://cloup.readthedocs.io/en/stable/autoapi/cloup/styling/index.html#cloup.styling.Style): + +```{eval-rst} +.. click:example:: + from click import command + from click_extra import Color, style, Choice, option + from click_extra.tabulate import render_table + + all_styles = [ + "bold", + "dim", + "underline", + "overline", + "italic", + "blink", + "reverse", + "strikethrough", + ] + + all_colors = sorted(Color._dict.values()) + + @command + @option("--matrix", type=Choice(["colors", "styles"])) + def render_matrix(matrix): + table = [] + + if matrix == "colors": + table_headers = ["Foreground ↴ \ Background →"] + all_colors + for fg_color in all_colors: + line = [ + style(fg_color, fg=fg_color) + ] + for bg_color in all_colors: + line.append( + style(fg_color, fg=fg_color, bg=bg_color) + ) + table.append(line) + + elif matrix == "styles": + table_headers = ["Color ↴ \ Style →"] + all_styles + for color_name in all_colors: + line = [ + style(color_name, fg=color_name) + ] + for prop in all_styles: + line.append( + style(color_name, fg=color_name, **{prop: True}) + ) + table.append(line) + + render_table(table, headers=table_headers) + +.. click:run:: + result = invoke(render_matrix, ["--matrix=colors"]) + assert "\x1b[95mbright_magenta\x1b[0m" in result.stdout + assert "\x1b[95m\x1b[101mbright_magenta\x1b[0m" in result.stdout + +.. click:run:: + result = invoke(render_matrix, ["--matrix=styles"]) + assert "\x1b[97mbright_white\x1b[0m" in result.stdout + assert "\x1b[97m\x1b[1mbright_white\x1b[0m" in result.stdout + assert "\x1b[97m\x1b[2mbright_white\x1b[0m" in result.stdout + assert "\x1b[97m\x1b[4mbright_white\x1b[0m" in result.stdout +``` + +```{caution} +The current rendering of colors and styles in this HTML documentation is not complete, and does not reflect the real output in a terminal. + +That is because [`pygments-ansi-color`](https://github.com/chriskuehl/pygments-ansi-color), the component we rely on to render ANSI code in Sphinx via Pygments, [only supports a subset of the ANSI codes](https://github.com/chriskuehl/pygments-ansi-color/issues/31) we use. +``` + +```{tip} +The code above is presented as a CLI, so you can copy and run it yourself in your environment, and see the output in your terminal. That way you can evaluate the real effect of these styles and colors for your end users. +``` + +## `click_extra.colorize` API + +```{eval-rst} +.. autoclasstree:: click_extra.colorize + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.colorize + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/commands.md.txt b/_sources/commands.md.txt new file mode 100644 index 000000000..769c34236 --- /dev/null +++ b/_sources/commands.md.txt @@ -0,0 +1,536 @@ +# Commands & groups + +## Drop-in replacement + +Click Extra aims to be a drop-in replacement for Click. The vast majority of Click Extra's decorators, functions and classes are direct proxies of their Click counterparts. This means that you can replace, in your code, imports of the `click` namespace by `click_extra` and it will work as expected. + +Here is for instance the [canonical `click` example](https://github.com/pallets/click#a-simple-example) with all original imports replaced with `click_extra`: + +```{eval-rst} +.. click:example:: + from click_extra import command, echo, option + + @command + @option("--count", default=1, help="Number of greetings.") + @option("--name", prompt="Your name", help="The person to greet.") + def hello(count, name): + """Simple program that greets NAME for a total of COUNT times.""" + for _ in range(count): + echo(f"Hello, {name}!") + +As you can see the result does not deviates from the original Click-based output: + +.. click:run:: + from textwrap import dedent + result = invoke(hello, args=["--help"]) + assert result.output == dedent( + """\ + Usage: hello [OPTIONS] + + Simple program that greets NAME for a total of COUNT times. + + Options: + --count INTEGER Number of greetings. + --name TEXT The person to greet. + --help Show this message and exit. + """ + ) +``` + +```{note} Click and Cloup inheritance + +At the module level, `click_extra` imports all elements from `click.*`, then all elements from the `cloup.*` namespace. + +Which means all elements not redefined by Click Extra fallback to Cloup. And if Cloup itself does not redefine them, they fallback to Click. + +For example: +- `click_extra.echo` is a direct proxy to `click.echo` because Cloup does not re-implement an `echo` helper. +- On the other hand, `@click_extra.option` is a proxy of `@cloup.option`, because Cloup adds the [possibility for options to be grouped](https://cloup.readthedocs.io/en/stable/pages/option-groups.html). +- `@click_extra.timer` is not a proxy of anything, because it is a new decorator implemented by Click Extra. +- As for `@click_extra.extra_version_option`, it is a re-implementation of `@click.version_option`. Because it adds new features and breaks the original API, it was prefixed with `extra_` to become its own thing. And `@click_extra.version_option` still proxy the original from Click. + +Here are few other examples on how Click Extra proxies the main elements from Click and Cloup: + +| Click Extra element | Target | [Click's original](https://click.palletsprojects.com/en/8.1.x/api/) | +| ----------------------------- | --------------------- | ----------------------------------------------------------- | +| `@click_extra.command` | `@cloup.command` | `@click.command` | +| `@click_extra.group` | `@cloup.group` | `@click.group` | +| `@click_extra.argument` | `@cloup.argument` | `@click.argument` | +| `@click_extra.option` | `@cloup.option` | `@click.option` | +| `@click_extra.option_group` | `@cloup.option_group` | *Not implemented* | +| `@click_extra.pass_context` | `@click.pass_context` | `@click.pass_context` | +| `@click_extra.version_option` | `@click.version_option` | `@click.version_option` | +| `@click_extra.extra_version_option` | *Itself* | `@click.version_option` | +| `@click_extra.help_option` | *Itself* | `@click.help_option` | +| `@click_extra.timer_option` | *Itself* | *Not implemented* | +| … | … | … | +| `click_extra.Argument` | `cloup.Argument` | `click.Argument` | +| `click_extra.Command` | `cloup.Command` | `click.Command` | +| `click_extra.Group` | `cloup.Group` | `click.Group` | +| `click_extra.HelpFormatter` | `cloup.HelpFormatter` | `click.HelpFormatter` | +| `click_extra.HelpTheme` | `cloup.HelpThene` | *Not implemented* | +| `click_extra.Option` | `cloup.Option` | `click.Option` | +| `click_extra.ExtraVersionOption` | *Itself* | *Not implemented* | +| `click_extra.Style` | `cloup.Style` | *Not implemented* | +| `click_extra.echo` | `click.echo` | `click.echo` | +| `click_extra.ParameterSource` | `click.core.ParameterSource` | `click.core.ParameterSource` | +| … | … | … | + +You can inspect the implementation details by looking at: + + * [`click_extra.__init__`](https://github.com/kdeldycke/click-extra/blob/main/click_extra/__init__.py) + * [`cloup.__init__`](https://github.com/janluke/cloup/blob/master/cloup/__init__.py) + * [`click.__init__`](https://github.com/pallets/click/blob/main/src/click/__init__.py) +``` + +## Extra variants + +Now if you want to benefit from all the [wonderful features of Click Extra](index.md#features), you have to use the `extra`-prefixed variants: + +| [Original](https://click.palletsprojects.com/en/8.1.x/api/) | Extra variant | +| ----------------------------------------------------------- | ----------------------------------- | +| `@click.command` | `@click_extra.extra_command` | +| `@click.group` | `@click_extra.extra_group` | +| `click.Command` | `click_extra.ExtraCommand` | +| `click.Group` | `click_extra.ExtraGroup` | +| `click.Context` | `click_extra.ExtraContext` | +| `click.Option` | `click_extra.ExtraOption` | +| `@click.version_option` | `@click_extra.extra_version_option` | +| `click.testing.CliRunner` | `click_extra.ExtraCliRunner` | + +You can see how to use some of these `extra` variants in the [tutorial](tutorial.md). + +## Default options + +The `@extra_command` and `@extra_group` decorators are [pre-configured with a set of default options](commands.md#click_extra.commands.default_extra_params). + +### Remove default options + +You can remove all default options by resetting the `params` argument to `None`: + +```{eval-rst} +.. click:example:: + from click_extra import extra_command + + @extra_command(params=None) + def bare_cli(): + pass + +Which results in: + +.. click:run:: + from textwrap import dedent + result = invoke(bare_cli, args=["--help"]) + assert result.output == dedent( + """\ + \x1b[94m\x1b[1m\x1b[4mUsage:\x1b[0m \x1b[97mbare-cli\x1b[0m \x1b[36m\x1b[2m[OPTIONS]\x1b[0m + + \x1b[94m\x1b[1m\x1b[4mOptions:\x1b[0m + \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m Show this message and exit. + """ + ) +``` + +As you can see, all options are stripped out, but the colouring and formatting of the help message is preserved. + +### Change default options + +To override the default options, you can provide the `params=` argument to the command. But note how we use classes instead of option decorators: + +```{eval-rst} +.. click:example:: + from click_extra import extra_command, ConfigOption, VerbosityOption + + @extra_command( + params=[ + ConfigOption(default="ex.yml"), + VerbosityOption(default="DEBUG"), + ] + ) + def cli(): + pass + +And now you get: + +.. click:run:: + from textwrap import dedent + result = invoke(cli, args=["--help"]) + assert result.stdout.startswith(dedent( + """\ + \x1b[94m\x1b[1m\x1b[4mUsage:\x1b[0m \x1b[97mcli\x1b[0m \x1b[36m\x1b[2m[OPTIONS]\x1b[0m + + \x1b[94m\x1b[1m\x1b[4mOptions:\x1b[0m + \x1b[36m-C\x1b[0m, \x1b[36m--config\x1b[0m \x1b[36m\x1b[2mCONFIG_PATH\x1b[0m""" + )) +``` + +This let you replace the preset options by your own set, tweak their order and fine-tune their defaults. + +```{eval-rst} +.. caution:: Duplicate options + + If you try to add option decorators to a command which already have them by default, you will end up with duplicate entries ([as seen in issue #232](https://github.com/kdeldycke/click-extra/issues/232)): + + .. click:example:: + from click_extra import extra_command, extra_version_option + + @extra_command + @extra_version_option(version="0.1") + def cli(): + pass + + See how the ``--version`` option gets duplicated at the end: + + .. click:run:: + from textwrap import dedent + result = invoke(cli, args=["--help"]) + assert ( + " \x1b[36m--version\x1b[0m Show the version and exit.\n" + " \x1b[36m-h\x1b[0m, \x1b[36m--help\x1b[0m Show this message and exit.\n" + " \x1b[36m--version\x1b[0m Show the version and exit.\n" + ) in result.output + + This is by design: decorators are cumulative, to allow you to add your own options to the preset of `@extra_command` and `@extra_group`. +``` + +### Option order + +Notice how the options above are ordered in the help message. + +The default behavior of `@extra_command` (and its derivates decorators) is to order options in the way they are provided to the `params=` argument of the decorator. Then adds to that list the additional option decorators positioned after the `@extra_command` decorator. + +After that, there is a final [sorting step applied to options](https://kdeldycke.github.io/click-extra/commands.html#click_extra.commands.ExtraCommand). This is done by the `extra_option_at_end` option, which is `True` by default. + +### Option's defaults + +Because Click Extra commands and groups inherits from Click, you can [override the defaults the way Click allows you to](https://click.palletsprojects.com/en/8.1.x/commands/#context-defaults). Here is a reminder on how to do it. + +For example, the [`--verbosity` option defaults to the `WARNING` level](logging.md#click_extra.logging.DEFAULT_LEVEL_NAME). Now we'd like to change this default to `INFO`. + +If you manage your own `--verbosity` option, you can [pass the `default` argument to its decorator like we did above](#change-default-options): + +```python +from click_extra import command, verbosity_option + + +@command +@verbosity_option(default="INFO") +def cli(): + pass +``` + +This also works in its class form: + +```python +from click_extra import command, VerbosityOption + + +@command(params=[VerbosityOption(default="INFO")]) +def cli(): + pass +``` + +But you also have the alternative to pass a `default_map` via the `context_settings`: + +```{eval-rst} +.. click:example:: + from click_extra import extra_command + + @extra_command(context_settings={"default_map": {"verbosity": "INFO"}}) + def cli(): + pass + +Which results in ``[default: INFO]`` being featured in the help message: + +.. click:run:: + result = invoke(cli, args=["--help"]) + assert "\x1b[2m[\x1b[0m\x1b[2mdefault: \x1b[0m\x1b[32m\x1b[2m\x1b[3mINFO\x1b[0m\x1b[2m]\x1b[0m\n" in result.stdout +``` + +```{tip} +The advantage of the `context_settings` method we demonstrated last, is that it let you change the default of the `--verbosity` option provided by Click Extra, without having to [re-list the whole set of default options](#change-default-options). +``` + +## Third-party commands composition + +Click Extra is capable of composing with existing Click CLI in various situation. + +### Wrap other commands + +Click allows you to build up a hierarchy of command and subcommands. Click Extra inherits this behavior, which means we are free to assemble multiple third-party subcommands into a top-level one. + +For this example, let's imagine you are working for an operation team that is relying daily on a couple of CLIs. Like [`dbt`](https://github.com/dbt-labs/dbt-core) to manage your data workflows, and [`aws-sam-cli`](https://github.com/aws/aws-sam-cli) to deploy them in the cloud. + +For some practical reasons, you'd like to wrap all these commands into a big one. This is how to do it. + +````{note} +Here is how I initialized this example on my machine: + +```shell-session +$ git clone https://github.com/kdeldycke/click-extra +(...) + +$ cd click-extra +(...) + +$ poetry install +(...) + +$ poetry run python -m pip install dbt-core +(...) + +$ poetry run python -m pip install aws-sam-cli +(...) +``` + +That way I had the latest Click Extra, `dbt` and `aws-sam-cli` installed in the same virtual environment: + +```shell-session +$ poetry run dbt --version +Core: + - installed: 1.6.1 + - latest: 1.6.2 - Update available! + + Your version of dbt-core is out of date! + You can find instructions for upgrading here: + https://docs.getdbt.com/docs/installation + +Plugins: + + +``` + +```shell-session +$ poetry run sam --version +SAM CLI, version 1.97.0 +``` +```` + +Once you identified the entry points of each commands, you can easily wrap them into a top-level Click Extra CLI. Here is for instance the content of a `wrap.py` script: + +```python +from click_extra import extra_group + +from samcli.cli.main import cli as sam_cli +from dbt.cli.main import cli as dbt_cli + + +@extra_group +def main(): + pass + + +main.add_command(cmd=sam_cli, name="aws_sam") +main.add_command(cmd=dbt_cli, name="dbt") + + +if __name__ == "__main__": + main() +``` + +And this simple script gets rendered into: + +```shell-session +$ poetry run python ./wrap.py +Usage: wrap.py [OPTIONS] COMMAND [ARGS]... + +Options: + --time / --no-time Measure and print elapsed execution time. [default: + no-time] + --color, --ansi / --no-color, --no-ansi + Strip out all colors and all ANSI codes from output. + [default: color] + -C, --config CONFIG_PATH Location of the configuration file. Supports glob + pattern of local path and remote URL. [default: + ~/Library/Application + Support/wrap.py/*.{toml,yaml,yml,json,ini,xml}] + --show-params Show all CLI parameters, their provenance, defaults + and value, then exit. + -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG. + [default: WARNING] + --version Show the version and exit. + -h, --help Show this message and exit. + +Commands: + aws_sam AWS Serverless Application Model (SAM) CLI + dbt An ELT tool for managing your SQL transformations and data models. +``` + +Here you can see that the top-level CLI gets [all the default options and behavior (including coloring)](tutorial.md#all-bells-and-whistles) of `@extra_group`. But it also made available the standalone `aws_sam` and `dbt` CLI as standard subcommands. + +And they are perfectly functional as-is. + +You can compare the output of the `aws_sam` subcommand with its original one: + +`````{tab-set} +````{tab-item} aws_sam subcommand in wrap.py +```shell-session +$ poetry run python ./wrap.py aws_sam --help +Usage: wrap.py aws_sam [OPTIONS] COMMAND [ARGS]... + + AWS Serverless Application Model (SAM) CLI + + The AWS Serverless Application Model Command Line Interface (AWS SAM CLI) is + a command line tool that you can use with AWS SAM templates and supported + third-party integrations to build and run your serverless applications. + + Learn more: https://docs.aws.amazon.com/serverless-application-model/ + +Commands: + + Learn: + docs NEW! Launch the AWS SAM CLI documentation in a browser. + + Create an App: + init Initialize an AWS SAM application. + + Develop your App: + build Build your AWS serverless function code. + local Run your AWS serverless function locally. + validate Validate an AWS SAM template. + sync NEW! Sync an AWS SAM project to AWS. + remote NEW! Invoke or send an event to cloud resources in your AWS + Cloudformation stack. + + Deploy your App: + package Package an AWS SAM application. + deploy Deploy an AWS SAM application. + + Monitor your App: + logs Fetch AWS Cloudwatch logs for AWS Lambda Functions or + Cloudwatch Log groups. + traces Fetch AWS X-Ray traces. + + And More: + list NEW! Fetch the state of your AWS serverless application. + delete Delete an AWS SAM application and the artifacts created + by sam deploy. + pipeline Manage the continuous delivery of your AWS serverless + application. + publish Publish a packaged AWS SAM template to AWS Serverless + Application Repository for easy sharing. + +Options: + + --beta-features / --no-beta-features + Enable/Disable beta features. + --debug Turn on debug logging to print debug message + generated by AWS SAM CLI and display + timestamps. + --version Show the version and exit. + --info Show system and dependencies information. + -h, --help Show this message and exit. + +Examples: + + Get Started: $wrap.py aws_sam init +``` +```` + +````{tab-item} Vanilla sam CLI +```shell-session +$ poetry run sam --help +Usage: sam [OPTIONS] COMMAND [ARGS]... + + AWS Serverless Application Model (SAM) CLI + + The AWS Serverless Application Model Command Line Interface (AWS SAM CLI) is + a command line tool that you can use with AWS SAM templates and supported + third-party integrations to build and run your serverless applications. + + Learn more: https://docs.aws.amazon.com/serverless-application-model/ + +Commands: + + Learn: + docs NEW! Launch the AWS SAM CLI documentation in a browser. + + Create an App: + init Initialize an AWS SAM application. + + Develop your App: + build Build your AWS serverless function code. + local Run your AWS serverless function locally. + validate Validate an AWS SAM template. + sync NEW! Sync an AWS SAM project to AWS. + remote NEW! Invoke or send an event to cloud resources in your AWS + Cloudformation stack. + + Deploy your App: + package Package an AWS SAM application. + deploy Deploy an AWS SAM application. + + Monitor your App: + logs Fetch AWS Cloudwatch logs for AWS Lambda Functions or + Cloudwatch Log groups. + traces Fetch AWS X-Ray traces. + + And More: + list NEW! Fetch the state of your AWS serverless application. + delete Delete an AWS SAM application and the artifacts created + by sam deploy. + pipeline Manage the continuous delivery of your AWS serverless + application. + publish Publish a packaged AWS SAM template to AWS Serverless + Application Repository for easy sharing. + +Options: + + --beta-features / --no-beta-features + Enable/Disable beta features. + --debug Turn on debug logging to print debug message + generated by AWS SAM CLI and display + timestamps. + --version Show the version and exit. + --info Show system and dependencies information. + -h, --help Show this message and exit. + +Examples: + + Get Started: $sam init +``` +```` +````` + +Here is the highlighted differences to make them even more obvious: + +```diff +@@ -1,5 +1,5 @@ +-$ poetry run python ./wrap.py aws_sam --help +-Usage: wrap.py aws_sam [OPTIONS] COMMAND [ARGS]... ++$ poetry run sam --help ++Usage: sam [OPTIONS] COMMAND [ARGS]... + + AWS Serverless Application Model (SAM) CLI + +@@ -56,4 +56,4 @@ + + Examples: + +- Get Started: $wrap.py aws_sam init ++ Get Started: $sam init +``` + +Now that all commands are under the same umbrella, there is no limit to your imagination! + +```{important} +This might looks janky, but this franken-CLI might be a great way to solve practical problems in your situation. + +You can augment them with your custom glue code. Or maybe mashing them up will simplify the re-distribution of these CLIs on your production machines. Or control their common dependencies. Or freeze their versions. Or hard-code some parameters. Or apply monkey-patches. Or chain these commands to create new kind of automation... + +There is a miriad of possibilities. If you have some other examples in the same vein, please share them in an issue or even directly via a PR. I'd love to complement this documentation with creative use-cases. +``` + +## `click_extra.commands` API + +```{eval-rst} +.. autoclasstree:: click_extra.commands + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.commands + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/config.md.txt b/_sources/config.md.txt new file mode 100644 index 000000000..7ed95e799 --- /dev/null +++ b/_sources/config.md.txt @@ -0,0 +1,509 @@ +# Configuration + +The structure of the configuration file is automatically [derived from the +parameters](parameters.md#parameter-structure) of the CLI and their types. There is no need to manually produce a configuration +data structure to mirror the CLI. + +## Standalone option + +The `@config_option` decorator provided by Click Extra can be used as-is with vanilla Click: + +```{eval-rst} +.. click:example:: + from click import group, option, echo + + from click_extra import config_option + + @group(context_settings={"show_default": True}) + @option("--dummy-flag/--no-flag") + @option("--my-list", multiple=True) + @config_option + def my_cli(dummy_flag, my_list): + echo(f"dummy_flag is {dummy_flag!r}") + echo(f"my_list is {my_list!r}") + + @my_cli.command + @option("--int-param", type=int, default=10) + def subcommand(int_param): + echo(f"int_parameter is {int_param!r}") + +The code above is saved into a file named ``my_cli.py``. + +It produces the following help screen: + +.. click:run:: + result = invoke(my_cli, args=["--help"]) + assert "-C, --config CONFIG_PATH" in result.stdout + +See there the explicit mention of the default location of the configuration file. This improves discoverability, and `makes sysadmins happy `_, especially those not familiar with your CLI. + +A bare call returns: + +.. click:run:: + from textwrap import dedent + result = invoke(my_cli, args=["subcommand"]) + assert result.stdout == dedent("""\ + dummy_flag is False + my_list is () + int_parameter is 10 + """ + ) +``` + +With a simple TOML file in the application folder, we will change the CLI's default output. + +Here is what `~/.config/my-cli/config.toml` contains: + +```toml +# My default configuration file. +top_level_param = "is_ignored" + +[my-cli] +extra_value = "is ignored too" +dummy_flag = true # New boolean default. +my_list = ["item 1", "item #2", "Very Last Item!"] + +[garbage] +# An empty random section that will be skipped. + +[my-cli.subcommand] +int_param = 3 +random_stuff = "will be ignored" +``` + +In the file above, pay attention to: + +- the [default configuration base path](#default-folder) (`~/.config/my-cli/` here on Linux) which is OS-dependant; +- the app's folder (`/my-cli/`) which is built from the script's + name (`my_cli.py`); +- the top-level config section (`[my-cli]`), based on the CLI's + group ID (`def my_cli()`); +- all the extra comments, sections and values that will be silently ignored. + +Now we can verify the configuration file is properly read and change the defaults: + +```shell-session +$ my-cli subcommand +dummy_flag is True +my_list is ('item 1', 'item #2', 'Very Last Item!') +int_parameter is 3 +``` + +## Precedence + +The configuration loader fetch values according the following precedence: + +- `CLI parameters` + - ↖ `Configuration file` + - ↖ `Environment variables` + - ↖ `Defaults` + +The parameter will take the first value set in that chain. + +See how inline parameters takes priority on defaults from the previous example: + +```{code-block} shell-session +--- +emphasize-lines: 1, 4 +--- +$ my-cli subcommand --int-param 555 +dummy_flag is True +my_list is ('item 1', 'item #2', 'Very Last Item!') +int_parameter is 555 +``` + +## Get configuration values + +After gathering all the configuration from the different sources, and assembling them together following the precedence rules above, the configuration values are merged back into the Context's `default_map`. But only the values that are matching the CLI's parameters are kept and passed as defaults. All others are silently ignored. + +You can still access the full configuration by looking into the context's `meta` attribute: + +```python +from click_extra import option, echo, pass_context, command, config_option + + +@command +@option("--int-param", type=int, default=10) +@config_option +@pass_context +def my_cli(ctx, int_param): + echo(f"Configuration location: {ctx.meta['click_extra.conf_source']}") + echo(f"Full configuration: {ctx.meta['click_extra.conf_full']}") + echo(f"Default values: {ctx.default_map}") + echo(f"int_param is {int_param!r}") +``` + +```toml +[my-cli] +int_param = 3 +random_stuff = "will be ignored" + +[garbage] +dummy_flag = true +``` + +```shell-session +$ my-cli --config ./conf.toml --int-param 999 +Load configuration matching ./conf.toml +Configuration location: /home/me/conf.toml +Full configuration: {'my-cli': {'int_param': 3, 'random_stuff': 'will be ignored'}, 'garbage': {'dummy_flag': True}} +Default values: {'int_param': 3} +int_parameter is 999 +``` + +```{hint} +Variables in `meta` are presented in their original Python type: +- `click_extra.conf_source` is either a normalized [`Path`](https://docs.python.org/3/library/pathlib.html) or [`URL` object](https://boltons.readthedocs.io/en/latest/urlutils.html#the-url-type) +- `click_extra.conf_full` is a `dict` whose values are either `str` or richer types, depending on the capabilities of [each format](#formats) +``` + +## Strictness + +As you can see [in the first example above](#standalone-option), all unrecognized content is ignored. + +If for any reason you do not want to allow any garbage in configuration files provided by the user, you can use the `strict` argument. + +Given this `cli.toml` file: + +```{code-block} toml +--- +emphasize-lines: 3 +--- +[cli] +int_param = 3 +random_param = "forbidden" +``` + +The use of `strict=True` parameter in the CLI below: + +```{code-block} python +--- +emphasize-lines: 7 +--- +from click import command, option, echo + +from click_extra import config_option + +@command +@option("--int-param", type=int, default=10) +@config_option(strict=True) +def cli(int_param): + echo(f"int_parameter is {int_param!r}") +``` + +Will raise an error and stop the CLI execution on unrecognized `random_param` value: + +```{code-block} shell-session +--- +emphasize-lines: 4 +--- +$ cli --config "cli.toml" +Load configuration matching cli.toml +(...) +ValueError: Parameter 'random_param' is not allowed in configuration file. +``` + +## Excluding parameters + +The {py:attr}`excluded_params ` argument allows you to block some of your CLI options to be loaded from configuration. By setting this argument, you will prevent your CLI users to set these parameters in their configuration file. + +It {py:attr}`defaults to the value of ParamStructure.DEFAULT_EXCLUDED_PARAMS `. + +You can set your own list of option to ignore with the `excluded_params` argument: + +```{code-block} python +--- +emphasize-lines: 7 +--- +from click import command, option, echo + +from click_extra import config_option + +@command +@option("--int-param", type=int, default=10) +@config_option(excluded_params=["my-cli.non_configurable_option", "my-cli.dangerous_param"]) +def my_cli(int_param): + echo(f"int_parameter is {int_param!r}") +``` + +```{attention} +You need to provide the fully-qualified ID of the option you're looking to block. I.e. the dot-separated ID that is prefixed by the CLI name. That way you can specify an option to ignore at any level, including subcommands. + +If you have difficulties identifying your options and their IDs, run your CLI with the [`--show-params` option](#show-params-option) for introspection. +``` + +## Formats + +Several dialects are supported: + +- [`TOML`](#toml) +- [`YAML`](#yaml) +- [`JSON`](#json), with inline and block comments (Python-style `#` and Javascript-style `//`, thanks to [`commentjson`](https://github.com/vaidik/commentjson)) +- [`INI`](#ini), with extended interpolation, multi-level sections and non-native types (`list`, `set`, …) +- [`XML`](#xml) + +### TOML + +See the [example in the top of this page](#standalone-option). + +### YAML + +The example above, given for a TOML configuration file, is working as-is with YAML. + +Just replace the TOML file with the following configuration at +`~/.config/my-cli/config.yaml`: + +```yaml +# My default configuration file. +top_level_param: is_ignored + +my-cli: + extra_value: is ignored too + dummy_flag: true # New boolean default. + my_list: + - point 1 + - 'point #2' + - Very Last Point! + + subcommand: + int_param: 77 + random_stuff: will be ignored + +garbage: > + An empty random section that will be skipped +``` + +```shell-session +$ my-cli --config "~/.config/my-cli/config.yaml" subcommand +dummy_flag is True +my_list is ('point 1', 'point #2', 'Very Last Point!') +int_parameter is 77 +``` + +### JSON + +Again, same for JSON: + +```json +{ + "top_level_param": "is_ignored", + "garbage": {}, + "my-cli": { + "dummy_flag": true, + "extra_value": "is ignored too", + "my_list": [ + "item 1", + "item #2", + "Very Last Item!" + ], + "subcommand": { + "int_param": 65, + "random_stuff": "will be ignored" + } + } +} +``` + +```shell-session +$ my-cli --config "~/.config/my-cli/config.json" subcommand +dummy_flag is True +my_list is ('item 1', 'item #2', 'Very Last Item!') +int_parameter is 65 +``` + +### INI + +`INI` configuration files are allowed to use [`ExtendedInterpolation`](https://docs.python.org/3/library/configparser.html?highlight=configparser#configparser.ExtendedInterpolation) by default. + +```{todo} +Write example. +``` + +### XML + +```{todo} +Write example. +``` + +## Pattern matching + +The configuration file is searched based on a wildcard-based pattern. + +By default, the pattern is `//*.{toml,yaml,yml,json,ini,xml}`, where: + +- `` is the [default application folder](#default-folder) (see section below) +- `*.{toml,yaml,yml,json,ini,xml}` is any file in that folder with any of `.toml`, `.yaml`, `.yml`, `.json` , `.ini` or `.xml` extension. + +```{seealso} +There is a long history about the choice of the default application folder. + +For Unix, the oldest reference I can track is from the [*Where Configurations Live* chapter](http://www.catb.org/~esr/writings/taoup/html/ch10s02.html) +of [The Art of Unix Programming](https://www.amazon.com/dp/0131429019?&linkCode=ll1&tag=kevideld-20&linkId=49054395b39ea5b23bdf912ff839bca2&language=en_US&ref_=as_li_ss_tl) by Eric S. Raymond. + +The [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) is the latest iteration of this tradition on Linux. This long-due guidelines brings [lots of benefits](https://xdgbasedirectoryspecification.com) to the platform. This is what Click Extra is [implementing by default](#default-folder). + +But there is still a lot of cases for which the XDG doesn't cut it, like on other platforms (macOS, Windows, …) or for legacy applications. That's why Click Extra allows you to customize the way configuration is searched and located. +``` + +### Default folder + +The configuration file is searched in the default application path, as defined by [`click.get_app_dir()`](https://click.palletsprojects.com/en/8.1.x/api/#click.get_app_dir). + +Like the latter, the `@config_option` decorator and `ConfigOption` class accept a `roaming` and `force_posix` argument to alter the default path: + +| Platform | `roaming` | `force_posix` | Folder | +| :---------------- | :-------- | :------------ | :---------------------------------------- | +| macOS (default) | - | `False` | `~/Library/Application Support/Foo Bar` | +| macOS | - | `True` | `~/.foo-bar` | +| Unix (default) | - | `False` | `~/.config/foo-bar` | +| Unix | - | `True` | `~/.foo-bar` | +| Windows (default) | `True` | - | `C:\Users\\AppData\Roaming\Foo Bar` | +| Windows | `False` | - | `C:\Users\\AppData\Local\Foo Bar` | + +Let's change the default base folder in the following example: + +```{eval-rst} +.. click:example:: + from click import command + + from click_extra import config_option + + @command(context_settings={"show_default": True}) + @config_option(force_posix=True) + def cli(): + pass + +See how the default to ``--config`` option has been changed to ``~/.cli/*.{toml,yaml,yml,json,ini,xml}``: + +.. click:run:: + result = invoke(cli, args=["--help"]) + assert "~/.cli/*.{toml,yaml,yml,json,ini,xml}]" in result.stdout +``` + +### Custom pattern + +If you'd like to customize the pattern, you can pass your own to the `default` parameter. + +Here is how to look for an extension-less YAML dotfile in the home directory, with a pre-defined `.commandrc` name: + +```{eval-rst} +.. click:example:: + from click import command + + from click_extra import config_option + from click_extra.config import Formats + + @command(context_settings={"show_default": True}) + @config_option(default="~/.commandrc", formats=Formats.YAML) + def cli(): + pass + +.. click:run:: + result = invoke(cli, args=["--help"]) + assert "~/.commandrc]" in result.stdout +``` + +### Pattern specifications + +Patterns provided to `@config_option`: + +- are [based on `wcmatch.glob` syntax](https://facelessuser.github.io/wcmatch/glob/#syntax) +- should be written with Unix separators (`/`), even for Windows (the [pattern will be normalized to the local platform dialect](https://facelessuser.github.io/wcmatch/glob/#windows-separators)) +- are configured with the following default flags: + - [`IGNORECASE`](https://facelessuser.github.io/wcmatch/glob/#ignorecase): case-insensitive matching + - [`GLOBSTAR`](https://facelessuser.github.io/wcmatch/glob/#globstar): recursive directory search via `**` + - [`FOLLOW`](https://facelessuser.github.io/wcmatch/glob/#follow): traverse symlink directories + - [`DOTGLOB`](https://facelessuser.github.io/wcmatch/glob/#dotglob): allow match of file or directory starting with a dot (`.`) + - [`BRACE`](https://facelessuser.github.io/wcmatch/glob/#brace): allow brace expansion for greater expressiveness + - [`GLOBTILDE`](https://facelessuser.github.io/wcmatch/glob/#globtilde): allows for user path expansion via `~` + - [`NODIR`](https://facelessuser.github.io/wcmatch/glob/#nodir): restricts results to files + +### Default extensions + +The extensions that are used for each dialect to produce the default file pattern matching are encoded by +the {py:class}`Formats ` Enum: + +| Format | Extensions | +| :----- | :---------------- | +| `TOML` | `*.toml` | +| `YAML` | `*.yaml`, `*.yml` | +| `JSON` | `*.json` | +| `INI` | `*.ini` | +| `XML` | `*.xml` | + +### Multi-format matching + +The default behavior consist in searching for all files matching the default `*.{toml,yaml,yml,json,ini,xml}` pattern. + +A parsing attempt is made for each file matching the extension pattern, in the order of the table above. + +As soon as a file is able to be parsed without error and returns a `dict`, the search stops and the file is used to feed the CLI's default values. + +### Forcing formats + +If you know in advance the only format you'd like to support, you can use the `formats` argument on your decorator like so: + +```{eval-rst} +.. click:example:: + from click import command, option, echo + + from click_extra import config_option + from click_extra.config import Formats + + @command(context_settings={"show_default": True}) + @option("--int-param", type=int, default=10) + @config_option(formats=Formats.JSON) + def cli(int_param): + echo(f"int_parameter is {int_param!r}") + +Notice how the default search pattern gets limited to files with a ``.json`` extension: + +.. click:run:: + result = invoke(cli, args=["--help"]) + assert "*.json]" in result.stdout +``` + +This also works with a subset of formats: + +```{eval-rst} +.. click:example:: + from click import command, option, echo + + from click_extra import config_option + from click_extra.config import Formats + + @command(context_settings={"show_default": True}) + @option("--int-param", type=int, default=10) + @config_option(formats=[Formats.INI, Formats.YAML]) + def cli(int_param): + echo(f"int_parameter is {int_param!r}") + +.. click:run:: + result = invoke(cli, args=["--help"]) + assert "*.{ini,yaml,yml}]" in result.stdout +``` + +### Remote URL + +Remote URL can be passed directly to the `--config` option: + +```shell-session +$ my-cli --config "https://example.com/dummy/configuration.yaml" subcommand +dummy_flag is True +my_list is ('point 1', 'point #2', 'Very Last Point!') +int_parameter is 77 +``` + +## `click_extra.config` API + +```{eval-rst} +.. autoclasstree:: click_extra.config + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.config + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/index.md.txt b/_sources/index.md.txt new file mode 100644 index 000000000..6042631a7 --- /dev/null +++ b/_sources/index.md.txt @@ -0,0 +1,45 @@ +--- +hide-toc: true +--- + +```{include} ../readme.md +``` + +```{toctree} +--- +maxdepth: 2 +hidden: +--- +install +tutorial +commands +config +colorize +logging +tabulate +version +timer +platforms +testing +parameters +pygments +sphinx +issues +``` + +```{toctree} +--- +caption: Development +maxdepth: 2 +hidden: +--- +API +genindex +modindex +todolist +changelog +code-of-conduct +license +GitHub Repository +Funding +``` diff --git a/_sources/install.md.txt b/_sources/install.md.txt new file mode 100644 index 000000000..4cc15411b --- /dev/null +++ b/_sources/install.md.txt @@ -0,0 +1,24 @@ +# Installation + +## With `pip` + +This package is +[available on PyPi](https://pypi.python.org/pypi/click-extra), so you +can install the latest stable release and its dependencies with a simple `pip` +call: + +```shell-session +$ pip install click-extra +``` + +See also +[pip installation instructions](https://pip.pypa.io/en/stable/installing/). + +## Python dependencies + +FYI, here is a graph of Python package dependencies: + +```mermaid assets/dependencies.mmd +:align: center +:zoom: +``` diff --git a/_sources/issues.md.txt b/_sources/issues.md.txt new file mode 100644 index 000000000..7e229a2f8 --- /dev/null +++ b/_sources/issues.md.txt @@ -0,0 +1,104 @@ +# Fixed issues + +Click Extra was basically born as a collection of patches for unmaintained or slow moving [`click-contrib` addons](https://github.com/click-contrib). + +Here is the list of issues and bugs from other projects that `click-extra` has addressed over the years. + +## [`configmanager`](https://github.com/jbasko/configmanager) + +- [`#164` - Should be used?](https://github.com/jbasko/configmanager/issues/164) +- [`#152` - XML format](https://github.com/jbasko/configmanager/issues/152) +- [`#139` - TOML format](https://github.com/jbasko/configmanager/issues/139) + +## [`click`](https://github.com/pallets/click) + +- [`#2680` - Fix closing of callbacks on CLI exit](https://github.com/pallets/click/pull/2680) +- [`#2523` - Keep track of `` and `` mix in `CliRunner` results ](https://github.com/pallets/click/pull/2523) +- [`#2522` - `CliRunner`: restrict `mix_stderr` influence to ``; keep `` and `` stable](https://github.com/pallets/click/issues/2522) +- [`#2483` - Missing auto-generated environment variables in help screen & case-sensitivity](https://github.com/pallets/click/issues/2483) +- [`#2331` - `version_option` module name and package name are not equivalent](https://github.com/pallets/click/issues/2331) +- [`#2324` - Can't pass `click.version_option()` to `click.MultiCommand(params=)`](https://github.com/pallets/click/issues/2324) +- [`#2313` - Add `show_envvar` as global setting via `context_settings` (like `show_default`)](https://github.com/pallets/click/issues/2313) +- [`#2207` - Support `NO_COLOR` environment variable](https://github.com/pallets/click/issues/2207) +- [`#2111` - `Context.color = False` doesn't overrides `echo(color=True)`](https://github.com/pallets/click/issues/2111) +- [`#2110` - `testing.CliRunner.invoke` cannot pass color for `Context` instantiation](https://github.com/pallets/click/issues/2110) +- [`#1756` - Path and Python version for version message formatting](https://github.com/pallets/click/issues/1756) +- [`#1498` - Support for `NO_COLOR` proposal](https://github.com/pallets/click/issues/1498) +- [`#1279` - Provide access to a normalized list of args](https://github.com/pallets/click/issues/1279) +- [`#1090` - Color output from CI jobs](https://github.com/pallets/click/issues/1090) +- [`#558` - Support `FORCE_COLOR` in `click.echo`](https://github.com/pallets/click/issues/558) + +## [`click-config-file`](https://github.com/phha/click_config_file) + +- [`#26` - Don't require `FILE` for `--config` when `implicit=False`?](https://github.com/phha/click_config_file/issues/26) +- [`#11` - Warn when providing unsupported options in the config file?](https://github.com/phha/click_config_file/issues/11) +- [`#9` - Additional configuration providers](https://github.com/phha/click_config_file/issues/9) + +## [`click-configfile`](https://github.com/click-contrib/click-configfile) + +- [`#9` - Inquiry on Repo Status](https://github.com/click-contrib/click-configfile/issues/9) +- [`#8` - Exclude some options from config file](https://github.com/click-contrib/click-configfile/issues/8) +- [`#5` - Interpolation](https://github.com/click-contrib/click-configfile/issues/5) +- [`#2` - Order of configuration file and environment variable value](https://github.com/click-contrib/click-configfile/issues/2) + +## [`click-help-color`](https://github.com/click-contrib/click-help-colors) + +- [`#17` - Highlighting of options, choices and metavars](https://github.com/click-contrib/click-help-colors/issues/17) + +## [`click-log`](https://github.com/click-contrib/click-log) + +- [`#30` - Add a `no-color` option, method or parameter to disable coloring globally](https://github.com/click-contrib/click-log/issues/30) +- [`#29` - Log level is leaking between invocations: hack to force-reset it](https://github.com/click-contrib/click-log/issues/29) +- [`#24` - Add missing string interpolation in error message](https://github.com/click-contrib/click-log/pull/24) +- [`#18` - Add trailing dot to help text](https://github.com/click-contrib/click-log/pull/18) + +## [`cli-helper`](https://github.com/dbcli/cli_helpers) + +- [`#79` - Replace local tabulate formats with those available upstream](https://github.com/dbcli/cli_helpers/issues/79) + +## [`cloup`](https://github.com/janluke/cloup) + +- [`#127` - Optional parenthesis for `@command` and `@option`](https://github.com/janluke/cloup/issues/127) +- [`#98` - Add support for option groups on `cloup.Group`](https://github.com/janluke/cloup/issues/98) +- [`#97` - Styling metavars, default values, env var, choices](https://github.com/janluke/cloup/issues/97) +- [`#96` - Add loading of options from a TOML configuration file](https://github.com/janluke/cloup/issues/96) +- [`#95` - Highlights options, choices and metavars](https://github.com/janluke/cloup/issues/95) +- [`#92` - Use sphinx directive to generate output of full examples in the docs](https://github.com/janluke/cloup/issues/92) + +## [`distro`](https://github.com/python-distro/distro) + +- [`#177` - Support for Windows and Mac OS](https://github.com/python-distro/distro/issues/177) + +## [`kitty`](https://github.com/kovidgoyal/kitty) + +- [`#5482` - ANSI shell sessions in Sphinx documentation](https://github.com/kovidgoyal/kitty/discussions/5482) + +## [`pallets-sphinx-themes`](https://github.com/pallets/pallets-sphinx-themes) + +- [`#62` - Render `.. click:run::` code blocks with `shell-session` lexer](https://github.com/pallets/pallets-sphinx-themes/pull/62) +- [`#61` - Move `.. click:example::` and `.. click:run::` implementation to `sphinx-click`](https://github.com/pallets/pallets-sphinx-themes/issues/61) + +## [`pygments`](https://github.com/pygments/pygments) + +- [`#1148` - Can't format console/shell-session output that includes ANSI colors](https://github.com/pygments/pygments/issues/1148) +- [`#477` - Support ANSI (ECMA-48) color-coded text input](https://github.com/pygments/pygments/issues/477) + +## [`python-tabulate`](https://github.com/astanin/python-tabulate) + +- [`#261` - Add support for alignments in Markdown tables](https://github.com/astanin/python-tabulate/pull/261) +- [`#260` - Renders GitHub-Flavored Markdown tables in canonical format](https://github.com/astanin/python-tabulate/pull/260) +- [`#151` - Add new {`rounded`,`simple`,`double`}\_(`grid`,`outline`} formats](https://github.com/astanin/python-tabulate/pull/151) + +## [`rich-click`](https://github.com/ewels/rich-click) + +- [`#101` - Command Aliases?](https://github.com/ewels/rich-click/issues/101) +- [`#18` - Options inherited from context settings aren't applied](https://github.com/ewels/rich-click/issues/18) + +## [`sphinx-click`](https://github.com/click-contrib/sphinx-click) + +- [`#117` - Add reference to complementary Click CLI documentation helpers](https://github.com/click-contrib/sphinx-click/pull/117) +- [`#110` - Supports `.. click:example::` and `.. click:run::` directives](https://github.com/click-contrib/sphinx-click/issues/110) + +## [`sphinx-contrib/ansi`](https://github.com/sphinx-contrib/ansi) + +- [`#9` - ANSI Codes in output](https://github.com/sphinx-contrib/ansi/issues/9) diff --git a/_sources/license.md.txt b/_sources/license.md.txt new file mode 100644 index 000000000..9a714e508 --- /dev/null +++ b/_sources/license.md.txt @@ -0,0 +1,17 @@ +# License + +This software is licensed under the +[GNU General Public License v2 or later (GPLv2+)](https://github.com/kdeldycke/click-extra/blob/main/license). + +```{literalinclude} ../license +``` + +## Additional credits + +The following images are sourced from [Open Clipart](https://openclipart.org) +which are distributed under a +[Creative Commons Zero 1.0 Public Domain License](http://creativecommons.org/publicdomain/zero/1.0/): + +- [cube logo](https://github.com/kdeldycke/click-extra/blob/main/docs/assets/logo-banner.svg) + is based on a modified + [Prismatic Isometric Cube Extra Pattern](https://openclipart.org/detail/266153/prismatic-isometric-cube-extra-pattern-no-background) diff --git a/_sources/logging.md.txt b/_sources/logging.md.txt new file mode 100644 index 000000000..566c2474b --- /dev/null +++ b/_sources/logging.md.txt @@ -0,0 +1,315 @@ +# Logging + +## Colored verbosity option + +Click Extra provides a pre-configured option which adds a `--verbosity`/`-v` flag to your CLI. It allow users of your CLI to set the log level of a [`logging.Logger` instance](https://docs.python.org/3/library/logging.html#logger-objects). + +### Integrated extra option + +This option is added by default to `@extra_command` and `@extra_group`: + +```{eval-rst} +.. click:example:: + from click_extra import extra_command, echo + + @extra_command + def my_cli(): + echo("It works!") + +See the default ``--verbosity``/``-v`` option in the help screen: + +.. click:run:: + result = invoke(my_cli, args=["--help"]) + assert "--verbosity" in result.stdout, "missing --verbosity option" + +Which can be invoked to display all the gory details of your CLI with the ``DEBUG`` level: + +.. click:run:: + result = invoke(my_cli, args=["--verbosity", "DEBUG"]) + assert "Set to DEBUG." in result.stderr, "missing DEBUG message" + assert "Set to DEBUG." in result.stderr, "missing DEBUG message" +``` + +### Standalone option + +The verbosity option can be used independently of `@extra_command`, and you can attach it to a vanilla commands: + +```{eval-rst} +.. click:example:: + import logging + from click import command, echo + from click_extra import verbosity_option + + @command + @verbosity_option + def vanilla_command(): + echo("It works!") + logging.debug("We're printing stuff.") + +.. click:run:: + result = invoke(vanilla_command, args=["--help"]) + assert "-v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG." in result.stdout, "missing --verbosity option" + +.. click:run:: + result = invoke(vanilla_command) + assert result.stdout == "It works!\n" + assert not result.stderr + +.. click:run:: + result = invoke(vanilla_command, args=["--verbosity", "DEBUG"]) + assert result.stdout == "It works!\n" + assert "We're printing stuff." in result.stderr +``` + +```{tip} +Note in the output above how the verbosity option is automatticcaly printing its own log level as a debug message. +``` + +### Default logger + +By default the `--verbosity` option is setting the log level of [Python's global `root` logger](https://github.com/python/cpython/blob/a59dc1fb4324589427c5c84229eb2c0872f29ca0/Lib/logging/__init__.py#L1945). + +That way you can simply use the module helpers like [`logging.debug`](https://docs.python.org/3/library/logging.html?highlight=logging#logging.Logger.debug): + +```{eval-rst} +.. click:example:: + import logging + from click import command + from click_extra import verbosity_option + + @command + @verbosity_option + def my_cli(): + # Print a messages for each level. + logging.debug("We're printing stuff.") + logging.info("This is a message.") + logging.warning("Mad scientist at work!") + logging.error("Does not compute.") + logging.critical("Complete meltdown!") + +.. hint:: + + By default, the ``root`` logger is preconfigured to: + + - output to ````, + - render log records with the ``%(levelname)s: %(message)s`` format, + - color the log level name in the ``%(levelname)s`` variable, + - default to the ``INFO`` level. + +You can check these defaults by running the CLI without the ``--verbosity`` option: + +.. click:run:: + from textwrap import dedent + result = invoke(my_cli) + assert result.stderr == dedent("""\ + \x1b[33mwarning\x1b[0m: Mad scientist at work! + \x1b[31merror\x1b[0m: Does not compute. + \x1b[31m\x1b[1mcritical\x1b[0m: Complete meltdown! + """ + ) + +And then see how each level selectively print messages and renders with colors: + +.. click:run:: + from textwrap import dedent + result = invoke(my_cli, args=["--verbosity", "CRITICAL"]) + assert result.stderr == "\x1b[31m\x1b[1mcritical\x1b[0m: Complete meltdown!\n" + +.. click:run:: + from textwrap import dedent + result = invoke(my_cli, args=["--verbosity", "ERROR"]) + assert result.stderr == dedent("""\ + \x1b[31merror\x1b[0m: Does not compute. + \x1b[31m\x1b[1mcritical\x1b[0m: Complete meltdown! + """ + ) + +.. click:run:: + from textwrap import dedent + result = invoke(my_cli, args=["--verbosity", "WARNING"]) + assert result.stderr == dedent("""\ + \x1b[33mwarning\x1b[0m: Mad scientist at work! + \x1b[31merror\x1b[0m: Does not compute. + \x1b[31m\x1b[1mcritical\x1b[0m: Complete meltdown! + """ + ) + +.. click:run:: + from textwrap import dedent + result = invoke(my_cli, args=["--verbosity", "INFO"]) + assert result.stderr == dedent("""\ + info: This is a message. + \x1b[33mwarning\x1b[0m: Mad scientist at work! + \x1b[31merror\x1b[0m: Does not compute. + \x1b[31m\x1b[1mcritical\x1b[0m: Complete meltdown! + """ + ) + +.. click:run:: + from textwrap import dedent + result = invoke(my_cli, args=["--verbosity", "DEBUG"]) + assert result.stderr == dedent("""\ + \x1b[34mdebug\x1b[0m: Set to DEBUG. + \x1b[34mdebug\x1b[0m: Set to DEBUG. + \x1b[34mdebug\x1b[0m: We're printing stuff. + info: This is a message. + \x1b[33mwarning\x1b[0m: Mad scientist at work! + \x1b[31merror\x1b[0m: Does not compute. + \x1b[31m\x1b[1mcritical\x1b[0m: Complete meltdown! + \x1b[34mdebug\x1b[0m: Reset to WARNING. + \x1b[34mdebug\x1b[0m: Reset to WARNING. + """ + ) +``` + +```{eval-rst} +.. attention:: Level propagation + + Because the default logger is ``root``, its level is by default propagated to all other loggers: + + .. click:example:: + import logging + from click import command, echo + from click_extra import verbosity_option + + @command + @verbosity_option + def multiple_loggers(): + # Print to default root logger. + root_logger = logging.getLogger() + root_logger.info("Default informative message") + root_logger.debug("Default debug message") + + # Print to a random logger. + random_logger = logging.getLogger("my_random_logger") + random_logger.info("Random informative message") + random_logger.debug("Random debug message") + + echo("It works!") + + .. click:run:: + invoke(multiple_loggers) + + .. click:run:: + invoke(multiple_loggers, args=["--verbosity", "DEBUG"]) +``` + +### Custom logger + +If you'd like to target another logger than the default `root` logger, you can pass [your own logger](https://docs.python.org/3/library/logging.html?#logging.getLogger)'s ID to the option parameter: + +```{eval-rst} +.. click:example:: + import logging + from click import command, echo + from click_extra import extra_basic_config, verbosity_option + + # Create a custom logger in the style of Click Extra, with our own format message. + extra_basic_config( + logger_name="app_logger", + format="{levelname} | {name} | {message}", + ) + + @command + @verbosity_option(default_logger="app_logger") + def awesome_app(): + echo("Awesome App started") + logger = logging.getLogger("app_logger") + logger.debug("Awesome App has started.") + +You can now check that the ``--verbosity`` option influence the log level of your own ``app_logger`` global logger: + +.. click:run:: + invoke(awesome_app) + +.. click:run:: + from textwrap import dedent + result = invoke(awesome_app, args=["--verbosity", "DEBUG"]) + assert dedent("""\ + Awesome App started + \x1b[34mdebug\x1b[0m | app_logger | Awesome App has started. + \x1b[34mdebug\x1b[0m: Awesome App has started. + """ + ) in result.output +``` + +You can also pass the default logger object to the option: + +```{eval-rst} +.. click:example:: + import logging + from click import command, echo + from click_extra import verbosity_option + + my_app_logger = logging.getLogger("app_logger") + + @command + @verbosity_option(default_logger=my_app_logger) + def awesome_app(): + echo("Awesome App started") + logger = logging.getLogger("app_logger") + logger.debug("Awesome App has started.") + +.. click:run:: + from textwrap import dedent + result = invoke(awesome_app, args=["--verbosity", "DEBUG"]) + assert dedent("""\ + Awesome App started + \x1b[34mdebug\x1b[0m | app_logger | Awesome App has started. + \x1b[34mdebug\x1b[0m: Awesome App has started. + """ + ) in result.output +``` + +### Custom configuration + +The Python standard library provides the [`logging.basicConfig`](https://docs.python.org/3/library/logging.html?#logging.basicConfig) function, which is a helper to simplify the configuration of loggers and covers most use cases. + +Click Extra provides a similar helper, [`click_extra.logging.extra_basic_config`](#click_extra.logging.extra_basic_config). + +```{todo} +Write detailed documentation of `extra_basic_config()`. +``` + +### Get verbosity level + +You can get the name of the current verbosity level from the context or the logger itself: + +```{eval-rst} +.. click:example:: + import logging + from click_extra import command, echo, pass_context, verbosity_option + + @command + @verbosity_option + @pass_context + def vanilla_command(ctx): + level_from_context = ctx.meta["click_extra.verbosity"] + echo(f"Level from context: {level_from_context}") + + level_from_logger = logging._levelToName[logging.getLogger().getEffectiveLevel()] + echo(f"Level from logger: {level_from_logger}") + +.. click:run:: + result = invoke(vanilla_command, args=["--verbosity", "DEBUG"]) +``` + +## Internal `click_extra` logger + +```{todo} +Write docs! +``` + +## `click_extra.logging` API + +```{eval-rst} +.. autoclasstree:: click_extra.logging + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.logging + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/parameters.md.txt b/_sources/parameters.md.txt new file mode 100644 index 000000000..1740106ed --- /dev/null +++ b/_sources/parameters.md.txt @@ -0,0 +1,115 @@ +# Parameters + +Click Extra provides a set of tools to help you manage parameters in your CLI. + +Like the magical `--show-params` option, which is a X-ray scanner for your CLI's parameters. + +## Parameter structure + +```{todo} +Write example and tutorial. +``` + +## Introspecting parameters + +If for any reason you need to dive into parameters and their values, there is a lot of intermediate and metadata available in the context. Here are some pointers: + +```{code-block} python +from click import option, echo, pass_context + +from click_extra import config_option, extra_group + +@extra_group +@option("--dummy-flag/--no-flag") +@option("--my-list", multiple=True) +@config_option +@pass_context +def my_cli(ctx, dummy_flag, my_list): + echo(f"dummy_flag is {dummy_flag!r}") + echo(f"my_list is {my_list!r}") + echo(f"Raw parameters: {ctx.meta.get('click_extra.raw_args', [])}") + echo(f"Loaded, default values: {ctx.default_map}") + echo(f"Values passed to function: {ctx.params}") + +@my_cli.command() +@option("--int-param", type=int, default=10) +def subcommand(int_param): + echo(f"int_parameter is {int_param!r}") +``` + +```{caution} +The `click_extra.raw_args` metadata field in the context referenced above is not a standard feature from Click, but a helper introduced by Click Extra. It is only available with `@extra_group` and `@extra_command` decorators. + +In the mean time, it is [being discussed in the Click community at `click#1279`](https://github.com/pallets/click/issues/1279#issuecomment-1493348208). +``` + +```{todo} +Propose the `raw_args` feature upstream to Click. +``` + +Now if we feed the following `~/configuration.toml` configuration file: + +```toml +[my-cli] +verbosity = "DEBUG" +dummy_flag = true +my_list = [ "item 1", "item #2", "Very Last Item!",] + +[my-cli.subcommand] +int_param = 3 +``` + +Here is what we get: + +```{code-block} shell-session +$ cli --config ~/configuration.toml default-command +dummy_flag is True +my_list is ('item 1', 'item #2', 'Very Last Item!') +Raw parameters: ['--config', '~/configuration.toml', 'default-command'] +Loaded, default values: {'dummy_flag': True, 'my_list': ['pip', 'npm', 'gem'], 'verbosity': 'DEBUG', 'default-command': {'int_param': 3}} +Values passed to function: {'dummy_flag': True, 'my_list': ('pip', 'npm', 'gem')} +``` + +## `--show-params` option + +Click Extra provides a ready-to-use `--show-params` option, which is enabled by default. + +It produces a comprehensive table of your CLI parameters, normalized IDs, types and corresponding environment variables. And because it dynamiccaly print their default value, actual value and its source, it is a practical tool for users to introspect and debug the parameters of a CLI. + +See how the default `@extra_command` decorator come with the default `--show-params` option and the result of its use: + +```{eval-rst} +.. click:example:: + from click_extra import extra_command, option, echo + + @extra_command + @option("--int-param1", type=int, default=10) + @option("--int-param2", type=int, default=555) + def cli(int_param1, int_param2): + echo(f"int_param1 is {int_param1!r}") + echo(f"int_param2 is {int_param2!r}") + +.. click:run:: + result = invoke(cli, args=["--verbosity", "Debug", "--int-param1", "3", "--show-params"]) + assert "click_extra.raw_args: ['--verbosity', 'Debug', '--int-param1', '3', '--show-params']" in result.stderr + assert "│ \x1b[33m\x1b[2mCLI_INT_PARAM1\x1b[0m │ \x1b[32m\x1b[2m\x1b[3m10\x1b[0m " in result.stdout + assert "│ \x1b[33m\x1b[2mCLI_INT_PARAM2\x1b[0m │ \x1b[32m\x1b[2m\x1b[3m555\x1b[0m " in result.stdout +``` + +```{note} +Notice how `--show-params` is showing all parameters, even those provided to the `excluded_params` argument. You can still see the `--help`, `--version`, `-C`/`--config` and `--show-params` options in the table. +``` + +## `click_extra.parameters` API + +```{eval-rst} +.. autoclasstree:: click_extra.parameters + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.parameters + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/platforms.md.txt b/_sources/platforms.md.txt new file mode 100644 index 000000000..3ebf7aefb --- /dev/null +++ b/_sources/platforms.md.txt @@ -0,0 +1,124 @@ +# Platform detection + +## OS families + +All platforms are grouped in sets of non-overlpaping families: + + + +{caption="`click_extra.platforms.NON_OVERLAPPING_GROUPS` - Non-overlapping groups."} +```mermaid +:zoom: +flowchart + subgraph click_extra.platforms.ALL_LINUX
Any Linux + all_linux_linux(linux
Linux) + end + subgraph click_extra.platforms.ALL_WINDOWS
Any Windows + all_windows_windows(windows
Windows) + end + subgraph click_extra.platforms.BSD
Any BSD + bsd_freebsd(freebsd
FreeBSD) + bsd_macos(macos
macOS) + bsd_netbsd(netbsd
NetBSD) + bsd_openbsd(openbsd
OpenBSD) + bsd_sunos(sunos
SunOS) + end + subgraph click_extra.platforms.LINUX_LAYERS
Any Linux compatibility layers + linux_layers_wsl1(wsl1
Windows Subsystem for Linux v1) + linux_layers_wsl2(wsl2
Windows Subsystem for Linux v2) + end + subgraph click_extra.platforms.OTHER_UNIX
Any other Unix + other_unix_hurd(hurd
GNU/Hurd) + end + subgraph click_extra.platforms.SYSTEM_V
Any Unix derived from AT&T System Five + system_v_aix(aix
AIX) + system_v_solaris(solaris
Solaris) + end + subgraph click_extra.platforms.UNIX_LAYERS
Any Unix compatibility layers + unix_layers_cygwin(cygwin
Cygwin) + end +``` + + + +## Other groups + +Other groups are available for convenience, but these overlaps: + + + +{caption="`click_extra.platforms.EXTRA_GROUPS` - Overlapping groups, defined for convenience."} +```mermaid +:zoom: +flowchart + subgraph click_extra.platforms.ALL_PLATFORMS
Any platforms + all_platforms_aix(aix
AIX) + all_platforms_cygwin(cygwin
Cygwin) + all_platforms_freebsd(freebsd
FreeBSD) + all_platforms_hurd(hurd
GNU/Hurd) + all_platforms_linux(linux
Linux) + all_platforms_macos(macos
macOS) + all_platforms_netbsd(netbsd
NetBSD) + all_platforms_openbsd(openbsd
OpenBSD) + all_platforms_solaris(solaris
Solaris) + all_platforms_sunos(sunos
SunOS) + all_platforms_windows(windows
Windows) + all_platforms_wsl1(wsl1
Windows Subsystem for Linux v1) + all_platforms_wsl2(wsl2
Windows Subsystem for Linux v2) + end + subgraph click_extra.platforms.BSD_WITHOUT_MACOS
Any BSD but macOS + bsd_without_macos_freebsd(freebsd
FreeBSD) + bsd_without_macos_netbsd(netbsd
NetBSD) + bsd_without_macos_openbsd(openbsd
OpenBSD) + bsd_without_macos_sunos(sunos
SunOS) + end + subgraph click_extra.platforms.UNIX
Any Unix + unix_aix(aix
AIX) + unix_cygwin(cygwin
Cygwin) + unix_freebsd(freebsd
FreeBSD) + unix_hurd(hurd
GNU/Hurd) + unix_linux(linux
Linux) + unix_macos(macos
macOS) + unix_netbsd(netbsd
NetBSD) + unix_openbsd(openbsd
OpenBSD) + unix_solaris(solaris
Solaris) + unix_sunos(sunos
SunOS) + unix_wsl1(wsl1
Windows Subsystem for Linux v1) + unix_wsl2(wsl2
Windows Subsystem for Linux v2) + end + subgraph click_extra.platforms.UNIX_WITHOUT_MACOS
Any Unix but macOS + unix_without_macos_aix(aix
AIX) + unix_without_macos_cygwin(cygwin
Cygwin) + unix_without_macos_freebsd(freebsd
FreeBSD) + unix_without_macos_hurd(hurd
GNU/Hurd) + unix_without_macos_linux(linux
Linux) + unix_without_macos_netbsd(netbsd
NetBSD) + unix_without_macos_openbsd(openbsd
OpenBSD) + unix_without_macos_solaris(solaris
Solaris) + unix_without_macos_sunos(sunos
SunOS) + unix_without_macos_wsl1(wsl1
Windows Subsystem for Linux v1) + unix_without_macos_wsl2(wsl2
Windows Subsystem for Linux v2) + end +``` + + + +```{important} +All the graphs above would be better off and user-friendly if merged together. Unfortunately Graphviz is not capable of producing [Euler diagrams](https://xkcd.com/2721/). Only non-overlapping clusters can be rendered. + +There's still a chance to [have them supported by Mermaid](https://github.com/mermaid-js/mermaid/issues/2583) so we can switch to that if the feature materialize. +``` + +## `click_extra.platforms` API + +```{eval-rst} +.. autoclasstree:: click_extra.platforms + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.platforms + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/pygments.md.txt b/_sources/pygments.md.txt new file mode 100644 index 000000000..5d1b0d771 --- /dev/null +++ b/_sources/pygments.md.txt @@ -0,0 +1,374 @@ +# Pygments extensions + +Click Extra plugs into Pygments to allow for the rendering of ANSI codes in various terminal output. + +## Integration + +As soon as [`click-extra` is installed](install.md), all its additional components are automaticcaly registered to Pygments. + +Here is a quick way to check the new plugins are visible to Pygments' regular API: + +- Formatter: + + ```ansi-pycon + >>> from pygments.formatters import get_formatter_by_name + >>> get_formatter_by_name("ansi-html") + + ``` + +- Filter: + + ```ansi-pycon + >>> from pygments.filters import get_filter_by_name + >>> get_filter_by_name("ansi-filter") + + ``` + +- Lexers: + + ```ansi-pycon + >>> from pygments.lexers import get_lexer_by_name + >>> get_lexer_by_name("ansi-shell-session") + + ``` + +```{tip} +If `click-extra` is installed but you don't see these new components, you are probably running the snippets above in the wrong Python interpreter. + +For instance, you may be running them in a virtual environment. In that case, make sure the virtual environment is activated, and you can `import click_extra` from it. +``` + +## ANSI HTML formatter + +The new `ansi-html` formatter interpret ANSI Pygments tokens and renders them into HTML. It is also responsible for producing the corresponding CSS style to color the HTML elements. + +````{warning} +This `ansi-html` formatter is designed to only work with the `ansi-color` lexer. These two components are the only one capable of producing ANSI tokens (`ansi-color`) and rendering them in HTML (`ansi-html`). + +[`ansi-color` is implement by `pygments_ansi_color.AnsiColorLexer`](https://github.com/chriskuehl/pygments-ansi-color/blob/2ef0410763eff53f0af736c2f08ebd16fa4abb83/pygments_ansi_color/__init__.py#L203) on which Click Extra depends. So on Click Extra installation, `ansi-color` will be available to Pygments: + +```ansi-pycon +>>> from pygments.lexers import get_lexer_by_name +>>> get_lexer_by_name("ansi-color") + +``` +```` + +### Formatter usage + +To test it, let's generate a `cowsay.ans` file that is full of ANSI colors: + +```ansi-shell-session +$ fortune | cowsay | lolcat --force > ./cowsay.ans +$ cat ./cowsay.ans + ________________________________  +/ Reality is for people who lack \ +\ imagination.                   / + --------------------------------  +        \   ^__^ +         \  (oo)\_______ +            (__)\       )\/\ +                ||----w | +                ||     || +``` + +We can run our formatter on that file: + +```python +from pathlib import Path + +from pygments import highlight +from pygments.lexers import get_lexer_by_name +from pygments.formatters import get_formatter_by_name + +lexer = get_lexer_by_name("ansi-color") +formatter = get_formatter_by_name("ansi-html") + +ansi_content = Path("./cowsay.ans").read_text() + +print(highlight(ansi_content, lexer, formatter)) +``` + +```{hint} +The `ansi-color` lexer parse raw ANSI codes and transform them into custom Pygments tokens, for the formatter to render. + +[Pygments' `highlight()`](https://pygments.org/docs/api/#pygments.highlight) is the utility method tying the lexer and formatter together to generate the final output. +``` + +The code above prints the following HTML: + +```html +
+
+      
+       __
+      _
+      ___________
+      _
+      _________
+      ________ 
+      /
+       Reality is
+       
+      for people
+       who lack
+      …
+   
+
+ +``` + +And here is how to obtain the corresponding CSS style: + +```python +print(formatter.get_style_defs(".highlight")) +``` + +```css +pre { + line-height: 125%; +} + +.highlight .hll { + background-color: #ffffcc +} + +.highlight { + background: #f8f8f8; +} + +.highlight .c { + color: #3D7B7B; + font-style: italic +} + +/* Comment */ +.highlight .err { + border: 1px solid #FF0000 +} + +/* Error */ +.highlight .o { + color: #666666 +} + +/* Operator */ +.highlight .-C-BGBlack { + background-color: #000000 +} + +/* C.BGBlack */ +.highlight .-C-BGBlue { + background-color: #3465a4 +} + +/* C.BGBlue */ +.highlight .-C-BGBrightBlack { + background-color: #676767 +} + +/* C.BGBrightBlack */ +.highlight .-C-BGBrightBlue { + background-color: #6871ff +} + +/* C.BGBrightBlue */ +.highlight .-C-BGC0 { + background-color: #000000 +} + +/* C.BGC0 */ +.highlight .-C-BGC100 { + background-color: #878700 +} + +/* C.BGC100 */ +.highlight .-C-BGC101 { + background-color: #87875f +} + +/* C.BGC101 */ +/* … */ +``` + +```{caution} +The `ansi-color` lexer/`ansi-html` formatter combo can only render pure ANSI content. It cannot interpret the regular Pygments tokens produced by [the usual language lexers](https://pygments.org/languages/). + +That's why we also maintain a collection of [ANSI-capable lexers for numerous languages](#ansi-language-lexers), as detailed below. +``` + +## ANSI filter + +```{todo} +Write example and tutorial. +``` + +## ANSI language lexers + +Some [languages supported by Pygments](https://pygments.org/languages/) are command lines or code, mixed with [generic output](#click_extra.pygments.DEFAULT_TOKEN_TYPE). + +For example, the [`console` lexer can be used to highlight shell sessions](https://pygments.org/docs/terminal-sessions/). The general structure of the shell session will be highlighted by the `console` lexer, including the leading prompt. But the ANSI codes in the output will not be interpreted by `console` and will be rendered as plain text. + +To fix that, Click Extra implements ANSI-capable lexers. These can parse both the language syntax and the ANSI codes in the output. So you can use the `ansi-console` lexer instead of `console`, and this `ansi-`-prefixed variant will highlight shell sessions with ANSI codes. + +### Lexer variants + +Here is the list of new ANSI-capable lexers and the [original lexers](https://pygments.org/languages/) they are based on: + + + +| Original Lexer | Original IDs | ANSI variants | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------- | +| [`BashSessionLexer`](https://pygments.org/docs/lexers/#pygments.lexers.shell.BashSessionLexer) | `console`, `shell-session` | `ansi-console`, `ansi-shell-session` | +| [`DylanConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.dylan.DylanConsoleLexer) | `dylan-console`, `dylan-repl` | `ansi-dylan-console`, `ansi-dylan-repl` | +| [`ElixirConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.erlang.ElixirConsoleLexer) | `iex` | `ansi-iex` | +| [`ErlangShellLexer`](https://pygments.org/docs/lexers/#pygments.lexers.erlang.ErlangShellLexer) | `erl` | `ansi-erl` | +| [`GAPConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.algebra.GAPConsoleLexer) | `gap-console`, `gap-repl` | `ansi-gap-console`, `ansi-gap-repl` | +| [`JuliaConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.julia.JuliaConsoleLexer) | `jlcon`, `julia-repl` | `ansi-jlcon`, `ansi-julia-repl` | +| [`MSDOSSessionLexer`](https://pygments.org/docs/lexers/#pygments.lexers.shell.MSDOSSessionLexer) | `doscon` | `ansi-doscon` | +| [`MatlabSessionLexer`](https://pygments.org/docs/lexers/#pygments.lexers.matlab.MatlabSessionLexer) | `matlabsession` | `ansi-matlabsession` | +| [`OutputLexer`](https://pygments.org/docs/lexers/#pygments.lexers.special.OutputLexer) | `output` | `ansi-output` | +| [`PostgresConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.sql.PostgresConsoleLexer) | `postgres-console`, `postgresql-console`, `psql` | `ansi-postgres-console`, `ansi-postgresql-console`, `ansi-psql` | +| [`PowerShellSessionLexer`](https://pygments.org/docs/lexers/#pygments.lexers.shell.PowerShellSessionLexer) | `ps1con`, `pwsh-session` | `ansi-ps1con`, `ansi-pwsh-session` | +| [`PsyshConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.php.PsyshConsoleLexer) | `psysh` | `ansi-psysh` | +| [`PythonConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.python.PythonConsoleLexer) | `pycon` | `ansi-pycon` | +| [`RConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.r.RConsoleLexer) | `rconsole`, `rout` | `ansi-rconsole`, `ansi-rout` | +| [`RubyConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.ruby.RubyConsoleLexer) | `irb`, `rbcon` | `ansi-irb`, `ansi-rbcon` | +| [`SqliteConsoleLexer`](https://pygments.org/docs/lexers/#pygments.lexers.sql.SqliteConsoleLexer) | `sqlite3` | `ansi-sqlite3` | +| [`TcshSessionLexer`](https://pygments.org/docs/lexers/#pygments.lexers.shell.TcshSessionLexer) | `tcshcon` | `ansi-tcshcon` | + + + +### Lexers usage + +Let's test one of these lexers. We are familiar with Python so we'll focus on the `pycon` Python console lexer. + +First, we will generate some random art in an interactive Python shell: + +```pycon +>>> import itertools +>>> colors = [f"\033[3{i}m{{}}\033[0m" for i in range(1, 7)] +>>> rainbow = itertools.cycle(colors) +>>> letters = [next(rainbow).format(c) for c in "║▌█║ ANSI Art ▌│║▌"] +>>> art = "".join(letters) +>>> art +'\x1b[35m║\x1b[0m\x1b[36m▌\x1b[0m\x1b[31m█\x1b[0m\x1b[32m║\x1b[0m\x1b[33m \x1b[0m\x1b[34mA\x1b[0m\x1b[35mN\x1b[0m\x1b[36mS\x1b[0m\x1b[31mI\x1b[0m\x1b[32m \x1b[0m\x1b[33mA\x1b[0m\x1b[34mr\x1b[0m\x1b[35mt\x1b[0m\x1b[36m \x1b[0m\x1b[31m▌\x1b[0m\x1b[32m│\x1b[0m\x1b[33m║\x1b[0m\x1b[34m▌\x1b[0m' +``` + +The code block above is a typical Python console session. You have interactive prompt (`>>>`), pure Python code, and the output of these invocations. It is rendered here with Pygments' original `pycon` lexer. + +You can see that the raw Python string `art` contain ANSI escape sequences (`\x1b[XXm`). When we print this string and give the results to Pygments, the ANSI codes are not interpreted and the output is rendered as-is: + +```pycon +>>> print(art) +║▌█║ ANSI Art ▌│║▌ +``` + +If you try to run the snippet above in your own Python console, you will see that the result of the `print(art)` is colored. + +That's why you need Click Extra's lexers. If we switch to the new `ansi-pycon` lexer, the output is colored, replicating exactly what you are expecting in your console: + +```ansi-pycon +>>> print(art) +║▌█║ ANSI Art ▌│║▌ +``` + +```{seealso} +All these new lexers [can be used in Sphinx](https://kdeldycke.github.io/click-extra/sphinx.html#ansi-shell-sessions) out of the box, with [a bit of configuration](https://kdeldycke.github.io/click-extra/sphinx.html#setup). +``` + +### Lexer design + +We can check how `pygments_ansi_color`'s `ansi-color` lexer transforms a raw string into ANSI tokens: + +```ansi-pycon +>>> from pygments.lexers import get_lexer_by_name +>>> ansi_lexer = get_lexer_by_name("ansi-color") +>>> tokens = ansi_lexer.get_tokens(art) +>>> tuple(tokens) +((Token.Color.Magenta, '║'), (Token.Text, ''), (Token.Color.Cyan, '▌'), (Token.Text, ''), (Token.Color.Red, '█'), (Token.Text, ''), (Token.Color.Green, '║'), (Token.Text, ''), (Token.Color.Yellow, ' '), (Token.Text, ''), (Token.Color.Blue, 'A'), (Token.Text, ''), (Token.Color.Magenta, 'N'), (Token.Text, ''), (Token.Color.Cyan, 'S'), (Token.Text, ''), (Token.Color.Red, 'I'), (Token.Text, ''), (Token.Color.Green, ' '), (Token.Text, ''), (Token.Color.Yellow, 'A'), (Token.Text, ''), (Token.Color.Blue, 'r'), (Token.Text, ''), (Token.Color.Magenta, 't'), (Token.Text, ''), (Token.Color.Cyan, ' '), (Token.Text, ''), (Token.Color.Red, '▌'), (Token.Text, ''), (Token.Color.Green, '│'), (Token.Text, ''), (Token.Color.Yellow, '║'), (Token.Text, ''), (Token.Color.Blue, '▌'), (Token.Text, '\n')) +``` + +See how the raw string is split into Pygments tokens, including the new `Token.Color` tokens. These tokens are then ready to be rendered by [our own `ansi-html` formatter](#ansi-html-formatter). + +## `pygmentize` command line + +Because they're properly registered to Pygments, all these new components can be invoked with the [`pygmentize` CLI](https://pygments.org/docs/cmdline/). + +For example, here is how we can render the `cowsay.ans` file from the [example above](<>) into a standalone HTML file: + +```ansi-shell-session +$ pygmentize -f ansi-html -O full -o cowsay.html ./cowsay.ans +$ cat cowsay.html +``` + +```html + + + + + + + + + + +

+

+
+
+            
+             __
+            _
+            ___________
+            _
+            _________
+            ________ 
+            /
+             Reality is
+             
+            for people
+             who lack
+            …
+         
+
+ + + +``` + +## `click_extra.pygments` API + +```{eval-rst} +.. autoclasstree:: click_extra.pygments + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.pygments + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/sphinx.md.txt b/_sources/sphinx.md.txt new file mode 100644 index 000000000..b820b27a9 --- /dev/null +++ b/_sources/sphinx.md.txt @@ -0,0 +1,316 @@ +# Sphinx extensions + +[Sphinx](https://www.sphinx-doc.org) is the best way to document your Python CLI. Click Extra provides several utilities to improve the quality of life of maintainers. + +## Setup + +Once [Click Extra is installed](install.md), you can enable its [extensions](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-extensions) in your Sphinx's `conf.py`: + +```python +extensions = ["click_extra.sphinx", ...] +``` + +## Click directives + +Click Extra adds two new directives: + +- `.. click:example::` to display any Click-based Python code blocks in Sphinx (and renders like `.. code-block:: python`) +- `.. click:run::` to invoke the CLI defined above, and display the results as if was executed in a terminmal (within a `.. code-block:: ansi-shell-session`) + +Thanks to these, you can directly demonstrate the usage of your CLI in your documentation. You no longer have to maintain screenshots of you CLIs. Or copy and paste their outputs to keep them in sync with the latest revision. Click Extra will do that job for you. + +```{hint} +These directives are [based on the official `Pallets-Sphinx-Themes`](https://github.com/pallets/pallets-sphinx-themes/blob/main/src/pallets_sphinx_themes/themes/click/domain.py) from Click's authors, but augmented with support for ANSI coloring. That way you can show off your user-friendly CLI in all its glory. 🌈 +``` + +```{seealso} +Click Extra's own documentation extensively use `.. click:example::` and `.. click:run::` directives. [Look around +in its Markdown source files](https://github.com/kdeldycke/click-extra/tree/main/docs) for advanced examples and +inspiration. +``` + +### Usage + +Here is how to define a simple Click-based CLI with the `.. click:example::` directive: + +``````{tab-set} +`````{tab-item} Markdown (MyST) +:sync: myst +```{attention} +As you can see in the example below, these Click directives are not recognized as-is by the MyST parser, so you need to wrap them in `{eval-rst}` blocks. +``` + +````markdown +```{eval-rst} +.. click:example:: + from click_extra import echo, extra_command, option, style + + @extra_command + @option("--name", prompt="Your name", help="The person to greet.") + def hello_world(name): + """Simple program that greets NAME.""" + echo(f"Hello, {style(name, fg='red')}!") +``` +```` + +Thanks to the `.. click:run::` directive, we can invoke this CLI with its `--help` option: + +````markdown +```{eval-rst} +.. click:run:: + invoke(hello_world, args=["--help"]) +``` +```` + +````{warning} +CLI states and references are lost as soon as an `{eval-rst}` block ends. If you need to run a `.. click:example::` definition multiple times, all its `.. click:run::` calls must happens within the same rST block. + +A symptom of that issue is the execution failing with tracebacks such as: +```pytb +Exception occurred: + File "", line 1, in +NameError: name 'hello_world' is not defined +``` +```` +````` + +`````{tab-item} reStructuredText +:sync: rst + +```rst +.. click:example:: + from click_extra import echo, extra_command, option, style + + @extra_command + @option("--name", prompt="Your name", help="The person to greet.") + def hello_world(name): + """Simple program that greets NAME.""" + echo(f"Hello, {style(name, fg='red')}!") +``` + +Thanks to the `.. click:run::` directive, we can invoke this CLI with its `--help` option: + +```rst +.. click:run:: + invoke(hello_world, args=["--help"]) +``` +````` +`````` + +Placed in your Sphinx documentation, the two blocks above renders to: + +```{eval-rst} +.. click:example:: + from click_extra import echo, extra_command, option, style + + @extra_command + @option("--name", prompt="Your name", help="The person to greet.") + def hello_world(name): + """Simple program that greets NAME.""" + echo(f"Hello, {style(name, fg='red')}!") + +.. click:run:: + from textwrap import dedent + result = invoke(hello_world, args=["--help"]) + assert result.stdout.startswith(dedent( + """\ + \x1b[94m\x1b[1m\x1b[4mUsage:\x1b[0m \x1b[97mhello-world\x1b[0m \x1b[36m\x1b[2m[OPTIONS]\x1b[0m + + Simple program that greets NAME. + + \x1b[94m\x1b[1m\x1b[4mOptions:\x1b[0m + \x1b[36m--name\x1b[0m \x1b[36m\x1b[2mTEXT\x1b[0m The person to greet. + \x1b[36m--time\x1b[0m / \x1b[36m--no-time\x1b[0m Measure and print elapsed execution time.""" + )) + +This is perfect for documentation, as it shows both the source code of the CLI and its results. + +See for instance how the CLI code is properly rendered as a Python code block with syntax highlighting. And how the invocation of that CLI renders into a terminal session with ANSI coloring of output. + +You can then invoke that CLI again with its ``--name`` option: + +.. code-block:: rst + + .. click:run:: + invoke(hello_world, args=["--name", "Joe"]) + +Which renders in Sphinx like it was executed in a terminal block: + +.. click:run:: + result = invoke(hello_world, args=["--name", "Joe"]) + assert result.output == 'Hello, \x1b[31mJoe\x1b[0m!\n' +``` + +```{tip} +`.. click:example::` and `.. click:run::` directives works well with standard vanilla `click`-based CLIs. + +In the example above, we choose to import our CLI primitives from the `click-extra` module instead, to demonstrate the coloring of terminal session outputs, as `click-extra` provides [fancy coloring of help screens](colorize.md) by default. +``` + +### Inline tests + +The `.. click:run::` directive can also be used to embed tests in your documentation. + +These blocks are Python code executed at build time, so you can use them to validate the behavior of your CLI. This allow you to catch regressions, outdated documentation or changes in terminal output. + +For example, here is a simple CLI: + +```{eval-rst} +.. click:example:: + from click_extra import echo, extra_command, option, style + + @extra_command + @option("--name", prompt="Your name", help="The person to greet.") + def hello_world(name): + """Simple program that greets NAME.""" + echo(f"Hello, {style(name, fg='red')}!") + +If we put this CLI code in a ``.. click:example::`` directive, we can associate it with the following ``.. click:run::`` block: + +.. code-block:: rst + + .. click:run:: + result = invoke(hello_world, args=["--help"]) + + assert result.exit_code == 0, "CLI execution failed" + assert not result.stderr, "error message in " + assert "--show-params" in result.stdout, "--show-params not found in help screeen" + +See how we collect the ``result`` of the ``invoke`` command, and inspect the ``exit_code``, ``stderr`` and ``stdout`` of the CLI with ``assert`` statements. + +If for any reason our CLI changes and its help screen is no longer what we expect, the test will fail and the documentation build will break with a message similar to: + +.. code-block:: pytb + + Exception occurred: + File "", line 5, in + AssertionError: --show-params not found in help screeen + +Having your build fails when something unexpected happens is a great signal to catch regressions early. + +On the other hand, if the build succeed, the ``.. click:run::`` block will render as usual with the result of the invocation: + +.. click:run:: + result = invoke(hello_world, args=["--help"]) + + assert result.exit_code == 0, "CLI execution failed" + assert not result.stderr, "error message in " + assert "--show-params" in result.stdout, "--show-params not found in help screeen" +``` + +```{tip} +In a way, you can consider this kind of inline tests as like [doctests](https://docs.python.org/3/library/doctest.html), but for Click CLIs. + +Look around in the sources of Click Extra's documentation for more examples of inline tests. +``` + +```{hint} +The CLI runner used by `.. click:run::` is a custom version [derived from the original `click.testing.CliRunner`](https://click.palletsprojects.com/en/8.1.x/api/#click.testing.CliRunner). + +It is [called `ExtraCliRunner`](testing.md#click_extra.testing.ExtraCliRunner) and is patched so you can refine your tests by inspecting both `` and `` independently. It also provides an additional `` stream which simulates what the user sees in its terminal. +``` + +## ANSI shell sessions + +Sphinx extensions from Click Extra automaticcaly integrates the [new ANSI-capable lexers for Pygments](https://kdeldycke.github.io/click-extra/pygments.html#lexers). + +This allows you to render colored shell sessions in code blocks by referring to the `ansi-` prefixed lexers: + +``````{tab-set} + +`````{tab-item} Markdown (MyST) +:sync: myst + +````markdown +```ansi-shell-session +$ # Print ANSI foreground colors. +$ for i in {0..255}; do \ +> printf '\e[38;5;%dm%3d ' $i $i \ +> (((i+3) % 18)) || printf '\e[0m\n' \ +> done + 0  1  2  3  4  5  6  7  8  9  10  11  12  13  14  15  + 16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31  32  33  + 34  35  36  37  38  39  40  41  42  43  44  45  46  47  48  49  50  51  + 52  53  54  55  56  57  58  59  60  61  62  63  64  65  66  67  68  69  + 70  71  72  73  74  75  76  77  78  79  80  81  82  83  84  85  86  87  + 88  89  90  91  92  93  94  95  96  97  98  99 100 101 102 103 104 105  +106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123  +124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141  +142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159  +160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177  +178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195  +196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213  +214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231  +232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249  +250 251 252 253 254 255 +``` +```` +````` + +`````{tab-item} reStructuredText +:sync: rst +```rst +.. code-block:: ansi-shell-session + + $ # Print ANSI foreground colors. + $ for i in {0..255}; do \ + > printf '\e[38;5;%dm%3d ' $i $i \ + > (((i+3) % 18)) || printf '\e[0m\n' \ + > done +  0  1  2  3  4  5  6  7  8  9  10  11  12  13  14  15  +  16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31  32  33  +  34  35  36  37  38  39  40  41  42  43  44  45  46  47  48  49  50  51  +  52  53  54  55  56  57  58  59  60  61  62  63  64  65  66  67  68  69  +  70  71  72  73  74  75  76  77  78  79  80  81  82  83  84  85  86  87  +  88  89  90  91  92  93  94  95  96  97  98  99 100 101 102 103 104 105  + 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123  + 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141  + 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159  + 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177  + 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195  + 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213  + 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231  + 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249  + 250 251 252 253 254 255 +``` +````` +`````` + +In Sphinx, the snippet above renders to: + +```ansi-shell-session +$ # Print ANSI foreground colors. +$ for i in {0..255}; do \ +> printf '\e[38;5;%dm%3d ' $i $i \ +> (((i+3) % 18)) || printf '\e[0m\n' \ +> done + 0  1  2  3  4  5  6  7  8  9  10  11  12  13  14  15  + 16  17  18  19  20  21  22  23  24  25  26  27  28  29  30  31  32  33  + 34  35  36  37  38  39  40  41  42  43  44  45  46  47  48  49  50  51  + 52  53  54  55  56  57  58  59  60  61  62  63  64  65  66  67  68  69  + 70  71  72  73  74  75  76  77  78  79  80  81  82  83  84  85  86  87  + 88  89  90  91  92  93  94  95  96  97  98  99 100 101 102 103 104 105  +106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123  +124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141  +142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159  +160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177  +178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195  +196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213  +214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231  +232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249  +250 251 252 253 254 255 +``` + +## `click_extra.sphinx` API + +```{eval-rst} +.. autoclasstree:: click_extra.sphinx + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.sphinx + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/tabulate.md.txt b/_sources/tabulate.md.txt new file mode 100644 index 000000000..98e70e69c --- /dev/null +++ b/_sources/tabulate.md.txt @@ -0,0 +1,111 @@ +# Table + +Click Extra provides a way to render tables in the terminal. + +Here how to use the standalone table rendering option decorator: + +```{eval-rst} +.. click:example:: + from click_extra import command, echo, pass_context, table_format_option + + @command + @table_format_option + @pass_context + def table_command(ctx): + data = ((1, 87), (2, 80), (3, 79)) + headers = ("day", "temperature") + ctx.print_table(data, headers) + +As you can see above, this option adds a ready-to-use ``print_table()`` method to the context object. + +The default help message for this option list all available table formats: + +.. click:run:: + result = invoke(table_command, args=["--help"]) + assert "-t, --table-format" in result.stdout + +So you can use the ``--table-format`` option to change the table format: + +.. click:run:: + from textwrap import dedent + + result = invoke(table_command, args=["--table-format", "fancy_outline"]) + assert result.stdout == dedent("""\ + ╒═════╤═════════════╕ + │ day │ temperature │ + ╞═════╪═════════════╡ + │ 1 │ 87 │ + │ 2 │ 80 │ + │ 3 │ 79 │ + ╘═════╧═════════════╛ + """) + +.. click:run:: + from textwrap import dedent + + result = invoke(table_command, args=["--table-format", "jira"]) + assert result.stdout == dedent("""\ + || day || temperature || + | 1 | 87 | + | 2 | 80 | + | 3 | 79 | + """) +``` + +### Table formats + +Available table [formats are inherited from `python-tabulate`](https://github.com/astanin/python-tabulate#table-format). + +This list is augmented with extra formats: + +- `csv` +- `csv-excel` +- `csv-excel-tab` +- `csv-unix` +- `vertical` + +```{todo} +Explicitly list all formats IDs and render an example of each format. +``` + +```{todo} +Explain extra parameters supported by `print_table()` for each category of formats. +``` + +### Get table format + +You can get the ID of the current table format from the context: + +```{eval-rst} +.. click:example:: + from click_extra import command, echo, pass_context, table_format_option + + @command + @table_format_option + @pass_context + def vanilla_command(ctx): + format_id = ctx.meta["click_extra.table_format"] + echo(f"Table format: {format_id}") + + data = ((1, 87), (2, 80), (3, 79)) + headers = ("day", "temperature") + ctx.print_table(data, headers) + +.. click:run:: + result = invoke(vanilla_command, args=["--table-format", "fancy_outline"]) + assert "Table format: fancy_outline" in result.stdout +``` + +## `click_extra.tabulate` API + +```{eval-rst} +.. autoclasstree:: click_extra.tabulate + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.tabulate + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/testing.md.txt b/_sources/testing.md.txt new file mode 100644 index 000000000..0f29510d7 --- /dev/null +++ b/_sources/testing.md.txt @@ -0,0 +1,19 @@ +# CLI testing and execution + +```{todo} +Write example and tutorial. +``` + +## `click_extra.run` API + +```{eval-rst} +.. autoclasstree:: click_extra.testing + :strict: +``` + +```{eval-rst} +.. automodule:: click_extra.testing + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/_sources/timer.md.txt b/_sources/timer.md.txt new file mode 100644 index 000000000..4a057890b --- /dev/null +++ b/_sources/timer.md.txt @@ -0,0 +1,73 @@ +# Timer + +## Option + +Click Extra can measure the execution time of a CLI via a dedicated `--time`/`--no-time` option. + +Here how to use the standalone decorator: + +```{eval-rst} +.. click:example:: + from time import sleep + + from click_extra import command, echo, pass_context, timer_option + + @command + @timer_option + def timer(): + sleep(0.2) + echo("Hello world!") + +.. click:run:: + result = invoke(timer, args=["--help"]) + assert "--time / --no-time" in result.stdout + +.. click:run:: + import re + + result = invoke(timer, ["--time"]) + assert re.fullmatch( + r"Hello world!\n" + r"Execution time: (?P