Source code for cloup._option_groups

"""
Implements support to option group.
"""
from collections import defaultdict
from typing import Callable, List, Optional, Sequence, Tuple, Type, TypeVar, overload

import click
from click import Option, Parameter

from cloup._util import make_repr
from cloup.constraints import Constraint, ConstraintMixin

C = TypeVar('C', bound=Callable)

#: A decorator that registers an option to the wrapped function
OptionDecorator = Callable[[C], C]

#: A decorator that registers an option group to the wrapped function
OptionGroupDecorator = Callable[[C], C]


[docs]class OptionGroup: def __init__(self, name: str, help: Optional[str] = None, constraint: Optional[Constraint] = None): if not name: raise ValueError('name is a mandatory argument') self.name = name self.help = help self.options: Sequence[click.Option] = [] self.constraint = constraint
[docs] def get_help_records(self, ctx: click.Context): return [opt.get_help_record(ctx) for opt in self if not opt.hidden]
[docs] def option(self, *param_decls, **attrs): return option(*param_decls, group=self, **attrs)
[docs] def __iter__(self): 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.name, help=self.help, options=self.options)
[docs] def __str__(self) -> str: return make_repr( self, self.name, options=[opt.name for opt in self.options])
[docs]class GroupedOption(click.Option): """ A click.Option with an extra field ``group`` of type OptionGroup """ def __init__(self, *args, group: Optional[OptionGroup] = None, **attrs): super().__init__(*args, **attrs) self.group = group
[docs]def has_option_group(param) -> bool: return hasattr(param, 'group') and param.group is not None
[docs]def get_option_group_of(param, default=None): return param.group if has_option_group(param) else default
[docs]class OptionGroupMixin: """Implements support to option groups. .. versionadded:: 0.5.0 .. important:: In order to check the constraints defined on the option groups, a command must inherits from :class:`cloup.ConstraintMixin` too! """ def __init__(self, *args, align_option_groups: bool = True, **kwargs): self.align_option_groups = align_option_groups self.option_groups, self.ungrouped_options = \ self._option_groups_from_params(kwargs['params']) super().__init__(*args, **kwargs) # type: ignore @staticmethod def _option_groups_from_params( params: List[Parameter] ) -> Tuple[List[OptionGroup], List[Option]]: options_by_group = defaultdict(list) for param in params: if isinstance(param, click.Option): grp = get_option_group_of(param) options_by_group[grp].append(param) ungrouped_options = options_by_group.pop(None, []) option_groups = list(options_by_group.keys()) for group, options in options_by_group.items(): group.options = options return option_groups, ungrouped_options
[docs] def get_ungrouped_options(self, ctx: click.Context) -> Sequence[click.Option]: 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_option_group_title(self, ctx: click.Context, opt_group: OptionGroup) -> str: constraint = opt_group.constraint if constraint: if not isinstance(ctx.command, ConstraintMixin): raise TypeError('Command must inherits from ConstraintMixin in order to ' 'use OptionGroup constraints') constraint_help = constraint.help(ctx) if constraint else None if constraint_help: return f'{opt_group.name} [{constraint_help}]' return opt_group.name
[docs] def format_option_group(self, ctx: click.Context, formatter: click.HelpFormatter, opt_group: OptionGroup, help_records: Optional[Sequence] = None): if help_records is None: help_records = opt_group.get_help_records(ctx) if not help_records: return title = self.get_option_group_title(ctx, opt_group) with formatter.section(title): if opt_group.help: formatter.write_text(opt_group.help) formatter.write_dl(help_records)
[docs] def format_options(self, ctx: click.Context, formatter: click.HelpFormatter, max_option_width: int = 30): records_by_group = {} for group in self.option_groups: records_by_group[group] = group.get_help_records(ctx) ungrouped_options = self.get_ungrouped_options(ctx) if ungrouped_options: default_group = OptionGroup( 'Other options' if records_by_group else 'Options') default_group.options = ungrouped_options records_by_group[default_group] = default_group.get_help_records(ctx) if self.align_option_groups and len(records_by_group) > 1: option_name_width = min( max_option_width, max(len(rec[0]) for records in records_by_group.values() for rec in records) ) # This is a hacky way to have aligned options groups: pad the first column # of the first entry of each group to reach option_name_width for records in records_by_group.values(): first = records[0] pad_width = option_name_width - len(first[0]) if pad_width <= 0: continue records[0] = (first[0] + ' ' * pad_width, first[1]) for group, records in records_by_group.items(): self.format_option_group(ctx, formatter, group, help_records=records)
[docs]def option( *param_decls, group: Optional[OptionGroup] = None, cls: Type[click.Option] = GroupedOption, **attrs ) -> OptionGroupDecorator: def decorator(f): func = click.option(*param_decls, cls=cls, **attrs)(f) new_option = func.__click_params__[-1] new_option.group = group return func return decorator
@overload def option_group( name: str, help: str, *options: OptionDecorator, constraint: Optional[Constraint] = None, ) -> OptionGroupDecorator: ... # pragma: no cover @overload def option_group( name: str, *options: OptionDecorator, help: Optional[str] = None, constraint: Optional[Constraint] = None, ) -> OptionGroupDecorator: ... # pragma: no cover
[docs]def option_group(name, *args, **kwargs): """ Attaches an option group to the command. This decorator is overloaded with two signatures:: @option_group(name: str, *options, help: Optional[str] = None) @option_group(name: str, help: str, *options) In other words, if the second position argument is a string, it is interpreted as the "help" argument. Otherwise, it is interpreted as the first option; in this case, you can still pass the help as keyword argument. :param name: a mandatory name/title for the group :param help: an optional help string for the group :param options: option decorators like `click.option` :param constraint: a ``Constraint`` to validate on this option group :return: a decorator that attaches the contained options to the decorated function """ if args and isinstance(args[0], str): return _option_group(name, options=args[1:], help=args[0], **kwargs) else: return _option_group(name, options=args, **kwargs)
def _option_group( name: str, options: Sequence[OptionDecorator], **kwargs, ) -> OptionGroupDecorator: if not options: raise ValueError('you must provide at least one option') opt_group = OptionGroup(name, **kwargs) def decorator(f): for opt_decorator in reversed(options): # Note: the assignment is just a precaution, as both click.option # and @cloup.option currently return the same f f = opt_decorator(f) new_option = f.__click_params__[-1] if has_option_group(new_option): raise ValueError( f'{new_option} was first assigned to {new_option.group} and then ' f'passed as argument to @option_group({name!r}, ...)' ) new_option.group = opt_group return f return decorator