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)
[docs] def __iter__(self) -> Iterator[click.Option]: return iter(self.options)
[docs] def __getitem__(self, i: int) -> click.Option: return self.options[i]
[docs] def __len__(self) -> int: return len(self.options)
[docs] def __repr__(self) -> str: return make_repr(self, self.title, help=self.help, options=self.options)
[docs] def __str__(self) -> str: return make_repr( self, self.title, options=[opt.name for opt in self.options])
[docs]def has_option_group(param: click.Parameter) -> bool: return getattr(param, 'group', None) is not None
[docs]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