Skip to content

Commit

Permalink
Merge pull request #23 from click-contrib/v0.5.2
Browse files Browse the repository at this point in the history
v0.5.2

- Do not use default option group name. An empty group name will not be displayed (#22)
- Slightly edited error messages
- All arguments except name in optgroup decorator must be keyword-only
  • Loading branch information
espdev authored Nov 28, 2020
2 parents 223ae0c + c463e31 commit fba6855
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 89 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v0.5.2 (28.11.2020)

* Do not use default option group name. An empty group name will not be displayed
* Slightly edited error messages
* All arguments except `name` in `optgroup` decorator must be keyword-only

## v0.5.1 (14.06.2020)

* Fix incompatibility with autocomplete: out of the box Click completion and click-repl (Issue [#14](https://github.com/click-contrib/click-option-group/issues/14))
Expand Down
2 changes: 1 addition & 1 deletion click_option_group/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Option groups missing in Click
:copyright: © 2019 by Eugene Prilepin
:copyright: © 2019-2020 by Eugene Prilepin
:license: BSD, see LICENSE for more details.
"""

Expand Down
129 changes: 73 additions & 56 deletions click_option_group/_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import typing as ty
from typing import Optional, List, Tuple, Dict, Set

import collections
import weakref
import inspect
Expand Down Expand Up @@ -91,8 +92,8 @@ class OptionGroup:
:param help: the group help text or None
"""

def __init__(self, name: ty.Optional[str] = None, *,
hidden=False, help: ty.Optional[str] = None) -> None: # noqa
def __init__(self, name: Optional[str] = None, *,
hidden=False, help: Optional[str] = None) -> None: # noqa
self._name = name if name else ''
self._help = inspect.cleandoc(help if help else '')
self._hidden = hidden
Expand All @@ -117,30 +118,18 @@ def help(self) -> str:
return self._help

@property
def name_extra(self) -> ty.List[str]:
def name_extra(self) -> List[str]:
"""Returns extra name attributes for the group
"""
return []

@property
def forbidden_option_attrs(self) -> ty.List[str]:
def forbidden_option_attrs(self) -> List[str]:
"""Returns the list of forbidden option attributes for the group
"""
return []

def get_default_name(self, ctx: click.Context) -> str:
"""Returns default name for the group
:param ctx: Click Context object
:return: group default name
"""
if self.name:
return self.name

option_names = '|'.join(self.get_option_names(ctx))
return f'({option_names})'

def get_help_record(self, ctx: click.Context) -> ty.Optional[ty.Tuple[str, str]]:
def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]:
"""Returns the help record for the group
:param ctx: Click Context object
Expand All @@ -149,14 +138,20 @@ def get_help_record(self, ctx: click.Context) -> ty.Optional[ty.Tuple[str, str]]
if all(o.hidden for o in self.get_options(ctx).values()):
return None

name = self.get_default_name(ctx)
name = self.name
help_ = self.help if self.help else ''

extra = ', '.join(self.name_extra)
if extra:
extra = f'[{extra}]'

name = f'{name}: {extra}'
if name:
name = f'{name}: {extra}'
elif extra:
name = f'{extra}:'

if not name and not help_:
return None

return name, help_

Expand Down Expand Up @@ -186,17 +181,17 @@ def decorator(func):

return decorator

def get_options(self, ctx: click.Context) -> ty.Dict[str, GroupedOption]:
def get_options(self, ctx: click.Context) -> Dict[str, GroupedOption]:
"""Returns the dictionary with group options
"""
return self._options.get(resolve_wrappers(ctx.command.callback), {})

def get_option_names(self, ctx: click.Context) -> ty.List[str]:
def get_option_names(self, ctx: click.Context) -> List[str]:
"""Returns the list with option names ordered by addition in the group
"""
return list(reversed(list(self.get_options(ctx))))

def get_error_hint(self, ctx, option_names: ty.Optional[ty.Set[str]] = None) -> str:
def get_error_hint(self, ctx, option_names: Optional[Set[str]] = None) -> str:
options = self.get_options(ctx)
text = ''

Expand Down Expand Up @@ -250,6 +245,9 @@ def _option_memo(self, func):
option = params[-1]
self._options[func][option.name] = option

def _group_name_str(self) -> str:
return f"'{self.name}'" if self.name else "the"


class RequiredAnyOptionGroup(OptionGroup):
"""Option group with required any options of this group
Expand All @@ -258,29 +256,35 @@ class RequiredAnyOptionGroup(OptionGroup):
"""

@property
def forbidden_option_attrs(self) -> ty.List[str]:
def forbidden_option_attrs(self) -> List[str]:
return ['required']

@property
def name_extra(self) -> ty.List[str]:
def name_extra(self) -> List[str]:
return super().name_extra + ['required_any']

def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: dict) -> None:
if option.name in opts:
return

if all(o.hidden for o in self.get_options(ctx).values()):
error_text = (f'Need at least one non-hidden option in RequiredAnyOptionGroup '
f'"{self.get_default_name(ctx)}".')
raise TypeError(error_text)
cls_name = self.__class__.__name__
group_name = self._group_name_str()

raise TypeError(
f"Need at least one non-hidden option in {group_name} option group ({cls_name})."
)

option_names = set(self.get_options(ctx))

if not option_names.intersection(opts):
error_text = f'Missing one of the required options from "{self.get_default_name(ctx)}" option group:'
error_text += f'\n{self.get_error_hint(ctx)}'
group_name = self._group_name_str()
option_info = self.get_error_hint(ctx)

raise click.UsageError(error_text, ctx=ctx)
raise click.UsageError(
f"At least one of the following options from {group_name} option group is required:\n{option_info}",
ctx=ctx
)


class RequiredAllOptionGroup(OptionGroup):
Expand All @@ -290,23 +294,25 @@ class RequiredAllOptionGroup(OptionGroup):
"""

@property
def forbidden_option_attrs(self) -> ty.List[str]:
def forbidden_option_attrs(self) -> List[str]:
return ['required', 'hidden']

@property
def name_extra(self) -> ty.List[str]:
def name_extra(self) -> List[str]:
return super().name_extra + ['required_all']

def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: dict) -> None:
option_names = set(self.get_options(ctx))

if not option_names.issubset(opts):
group_name = self._group_name_str()
required_names = option_names.difference(option_names.intersection(opts))
option_info = self.get_error_hint(ctx, required_names)

error_text = f'Missing required options from "{self.get_default_name(ctx)}" option group:'
error_text += f'\n{self.get_error_hint(ctx, required_names)}'

raise click.UsageError(error_text, ctx=ctx)
raise click.UsageError(
f"Missing required options from {group_name} option group:\n{option_info}",
ctx=ctx
)


class MutuallyExclusiveOptionGroup(OptionGroup):
Expand All @@ -317,11 +323,11 @@ class MutuallyExclusiveOptionGroup(OptionGroup):
"""

@property
def forbidden_option_attrs(self) -> ty.List[str]:
def forbidden_option_attrs(self) -> List[str]:
return ['required']

@property
def name_extra(self) -> ty.List[str]:
def name_extra(self) -> List[str]:
return super().name_extra + ['mutually_exclusive']

def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: dict) -> None:
Expand All @@ -330,9 +336,14 @@ def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: d
given_option_count = len(given_option_names)

if given_option_count > 1:
error_text = 'The given mutually exclusive options cannot be used at the same time:'
error_text += f'\n{self.get_error_hint(ctx, given_option_names)}'
raise click.UsageError(error_text, ctx=ctx)
group_name = self._group_name_str()
option_info = self.get_error_hint(ctx, given_option_names)

raise click.UsageError(
f"Mutually exclusive options from {group_name} option group "
f"cannot be used at the same time:\n{option_info}",
ctx=ctx
)


class RequiredMutuallyExclusiveOptionGroup(MutuallyExclusiveOptionGroup):
Expand All @@ -343,7 +354,7 @@ class RequiredMutuallyExclusiveOptionGroup(MutuallyExclusiveOptionGroup):
"""

@property
def name_extra(self) -> ty.List[str]:
def name_extra(self) -> List[str]:
return super().name_extra + ['required']

def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: dict) -> None:
Expand All @@ -353,34 +364,40 @@ def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: d
given_option_names = option_names.intersection(opts)

if len(given_option_names) == 0:
error_text = ('Missing one of the required mutually exclusive options from '
f'"{self.get_default_name(ctx)}" option group:')
error_text += f'\n{self.get_error_hint(ctx)}'
raise click.UsageError(error_text, ctx=ctx)
group_name = self._group_name_str()
option_info = self.get_error_hint(ctx)

raise click.UsageError(
"Missing one of the required mutually exclusive options from "
f"{group_name} option group:\n{option_info}",
ctx=ctx
)


class AllOptionGroup(OptionGroup):
"""Option group with required all/none options of this group
`AllOptionGroup` defines the behavior:
- All options from the group must be set or None must be set.
- All options from the group must be set or None must be set
"""

@property
def forbidden_option_attrs(self) -> ty.List[str]:
def forbidden_option_attrs(self) -> List[str]:
return ['required', 'hidden']

@property
def name_extra(self) -> ty.List[str]:
def name_extra(self) -> List[str]:
return super().name_extra + ['all_or_none']

def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: dict) -> None:
option_names = set(self.get_options(ctx))

if not option_names.isdisjoint(opts) and option_names.intersection(opts) != option_names:
error_text = f'All options should be specified or None should be specified from the group ' \
f'"{self.get_default_name(ctx)}".'
error_text += f'\nMissing required options from "{self.get_default_name(ctx)}" option group.'
error_text += f'\n{self.get_error_hint(ctx)}'
error_text += '\n'
raise click.UsageError(error_text, ctx=ctx)
group_name = self._group_name_str()
option_info = self.get_error_hint(ctx)

raise click.UsageError(
f"All options from {group_name} option group should be specified or none should be specified. "
f"Missing required options:\n{option_info}",
ctx=ctx
)
40 changes: 27 additions & 13 deletions click_option_group/_decorators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import typing as ty
from typing import Optional, NamedTuple, List, Tuple, Dict, Any, Type

import collections.abc as abc
import collections
import warnings
Expand All @@ -15,9 +16,9 @@
)


class OptionStackItem(ty.NamedTuple):
param_decls: ty.Tuple[str, ...]
attrs: ty.Dict[str, ty.Any]
class OptionStackItem(NamedTuple):
param_decls: Tuple[str, ...]
attrs: Dict[str, Any]
param_count: int


Expand Down Expand Up @@ -61,29 +62,42 @@ class _OptGroup:
"""

def __init__(self) -> None:
self._decorating_state: ty.Dict[abc.Callable, ty.List[OptionStackItem]] = collections.defaultdict(list)
self._not_attached_options: ty.Dict[abc.Callable, ty.List[click.Option]] = collections.defaultdict(list)
self._decorating_state: Dict[abc.Callable, List[OptionStackItem]] = collections.defaultdict(list)
self._not_attached_options: Dict[abc.Callable, List[click.Option]] = collections.defaultdict(list)
self._outer_frame_index = 1

def __call__(self, name: ty.Optional[str] = None, help: ty.Optional[str] = None,
cls: ty.Optional[ty.Type[OptionGroup]] = None, **attrs):
def __call__(self,
name: Optional[str] = None, *,
help: Optional[str] = None,
cls: Optional[Type[OptionGroup]] = None, **attrs):
"""Creates a new group and collects its options
Creates the option group and registers all grouped options
which were added by `option` decorator.
:param name: Group name or None for deault name
:param help: Group help or None for empty help
:param cls: Option group class that should be inherited from `OptionGroup` class
:param attrs: Additional parameters of option group class
"""
try:
self._outer_frame_index = 2
return self.group(name, cls=cls, help=help, **attrs)
return self.group(name, help=help, cls=cls, **attrs)
finally:
self._outer_frame_index = 1

def group(self, name: ty.Optional[str] = None, *,
cls: ty.Optional[ty.Type[OptionGroup]] = None,
help: ty.Optional[str] = None, **attrs):
def group(self,
name: Optional[str] = None, *,
help: Optional[str] = None,
cls: Optional[Type[OptionGroup]] = None, **attrs):
"""The decorator creates a new group and collects its options
Creates the option group and registers all grouped options
which were added by `option` decorator.
:param name: Group name or None for deault name
:param cls: Option group class that should be inherited from `OptionGroup` class
:param help: Group help or None for empty help
:param cls: Option group class that should be inherited from `OptionGroup` class
:param attrs: Additional parameters of option group class
"""

Expand Down
5 changes: 3 additions & 2 deletions click_option_group/_helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import typing as ty
from typing import List, Tuple

import collections.abc as abc
import random
import string
Expand All @@ -11,7 +12,7 @@
FAKE_OPT_NAME_LEN = 30


def get_callback_and_params(func) -> ty.Tuple[abc.Callable, ty.List[click.Option]]:
def get_callback_and_params(func) -> Tuple[abc.Callable, List[click.Option]]:
"""Returns callback function and its parameters list
:param func: decorated function or click Command
Expand Down
2 changes: 1 addition & 1 deletion click_option_group/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-

__version__ = '0.5.1'
__version__ = '0.5.2'
Loading

0 comments on commit fba6855

Please sign in to comment.