pax_global_header00006660000000000000000000000064144407214550014520gustar00rootroot0000000000000052 comment=515950e26672c347922dbe3c7b2cc8ac0bd33856 click-option-group-0.5.6/000077500000000000000000000000001444072145500152555ustar00rootroot00000000000000click-option-group-0.5.6/.flake8000066400000000000000000000000371444072145500164300ustar00rootroot00000000000000[flake8] max-line-length = 120 click-option-group-0.5.6/.github/000077500000000000000000000000001444072145500166155ustar00rootroot00000000000000click-option-group-0.5.6/.github/dependabot.yml000066400000000000000000000001751444072145500214500ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily open-pull-requests-limit: 10 click-option-group-0.5.6/.github/workflows/000077500000000000000000000000001444072145500206525ustar00rootroot00000000000000click-option-group-0.5.6/.github/workflows/main.yaml000066400000000000000000000030251444072145500224620ustar00rootroot00000000000000name: main on: push: branches: [ master ] pull_request: branches: [ master ] jobs: tests: runs-on: ${{ matrix.platform }} strategy: max-parallel: 8 matrix: platform: - ubuntu-latest python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel python -m pip install flake8 python -m pip install -e .[tests_cov] - name: Unit Tests run: pytest --color=yes --cov=click_option_group --cov-report=term --cov-report=lcov:coverage.info - name: Coveralls if: ${{ matrix.python-version == '3.8' }} uses: coverallsapp/github-action@v2 with: format: lcov file: coverage.info - name: flake8 Static Analysis if: ${{ matrix.python-version == '3.11' }} run: flake8 click_option_group/ tests/ setup.py docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel python -m pip install -e .[docs] - name: Build Docs run: make -C docs/ html click-option-group-0.5.6/.gitignore000066400000000000000000000015051444072145500172460ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Sphinx documentation docs/_build/ # IPython profile_default/ ipython_config.py # pyenv .python-version # Environments .env .venv/ env/ venv/ ENV/ env.bak/ venv.bak/ # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # PyCharm .idea/ click-option-group-0.5.6/.readthedocs.yml000066400000000000000000000010431444072145500203410ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Build documentation in the docs/ directory with Sphinx sphinx: builder: html configuration: docs/conf.py # Optionally build your docs in additional formats such as PDF and ePub formats: - pdf # Optionally set the version of Python and requirements required to build your docs python: version: 3.7 install: - method: pip path: . extra_requirements: - docs click-option-group-0.5.6/CHANGELOG.md000066400000000000000000000047571444072145500171030ustar00rootroot00000000000000# Changelog ## v0.5.6 (09.06.2023) * Add `optgroup.help_option` decorator to add help option to the group (PR [#50](https://github.com/click-contrib/click-option-group/pull/50)) * Use GitHub Actions instead of Travis CI for CI * Delete tox runner * Add Python 3.11 to the setup classifiers ## v0.5.5 (12.10.2022) * Add `tests/` directory to tarball * Add `tests_cov` extra dependencies for testing with coverage ## v0.5.4 (12.10.2022) * Move frame gathering into error code path (PR [#34](https://github.com/click-contrib/click-option-group/pull/34)) * Fix typos (PR [#37](https://github.com/click-contrib/click-option-group/pull/37)) * PEP 561 support (PR [#42](https://github.com/click-contrib/click-option-group/pull/42)) * Update docs dependencies and Travis CI Python version matrix (PR [#43](https://github.com/click-contrib/click-option-group/pull/43)) ## v0.5.3 (14.05.2021) * Update Click dependency version to `<9` (Issue [#33](https://github.com/click-contrib/click-option-group/issues/33)) ## 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)) ## v0.5.0 (10.06.2020) * Add `AllOptionGroup` class: all options from the group must be set or none must be set (PR [#13](https://github.com/click-contrib/click-option-group/pull/13)) * Fix type hints * Update docs ## v0.4.0 (18.05.2020) * Support multi-layer wrapped functions (PR [#10](https://github.com/click-contrib/click-option-group/pull/10)) * Fix flake8 issues ## v0.3.1 * Add `hidden=True` to `_GroupTitleFakeOption` as a temporary workaroud for issue [#4](https://github.com/click-contrib/click-option-group/issues/4) ## v0.3.0 * Add support for hidden options inside groups (PR [#2](https://github.com/click-contrib/click-option-group/pull/2)) ## v0.2.3 * Transfer the repo to click-contrib organisation ## v0.2.2 * Add true lineno in warning when declaring empty option group * Update readme ## v0.2.1 * Use RuntimeWarning and stacklevel 2 when declaring empty option group * Update readme ## v0.2.0 * Implement `RequiredMutuallyExclusiveOptionGroup` class instead of `required` argument for `MutuallyExclusiveOptionGroup` * Add tests with 100% coverage * Update readme ## v0.1.0 * First public release click-option-group-0.5.6/LICENSE000066400000000000000000000027631444072145500162720ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2019, Eugene Prilepin All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. click-option-group-0.5.6/MANIFEST.in000066400000000000000000000001041444072145500170060ustar00rootroot00000000000000include README.md include CHANGELOG.md include LICENSE graft tests/ click-option-group-0.5.6/README.md000066400000000000000000000076331444072145500165450ustar00rootroot00000000000000# click-option-group [![PyPI version](https://img.shields.io/pypi/v/click-option-group.svg)](https://pypi.python.org/pypi/click-option-group) [![Build status](https://travis-ci.org/click-contrib/click-option-group.svg?branch=master)](https://travis-ci.org/click-contrib/click-option-group) [![Coverage Status](https://coveralls.io/repos/github/click-contrib/click-option-group/badge.svg?branch=master)](https://coveralls.io/github/click-contrib/click-option-group?branch=master) ![Supported Python versions](https://img.shields.io/pypi/pyversions/click-option-group.svg) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) **click-option-group** is a Click-extension package that adds option groups missing in [Click](https://github.com/pallets/click/). ## Aim and Motivation Click is a package for creating powerful and beautiful command line interfaces (CLI) in Python, but it has no the functionality for creating option groups. Option groups are convenient mechanism for logical structuring CLI, also it allows you to set the specific behavior and set the relationship among grouped options (mutually exclusive options for example). Moreover, [argparse](https://docs.python.org/3/library/argparse.html) stdlib package contains this functionality out of the box. At the same time, many Click users need this functionality. You can read interesting discussions about it in the following issues: * [issue 257](https://github.com/pallets/click/issues/257) * [issue 373](https://github.com/pallets/click/issues/373) * [issue 509](https://github.com/pallets/click/issues/509) * [issue 1137](https://github.com/pallets/click/issues/1137) The aim of this package is to provide group options with extensible functionality using canonical and clean API (Click-like API as far as possible). ## Quickstart ### Installing Install and update using pip: ```bash pip install -U click-option-group ``` ### A Simple Example Here is a simple example how to use option groups in your Click-based CLI. Just use `optgroup` for declaring option groups by decorating your command function in Click-like API style. ```python # app.py import click from click_option_group import optgroup, RequiredMutuallyExclusiveOptionGroup @click.command() @optgroup.group('Server configuration', help='The configuration of some server connection') @optgroup.option('-h', '--host', default='localhost', help='Server host name') @optgroup.option('-p', '--port', type=int, default=8888, help='Server port') @optgroup.option('-n', '--attempts', type=int, default=3, help='The number of connection attempts') @optgroup.option('-t', '--timeout', type=int, default=30, help='The server response timeout') @optgroup.group('Input data sources', cls=RequiredMutuallyExclusiveOptionGroup, help='The sources of the input data') @optgroup.option('--tsv-file', type=click.File(), help='CSV/TSV input data file') @optgroup.option('--json-file', type=click.File(), help='JSON input data file') @click.option('--debug/--no-debug', default=False, help='Debug flag') def cli(**params): print(params) if __name__ == '__main__': cli() ``` ```bash $ python app.py --help Usage: app.py [OPTIONS] Options: Server configuration: The configuration of some server connection -h, --host TEXT Server host name -p, --port INTEGER Server port -n, --attempts INTEGER The number of connection attempts -t, --timeout INTEGER The server response timeout Input data sources: [mutually_exclusive, required] The sources of the input data --tsv-file FILENAME CSV/TSV input data file --json-file FILENAME JSON input data file --debug / --no-debug Debug flag --help Show this message and exit. ``` ## Documentation https://click-option-group.readthedocs.io click-option-group-0.5.6/click_option_group/000077500000000000000000000000001444072145500211465ustar00rootroot00000000000000click-option-group-0.5.6/click_option_group/__init__.py000066400000000000000000000013271444072145500232620ustar00rootroot00000000000000# -*- coding: utf-8 -*- """ click-option-group ~~~~~~~~~~~~~~~~~~ Option groups missing in Click :copyright: © 2019-2020 by Eugene Prilepin :license: BSD, see LICENSE for more details. """ from ._version import __version__ from ._core import ( GroupedOption, OptionGroup, RequiredAnyOptionGroup, AllOptionGroup, RequiredAllOptionGroup, MutuallyExclusiveOptionGroup, RequiredMutuallyExclusiveOptionGroup, ) from ._decorators import optgroup __all__ = [ '__version__', 'optgroup', 'GroupedOption', 'OptionGroup', 'RequiredAnyOptionGroup', 'AllOptionGroup', 'RequiredAllOptionGroup', 'MutuallyExclusiveOptionGroup', 'RequiredMutuallyExclusiveOptionGroup', ] click-option-group-0.5.6/click_option_group/_core.py000066400000000000000000000334621444072145500226170ustar00rootroot00000000000000# -*- coding: utf-8 -*- import collections import inspect import weakref from typing import ( Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Union, ) import click from click.core import augment_usage_errors from ._helpers import ( get_callback_and_params, get_fake_option_name, raise_mixing_decorators_error, resolve_wrappers ) FC = Union[Callable, click.Command] class GroupedOption(click.Option): """Represents grouped (related) optional values The class should be used only with `OptionGroup` class for creating grouped options. :param param_decls: option declaration tuple :param group: `OptionGroup` instance (the group for this option) :param attrs: additional option attributes """ def __init__(self, param_decls: Optional[Sequence[str]] = None, *, group: 'OptionGroup', **attrs: Any): super().__init__(param_decls, **attrs) for attr in group.forbidden_option_attrs: if attr in attrs: raise TypeError( f"'{attr}' attribute is not allowed for '{type(group).__name__}' option `{self.name}'.") self.__group = group @property def group(self) -> 'OptionGroup': """Returns the reference to the group for this option :return: `OptionGroup` the group instance for this option """ return self.__group def handle_parse_result( self, ctx: click.Context, opts: Mapping[str, Any], args: List[str] ) -> Tuple[Any, List[str]]: with augment_usage_errors(ctx, param=self): if not ctx.resilient_parsing: self.group.handle_parse_result(self, ctx, opts) return super().handle_parse_result(ctx, opts, args) def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: help_record = super().get_help_record(ctx) if help_record is None: # this happens if the option is hidden return help_record opts, opt_help = help_record formatter = ctx.make_formatter() with formatter.indentation(): indent = ' ' * formatter.current_indent return f'{indent}{opts}', opt_help class _GroupTitleFakeOption(click.Option): """The helper `Option` class to display option group title in help """ def __init__( self, param_decls: Optional[Sequence[str]] = None, *, group: 'OptionGroup', **attrs: Any ) -> None: self.__group = group super().__init__(param_decls, hidden=True, expose_value=False, help=group.help, **attrs) # We remove parsed opts for the fake options just in case. # For example it is workaround for correct click-repl autocomplete self.opts = [] self.secondary_opts = [] def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: return self.__group.get_help_record(ctx) class OptionGroup: """Option group manages grouped (related) options The class is used for creating the groups of options. The class can de used as based class to implement specific behavior for grouped options. :param name: the group name. If it is not set the default group name will be used :param help: the group help text or None """ def __init__( self, name: Optional[str] = None, *, hidden: bool = False, help: Optional[str] = None ) -> None: self._name = name if name else '' self._help = inspect.cleandoc(help if help else '') self._hidden = hidden self._options: Mapping[Any, Any] = collections.defaultdict(weakref.WeakValueDictionary) self._group_title_options = weakref.WeakValueDictionary() @property def name(self) -> str: """Returns the group name or empty string if it was not set :return: group name """ return self._name @property def help(self) -> str: """Returns the group help or empty string if it was not set :return: group help """ return self._help @property def name_extra(self) -> List[str]: """Returns extra name attributes for the group """ return [] @property def forbidden_option_attrs(self) -> List[str]: """Returns the list of forbidden option attributes for the group """ return [] def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: """Returns the help record for the group :param ctx: Click Context object :return: the tuple of two fileds: `(name, help)` """ if all(o.hidden for o in self.get_options(ctx).values()): return None name = self.name help_ = self.help if self.help else '' extra = ', '.join(self.name_extra) if extra: extra = f'[{extra}]' if name: name = f'{name}: {extra}' elif extra: name = f'{extra}:' if not name and not help_: return None return name, help_ def option(self, *param_decls: str, **attrs: Any) -> Callable: """Decorator attaches a grouped option to the command The decorator is used for adding options to the group and to the Click-command """ def decorator(func: FC) -> FC: option_attrs = attrs.copy() option_attrs.setdefault('cls', GroupedOption) if self._hidden: option_attrs.setdefault('hidden', self._hidden) if not issubclass(option_attrs['cls'], GroupedOption): raise TypeError("'cls' argument must be a subclass of 'GroupedOption' class.") self._check_mixing_decorators(func) func = click.option(*param_decls, group=self, **option_attrs)(func) self._option_memo(func) # Add the fake invisible option to use for print nice title help for grouped options self._add_title_fake_option(func) return func return decorator 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) -> 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: click.Context, option_names: Optional[Set[str]] = None) -> str: options = self.get_options(ctx) text = '' for name, opt in reversed(list(options.items())): if option_names and name not in option_names: continue text += f' {opt.get_error_hint(ctx)}\n' if text: text = text[:-1] return text def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: Mapping[str, Any]) -> None: """The method should be used for adding specific behavior and relation for options in the group """ def _check_mixing_decorators(self, func: Callable) -> None: func, params = get_callback_and_params(func) if not params or func not in self._options: return last_param = params[-1] title_option = self._group_title_options[func] options = self._options[func] if last_param.name != title_option.name and last_param.name not in options: raise_mixing_decorators_error(last_param, func) def _add_title_fake_option(self, func: FC) -> None: callback, params = get_callback_and_params(func) if callback not in self._group_title_options: func = click.option(get_fake_option_name(), group=self, cls=_GroupTitleFakeOption)(func) _, params = get_callback_and_params(func) self._group_title_options[callback] = params[-1] title_option = self._group_title_options[callback] last_option = params[-1] if title_option.name != last_option.name: # Hold title fake option on the top of the option group title_index = params.index(title_option) params[-1], params[title_index] = params[title_index], params[-1] def _option_memo(self, func: Callable) -> None: func, params = get_callback_and_params(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 `RequiredAnyOptionGroup` defines the behavior: At least one option from the group must be set. """ @property def forbidden_option_attrs(self) -> List[str]: return ['required'] @property def name_extra(self) -> List[str]: return super().name_extra + ['required_any'] def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: Mapping[str, Any]) -> None: if option.name in opts: return if all(o.hidden for o in self.get_options(ctx).values()): 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): group_name = self._group_name_str() option_info = self.get_error_hint(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): """Option group with required all options of this group `RequiredAllOptionGroup` defines the behavior: All options from the group must be set. """ @property def forbidden_option_attrs(self) -> List[str]: return ['required', 'hidden'] @property def name_extra(self) -> List[str]: return super().name_extra + ['required_all'] def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: Mapping[str, Any]) -> 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) raise click.UsageError( f"Missing required options from {group_name} option group:\n{option_info}", ctx=ctx ) class MutuallyExclusiveOptionGroup(OptionGroup): """Option group with mutually exclusive behavior for grouped options `MutuallyExclusiveOptionGroup` defines the behavior: - Only one or none option from the group must be set """ @property def forbidden_option_attrs(self) -> List[str]: return ['required'] @property def name_extra(self) -> List[str]: return super().name_extra + ['mutually_exclusive'] def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: Mapping[str, Any]) -> None: option_names = set(self.get_options(ctx)) given_option_names = option_names.intersection(opts) given_option_count = len(given_option_names) if given_option_count > 1: 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): """Option group with required and mutually exclusive behavior for grouped options `RequiredMutuallyExclusiveOptionGroup` defines the behavior: - Only one required option from the group must be set """ @property def name_extra(self) -> List[str]: return super().name_extra + ['required'] def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: Mapping[str, Any]) -> None: super().handle_parse_result(option, ctx, opts) option_names = set(self.get_option_names(ctx)) given_option_names = option_names.intersection(opts) if len(given_option_names) == 0: 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 """ @property def forbidden_option_attrs(self) -> List[str]: return ['required', 'hidden'] @property def name_extra(self) -> List[str]: return super().name_extra + ['all_or_none'] def handle_parse_result(self, option: GroupedOption, ctx: click.Context, opts: Mapping[str, Any]) -> None: option_names = set(self.get_options(ctx)) if not option_names.isdisjoint(opts) and option_names.intersection(opts) != option_names: 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 ) click-option-group-0.5.6/click_option_group/_decorators.py000066400000000000000000000177201444072145500240330ustar00rootroot00000000000000# -*- coding: utf-8 -*- from typing import (Callable, Optional, NamedTuple, List, Tuple, Dict, Any, Type, TypeVar) import collections import warnings import inspect import click from ._core import OptionGroup from ._helpers import ( get_callback_and_params, raise_mixing_decorators_error, ) T = TypeVar('T') F = TypeVar('F', bound=Callable) Decorator = Callable[[F], F] class OptionStackItem(NamedTuple): param_decls: Tuple[str, ...] attrs: Dict[str, Any] param_count: int class _NotAttachedOption(click.Option): """The helper class to catch grouped options which were not attached to the group Raises TypeError if not attached options exist. """ def __init__(self, param_decls=None, *, all_not_attached_options, **attrs): super().__init__(param_decls, expose_value=False, hidden=False, is_eager=True, **attrs) self._all_not_attached_options = all_not_attached_options def handle_parse_result(self, ctx, opts, args): options_error_hint = '' for option in reversed(self._all_not_attached_options[ctx.command.callback]): options_error_hint += f' {option.get_error_hint(ctx)}\n' options_error_hint = options_error_hint[:-1] raise TypeError(( f"Missing option group decorator in '{ctx.command.name}' command for the following grouped options:\n" f"{options_error_hint}\n")) class _OptGroup: """A helper class to manage creating groups and group options via decorators The class provides two decorator-methods: `group`/`__call__` and `option`. These decorators should be used for adding grouped options. The class have single global instance `optgroup` that should be used in most cases. The example of usage:: ... @optgroup('Group 1', help='option group 1') @optgroup.option('--foo') @optgroup.option('--bar') @optgroup.group('Group 2', help='option group 2') @optgroup.option('--spam') ... """ def __init__(self) -> None: self._decorating_state: Dict[Callable, List[OptionStackItem]] = collections.defaultdict(list) self._not_attached_options: Dict[Callable, List[click.Option]] = collections.defaultdict(list) self._outer_frame_index = 1 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 default 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, help=help, cls=cls, **attrs) finally: self._outer_frame_index = 1 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 default 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 """ if not cls: cls = OptionGroup else: if not issubclass(cls, OptionGroup): raise TypeError("'cls' must be a subclass of 'OptionGroup' class.") def decorator(func): callback, params = get_callback_and_params(func) if callback not in self._decorating_state: frame = inspect.getouterframes(inspect.currentframe())[self._outer_frame_index] lineno = frame.lineno with_name = f' "{name}"' if name else '' warnings.warn((f'The empty option group{with_name} was found (line {lineno}) ' f'for "{callback.__name__}". The group will not be added.'), RuntimeWarning, stacklevel=2) return func option_stack = self._decorating_state.pop(callback) [params.remove(opt) for opt in self._not_attached_options.pop(callback)] self._check_mixing_decorators(callback, option_stack, self._filter_not_attached(params)) attrs['help'] = help try: option_group = cls(name, **attrs) except TypeError as err: message = str(err).replace('__init__()', f"'{cls.__name__}' constructor") raise TypeError(message) from err for item in option_stack: func = option_group.option(*item.param_decls, **item.attrs)(func) return func return decorator def option(self, *param_decls, **attrs) -> Decorator: """The decorator adds a new option to the group The decorator is lazy. It adds option decls and attrs. All options will be registered by `group` decorator. :param param_decls: option declaration tuple :param attrs: additional option attributes and parameters """ def decorator(func): callback, params = get_callback_and_params(func) option_stack = self._decorating_state[callback] params = self._filter_not_attached(params) self._check_mixing_decorators(callback, option_stack, params) self._add_not_attached_option(func, param_decls) option_stack.append(OptionStackItem(param_decls, attrs, len(params))) return func return decorator def help_option(self, *param_decls, **attrs) -> Decorator: """This decorator adds a help option to the group, which prints the command's help text and exits. """ if not param_decls: param_decls = ('--help',) attrs.setdefault('is_flag', True) attrs.setdefault('is_eager', True) attrs.setdefault('expose_value', False) attrs.setdefault('help', 'Show this message and exit.') if 'callback' not in attrs: def callback(ctx, _, value): if not value or ctx.resilient_parsing: return click.echo(ctx.get_help(), color=ctx.color) ctx.exit() attrs['callback'] = callback return self.option(*param_decls, **attrs) def _add_not_attached_option(self, func, param_decls) -> None: click.option( *param_decls, all_not_attached_options=self._not_attached_options, cls=_NotAttachedOption )(func) callback, params = get_callback_and_params(func) self._not_attached_options[callback].append(params[-1]) @staticmethod def _filter_not_attached(options: List[T]) -> List[T]: return [opt for opt in options if not isinstance(opt, _NotAttachedOption)] @staticmethod def _check_mixing_decorators(callback, options_stack, params): if options_stack: last_state = options_stack[-1] if len(params) > last_state.param_count: raise_mixing_decorators_error(params[-1], callback) optgroup = _OptGroup() """Provides decorators for creating option groups and adding grouped options Decorators: - `group` is used for creating an option group - `option` is used for adding options to a group Example:: @optgroup.group('Group 1', help='option group 1') @optgroup.option('--foo') @optgroup.option('--bar') @optgroup.group('Group 2', help='option group 2') @optgroup.option('--spam') """ click-option-group-0.5.6/click_option_group/_helpers.py000066400000000000000000000025361444072145500233270ustar00rootroot00000000000000# -*- coding: utf-8 -*- from typing import Callable, Tuple, List, TypeVar, NoReturn import random import string import click F = TypeVar('F', bound=Callable) FAKE_OPT_NAME_LEN = 30 def get_callback_and_params(func) -> Tuple[Callable, List[click.Option]]: """Returns callback function and its parameters list :param func: decorated function or click Command :return: (callback, params) """ if isinstance(func, click.Command): params = func.params func = func.callback else: params = getattr(func, '__click_params__', []) func = resolve_wrappers(func) return func, params def get_fake_option_name(name_len: int = FAKE_OPT_NAME_LEN, prefix: str = 'fake') -> str: return f'--{prefix}-' + ''.join(random.choices(string.ascii_lowercase, k=name_len)) def raise_mixing_decorators_error(wrong_option: click.Option, callback: Callable) -> NoReturn: error_hint = wrong_option.opts or [wrong_option.name] raise TypeError(( "Grouped options must not be mixed with regular parameters while adding by decorator. " f"Check decorator position for {error_hint} option in '{callback.__name__}'." )) def resolve_wrappers(f: F) -> F: """Get the underlying function behind any level of function wrappers.""" return resolve_wrappers(f.__wrapped__) if hasattr(f, "__wrapped__") else f click-option-group-0.5.6/click_option_group/_version.py000066400000000000000000000000571444072145500233460ustar00rootroot00000000000000# -*- coding: utf-8 -*- __version__ = '0.5.6' click-option-group-0.5.6/click_option_group/py.typed000066400000000000000000000000001444072145500226330ustar00rootroot00000000000000click-option-group-0.5.6/docs/000077500000000000000000000000001444072145500162055ustar00rootroot00000000000000click-option-group-0.5.6/docs/Makefile000066400000000000000000000011721444072145500176460ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) click-option-group-0.5.6/docs/api.rst000066400000000000000000000043421444072145500175130ustar00rootroot00000000000000.. _api: API Reference ============= .. currentmodule:: click_option_group .. autosummary:: :nosignatures: optgroup GroupedOption OptionGroup RequiredAnyOptionGroup AllOptionGroup RequiredAllOptionGroup MutuallyExclusiveOptionGroup RequiredMutuallyExclusiveOptionGroup | .. py:class:: optgroup A global instance of the helper class to manage creating groups and group options via decorators The class provides two decorator-methods: ``group``/``__call__`` and ``option``. These decorators should be used for adding grouped options. The class have single global instance ``optgroup`` that should be used in most cases. The example of usage:: from click_option_group import optgroup ... @optgroup('Group 1', help='option group 1') @optgroup.option('--foo') @optgroup.option('--bar') @optgroup.group('Group 2', help='option group 2') @optgroup.option('--spam') ... .. py:method:: group(name, *, cls, help, **attrs) The decorator creates a new group and collects its options Creates the option group and registers all grouped options which were added by :func:`option` decorator. :param name: Group name or None for deault name :param cls: Option group class that should be inherited from :class:`OptionGroup` class :param help: Group help or None for empty help :param attrs: Additional parameters of option group class .. py:method:: option(*param_decls, **attrs) The decorator adds a new option to the group The decorator is lazy. It adds option decls and attrs. All options will be registered by :func:`group` decorator. :param param_decls: option declaration tuple :param attrs: additional option attributes and parameters ---- .. autoclass:: GroupedOption :members: ---- .. autoclass:: OptionGroup :members: ---- .. autoclass:: RequiredAnyOptionGroup :members: ---- .. autoclass:: AllOptionGroup :members: ---- .. autoclass:: RequiredAllOptionGroup :members: ---- .. autoclass:: MutuallyExclusiveOptionGroup :members: ---- .. autoclass:: RequiredMutuallyExclusiveOptionGroup :members: click-option-group-0.5.6/docs/changelog.rst000066400000000000000000000000571444072145500206700ustar00rootroot00000000000000.. _changelog: .. mdinclude:: ../CHANGELOG.md click-option-group-0.5.6/docs/conf.py000066400000000000000000000053151444072145500175100ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. import os import sys sys.path.insert(0, os.path.abspath('.')) from pallets_sphinx_themes import ProjectLink from click_option_group import __version__ # noqa # -- Project information ----------------------------------------------------- project = 'click-option-group' copyright = '2019-2020, Eugene Prilepin' author = 'Eugene Prilepin' # The full version, including alpha/beta/rc tags release = __version__ # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.intersphinx', 'pallets_sphinx_themes', 'm2r2', ] autodoc_member_order = 'bysource' intersphinx_mapping = { 'Click': ('https://click.palletsprojects.com', None) } # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'click' html_context = { "project_links": [ ProjectLink("PyPI releases", "https://pypi.org/project/click-option-group/"), ProjectLink("Source Code", "https://github.com/click-contrib/click-option-group/"), ProjectLink("Issue Tracker", "https://github.com/click-contrib/click-option-group/issues/"), ] } html_sidebars = { "index": ["project.html", "localtoc.html", "searchbox.html"], "**": ["localtoc.html", "relations.html", "searchbox.html"], } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] click-option-group-0.5.6/docs/index.rst000066400000000000000000000045651444072145500200600ustar00rootroot00000000000000.. click-option-group documentation master file, created by sphinx-quickstart on Sat Jan 18 02:32:05 2020. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. click-option-group ================== **click-option-group** is a `Click `_-extension package that adds option groups missing in Click. Aim and Motivation ------------------ Click is a package for creating powerful and beautiful command line interfaces (CLI) in Python, but it has no the functionality for creating option groups. Option groups are convenient mechanism for logical structuring CLI, also it allows you to set the specific behavior and set the relationship among grouped options (mutually exclusive options for example). Moreover, `argparse `_ stdlib package contains this functionality out of the box. At the same time, many Click users need this functionality. You can read interesting discussions about it in the following issues: * `issue 257 `_ * `issue 373 `_ * `issue 509 `_ * `issue 1137 `_ The aim of this package is to provide group options with extensible functionality using canonical and clean API (Click-like API as far as possible). Installing ---------- You can install and update click-option-group using pip:: pip install -U click-option-group Quickstart ---------- Here is a simple example how to use option groups in your Click-based CLI. .. code-block:: python import click from click_option_group import optgroup @click.command() @optgroup.group('Server configuration', help='The configuration of some server connection') @optgroup.option('-h', '--host', default='localhost', help='Server host name') @optgroup.option('-p', '--port', type=int, default=8888, help='Server port') @click.option('--debug/--no-debug', default=False, help='Debug flag') def cli(host, port, debug): print(params) if __name__ == '__main__': cli() Contents -------- .. toctree:: :maxdepth: 2 tutorial api changelog Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` click-option-group-0.5.6/docs/make.bat000066400000000000000000000013701444072145500176130ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd click-option-group-0.5.6/docs/tutorial.rst000066400000000000000000000207671444072145500206160ustar00rootroot00000000000000.. _tutorial: Tutorial ======== A Simple Example ---------------- .. currentmodule:: click_option_group Let's start with a simple example. Just use :class:`optgroup` for declaring option groups by decorating your command function in Click-like API style. .. code-block:: python # app.py import click from click_option_group import optgroup, RequiredMutuallyExclusiveOptionGroup @click.command() @optgroup.group('Server configuration', help='The configuration of some server connection') @optgroup.option('-h', '--host', default='localhost', help='Server host name') @optgroup.option('-p', '--port', type=int, default=8888, help='Server port') @optgroup.option('-n', '--attempts', type=int, default=3, help='The number of connection attempts') @optgroup.option('-t', '--timeout', type=int, default=30, help='The server response timeout') @optgroup.group('Input data sources', cls=RequiredMutuallyExclusiveOptionGroup, help='The sources of the input data') @optgroup.option('--tsv-file', type=click.File(), help='CSV/TSV input data file') @optgroup.option('--json-file', type=click.File(), help='JSON input data file') @click.option('--debug/--no-debug', default=False, help='Debug flag') def cli(**params): print(params) if __name__ == '__main__': cli() Now we can see help for our app:: $ python app.py --help Usage: app.py [OPTIONS] Options: Server configuration: The configuration of some server connection -h, --host TEXT Server host name -p, --port INTEGER Server port -n, --attempts INTEGER The number of connection attempts -t, --timeout INTEGER The server response timeout Input data sources: [mutually_exclusive, required] The sources of the input data --tsv-file FILENAME CSV/TSV input data file --json-file FILENAME JSON input data file --debug / --no-debug Debug flag --help Show this message and exit. How It Works ------------ Firstly, we declare the group using :func:`optgroup.group` decorator: .. code-block:: python @optgroup.group('Server configuration', help='The configuration of some server connection') .. note:: Also we can declare groups just using ``optgroup()``: .. code-block:: python @optgroup('Server configuration', help='The configuration of some server connection') Secondly, we declare the grouped options below using :func:`optgroup.option` decorator: .. code-block:: python @optgroup.option('-h', '--host', default='localhost', help='Server host name') @optgroup.option('-p', '--port', type=int, default=8888, help='Server port') And that is all! Checking Declarations --------------------- .. attention:: The important point: do not mix :func:`optgroup.option` and :func:`click.option` decorators! **click-option-group** checks the decorators order and raises the exception if :func:`optgroup.option` and :func:`click.option` decorators are mixed. The following code is incorrect: .. code-block:: python @optgroup.group('My group') @click.option('--hello') # ERROR @optgroup.option('--foo') @click.option('--spam') # ERROR @optgroup.option('--bar') The correct code looks like: .. code-block:: python @click.option('--hello') @optgroup.group('My group') @optgroup.option('--foo') @optgroup.option('--bar') @click.option('--spam') If we try to use ``optgroup.option`` without ``optgroup.grpup()``/``optgroup()`` declaration it also will raise the exception. The following code is incorrect: .. code-block:: python @click.command() @click.option('--hello') @optgroup.option('--foo') # ERROR: Missing declaration of the option group @optgroup.option('--bar') # ERROR: Missing declaration of the option group @click.option('--spam') def cli(**params): pass If we declare only option group without the options it will raise warning. .. code-block:: python @click.command() @click.option('--hello') @optgroup.group('My group') # WARN: The empty option group @click.option('--spam') def cli(**params): pass API Features ------------ Besides :class:`optgroup` based decorators the package offers another way to declare grouped options using :class:`OptionGroup` based class objects directly. We can use the instances of these classes and use its :func:`OptionGroup.option` method as decorator for declaring and adding options to the group. Here is an example how it looks: .. code-block:: python import click from click_option_group import OptionGroup, RequiredMutuallyExclusiveOptionGroup server_config = OptionGroup('Server configuration', help='The configuration of some server connection') input_sources = RequiredMutuallyExclusiveOptionGroup('Input data sources', help='The sources of the input data') @click.command() @server_config.option('-h', '--host', default='localhost', help='Server host name') @server_config.option('-p', '--port', type=int, default=8888, help='Server port') @input_sources.option('--tsv-file', type=click.File(), help='CSV/TSV input data file') @input_sources.option('--json-file', type=click.File(), help='JSON input data file') @click.option('--debug/--no-debug', default=False, help='Debug flag') def cli(**params): print(params) if __name__ == '__main__': cli() In this case initially we create group objects and then we use :func:`OptionGroup.option` method for declaring options. As well as in above example we cannot mix ``option`` and ``click.option`` decorators. The following code is incorrect and will raise the exception: .. code-block:: python @server_config.option('-h', '--host', default='localhost', help='Server host name') @click.option('--foo') # ERROR @server_config.option('-p', '--port', type=int, default=8888, help='Server port') @input_sources.option('--tsv-file', type=click.File(), help='CSV/TSV input data file') @click.option('--bar') # ERROR @input_sources.option('--json-file', type=click.File(), help='JSON input data file') Behavior and Relationship among Options --------------------------------------- The groups are useful to define the specific behavior and relationship among grouped options. **click-option-groups** provides two main classes: :class:`OptionGroup` and :class:`GroupedOption`. - :class:`OptionGroup` class is a new entity for Click that provides the abstraction for grouping options and manage it. - :class:`GroupedOption` class is inherited from :class:`click.Option` and provides the functionality for grouped options. :class:`OptionGroup` and :class:`GroupedOption` classes contain the basic functionality for support option groups. Both these classes do not contain the specific behavior or relationship among grouped options. The specific behavior can be implemented by using the inheritance, mainly, in :class:`OptionGroup` sub classes. **click-option-groups** provides some useful :class:`OptionGroup` based classes out of the box: - :class:`RequiredAnyOptionGroup` -- At least one option from the group must be set - :class:`AllOptionGroup` -- All options from the group must be set or none must be set - :class:`RequiredAllOptionGroup` -- All options from the group must be set - :class:`MutuallyExclusiveOptionGroup` -- Only one or none option from the group must be set - :class:`RequiredMutuallyExclusiveOptionGroup` -- Only one required option from the group must be set :class:`OptionGroup` based class can be specified via ``cls`` argument in ``optgroup()``/``optgroup.group()`` decorator or can be used directly when the second API way is used. If you want to implement some complex behavior you can create a sub class of `GroupedOption` class and use your :class:`GroupedOption` based class via ``cls`` argument in ``optgroup.option``/``OptionGroup.option`` decorator method: .. code-block:: python @click.command() @optgroup('My group', cls=MyCustomOptionGroup) @optgroup.option('--foo', cls=MyCustomGroupedOption) ... Limitations ----------- The package does not support nested option groups (option subgroups). This is intentional. Nested option groups complicate the implementation, API and CLI and most often it is not necessary. If you think you need to nested option groups try redesign your CLI and doing it with `nesting commands `_. click-option-group-0.5.6/setup.cfg000066400000000000000000000000421444072145500170720ustar00rootroot00000000000000[metadata] license_file = LICENSE click-option-group-0.5.6/setup.py000066400000000000000000000044141444072145500167720ustar00rootroot00000000000000# -*- coding: utf-8 -*- import pathlib from setuptools import setup PACKAGE_NAME = 'click_option_group' ROOT_DIR = pathlib.Path(__file__).parent def get_version(): version = {} version_file = ROOT_DIR / PACKAGE_NAME / '_version.py' exec(version_file.read_text(), version) return version['__version__'] def get_long_description(): readme = ROOT_DIR / 'README.md' changelog = ROOT_DIR / 'CHANGELOG.md' return '{}\n{}'.format( readme.read_text(encoding='utf-8'), changelog.read_text(encoding='utf-8') ) setup( name='click-option-group', version=get_version(), packages=[PACKAGE_NAME], package_data={ PACKAGE_NAME: ["py.typed"] }, include_package_data=True, python_requires='>=3.6,<4', install_requires=[ 'Click>=7.0,<9', ], extras_require={ 'docs': ['sphinx', 'Pallets-Sphinx-Themes', 'm2r2'], 'tests': ['pytest'], 'tests_cov': ['pytest', 'pytest-cov', 'coverage', 'coveralls'], }, url='https://github.com/click-contrib/click-option-group', project_urls={ "Code": 'https://github.com/click-contrib/click-option-group', "Issue tracker": 'https://github.com/click-contrib/click-option-group/issues', "Documentation": 'https://click-option-group.readthedocs.io', }, license='BSD-3-Clause', author='Eugene Prilepin', author_email='esp.home@gmail.com', description='Option groups missing in Click', long_description=get_long_description(), long_description_content_type='text/markdown', classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Environment :: Console', 'Topic :: Software Development :: Libraries', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', ], ) click-option-group-0.5.6/tests/000077500000000000000000000000001444072145500164175ustar00rootroot00000000000000click-option-group-0.5.6/tests/conftest.py000066400000000000000000000002251444072145500206150ustar00rootroot00000000000000# -*- coding: utf-8 -*- from click.testing import CliRunner import pytest @pytest.fixture(scope='function') def runner(): return CliRunner() click-option-group-0.5.6/tests/test_click_option_group.py000066400000000000000000000602611444072145500237260ustar00rootroot00000000000000# -*- coding: utf-8 -*- from functools import wraps import pytest import click from click_option_group import ( optgroup, OptionGroup, GroupedOption, RequiredAnyOptionGroup, AllOptionGroup, RequiredAllOptionGroup, MutuallyExclusiveOptionGroup, RequiredMutuallyExclusiveOptionGroup, ) def test_basic_functionality_first_api(runner): @click.command() @click.option('--hello') @optgroup('Group 1', help='Group 1 description') @optgroup.option('--foo1') @optgroup.option('--bar1') @click.option('--lol') @optgroup.group('Group 2', help='Group 2 description') @optgroup.option('--foo2') @optgroup.option('--bar2') @click.option('--goodbye') def cli(hello, foo1, bar1, lol, foo2, bar2, goodbye): click.echo(f'{foo1},{bar1},{foo2},{bar2}') result = runner.invoke(cli, ['--help']) assert not result.exception assert 'Group 1:' in result.output assert 'Group 1 description' in result.output assert 'Group 2:' in result.output assert 'Group 2 description' in result.output result = runner.invoke(cli, [ '--foo1', 'foo1', '--bar1', 'bar1', '--foo2', 'foo2', '--bar2', 'bar2']) assert not result.exception assert 'foo1,bar1,foo2,bar2' in result.output def test_noname_group(runner): @click.command() @optgroup() @optgroup.option('--foo') def cli(foo): pass result = runner.invoke(cli, ['--help']) assert 'Options:\n --foo' in result.output @click.command() @optgroup(help='Group description') @optgroup.option('--foo') def cli(foo): pass result = runner.invoke(cli, ['--help']) assert 'Group description' in result.output def test_mix_decl_first_api(): with pytest.raises(TypeError, match=r"Check decorator position for \['--hello'\]"): @click.command() @optgroup('Group 1', help='Group 1 description') @optgroup.option('--foo') @click.option('--hello') @optgroup.option('--bar') def cli1(**params): pass with pytest.raises(TypeError, match=r"Check decorator position for \['--hello'\]"): @click.command() @optgroup('Group 1', help='Group 1 description') @click.option('--hello') @optgroup.option('--foo') @optgroup.option('--bar') def cli2(**params): pass with pytest.raises(TypeError, match=r"Check decorator position for \['--hello2'\]"): @click.command() @optgroup('Group 1', help='Group 1 description') @click.option('--hello1') @optgroup.option('--foo') @click.option('--hello2') @optgroup.option('--bar') def cli3(**params): pass def test_missing_group_decl_first_api(runner): @click.command() @click.option('--hello1') @optgroup.option('--foo') @optgroup.option('--bar') @click.option('--hello2') def cli(**params): pass result = runner.invoke(cli, ['--help']) assert result.exception assert TypeError == result.exc_info[0] assert 'Missing option group decorator' in str(result.exc_info[1]) assert '--foo' in str(result.exc_info[1]) assert '--bar' in str(result.exc_info[1]) result = runner.invoke(cli, []) assert result.exception assert TypeError == result.exc_info[0] assert 'Missing option group' in str(result.exc_info[1]) assert '--foo' in str(result.exc_info[1]) assert '--bar' in str(result.exc_info[1]) result = runner.invoke(cli, ['--hello1', 'hello1']) assert result.exception assert TypeError == result.exc_info[0] assert 'Missing option group' in str(result.exc_info[1]) assert '--foo' in str(result.exc_info[1]) assert '--bar' in str(result.exc_info[1]) result = runner.invoke(cli, ['--foo', 'foo']) assert result.exception assert TypeError == result.exc_info[0] assert 'Missing option group' in str(result.exc_info[1]) assert '--foo' in str(result.exc_info[1]) assert '--bar' in str(result.exc_info[1]) def test_missing_grouped_options_decl_first_api(runner): with pytest.warns(RuntimeWarning, match=r'The empty option group "Group 1"'): @click.command() @click.option('--hello1') @optgroup('Group 1', help='Group 1 description') @click.option('--hello2') def cli(**params): pass result = runner.invoke(cli, ['--help']) assert not result.exception assert 'Group 1:' not in result.output assert 'Group 1 description' not in result.output assert '--hello1' in result.output assert '--hello2' in result.output def test_incorrect_option_group_cls(): with pytest.raises(TypeError, match=r"must be a subclass of 'OptionGroup' class"): @click.command() @optgroup(cls=object) @optgroup.option('--foo') def cli(**params): pass def test_option_group_unexpected_arguments(): with pytest.raises(TypeError, match=r"'OptionGroup' constructor got an unexpected keyword argument 'oops'"): @click.command() @optgroup(oops=True) @optgroup.option('--foo') def cli(**params): pass def test_incorrect_grouped_option_cls(): @click.command() @optgroup() @optgroup.option('--foo', cls=GroupedOption) def cli1(**params): pass with pytest.raises(TypeError, match=r"must be a subclass of 'GroupedOption' class"): @click.command() @optgroup() @optgroup.option('--foo', cls=click.Option) def cli2(**params): pass def test_option_group_name_help(): group = OptionGroup() assert group.name == '' assert group.help == '' group = OptionGroup('Group Name', help='Group description') assert group.name == 'Group Name' assert group.help == 'Group description' def test_basic_functionality_second_api(runner): group1 = OptionGroup('Group 1', help='Group 1 description') group2 = OptionGroup('Group 2', help='Group 2 description') @click.command() @click.option('--hello') @group1.option('--foo1') @group1.option('--bar1') @click.option('--lol') @group2.option('--foo2') @group2.option('--bar2') @click.option('--goodbye') def cli(hello, foo1, bar1, lol, foo2, bar2, goodbye): click.echo(f'{foo1},{bar1},{foo2},{bar2}') result = runner.invoke(cli, ['--help']) assert not result.exception assert 'Group 1:' in result.output assert 'Group 1 description' in result.output assert 'Group 2:' in result.output assert 'Group 2 description' in result.output result = runner.invoke(cli, [ '--foo1', 'foo1', '--bar1', 'bar1', '--foo2', 'foo2', '--bar2', 'bar2']) assert not result.exception assert 'foo1,bar1,foo2,bar2' in result.output def test_mix_decl_second_api(): group1 = OptionGroup('Group 1', help='Group 1 description') with pytest.raises(TypeError, match=r"Check decorator position for \['--hello2'\]"): @click.command() @click.option('--hello1') @group1.option('--foo') @click.option('--hello2') @group1.option('--bar') @click.option('--hello3') def cli(**params): pass def test_required_any_option_group(runner): group = RequiredAnyOptionGroup() assert group.name_extra == ['required_any'] @click.command() @optgroup(cls=RequiredAnyOptionGroup) @optgroup.option('--foo') @optgroup.option('--bar') def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert '[required_any]' in result.output result = runner.invoke(cli, []) assert result.exception assert result.exit_code == 2 assert 'At least one of the following options' in result.output assert '--foo' in result.output assert '--bar' in result.output result = runner.invoke(cli, ['--foo', 'foo']) assert not result.exception assert result.exit_code == 0 assert 'foo,None' in result.output result = runner.invoke(cli, ['--bar', 'bar']) assert not result.exception assert result.exit_code == 0 assert 'None,bar' in result.output result = runner.invoke(cli, ['--foo', 'foo', '--bar', 'bar']) assert not result.exception assert result.exit_code == 0 assert 'foo,bar' in result.output def test_all_option_group(runner): group = AllOptionGroup() assert group.name_extra == ['all_or_none'] @click.command() @optgroup.group(cls=AllOptionGroup) @optgroup.option('--foo') @optgroup.option('--bar') def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert '[all_or_none]' in result.output result = runner.invoke(cli, []) assert not result.exception assert result.exit_code == 0 result = runner.invoke(cli, ['--foo', 'foo']) assert result.exception assert result.exit_code == 2 assert 'All options from' in result.output assert 'should be specified or none should be specified' in result.output assert '--foo' in result.output assert '--bar' in result.output result = runner.invoke(cli, ['--foo', 'foo', '--bar', 'bar']) assert not result.exception assert result.exit_code == 0 assert 'foo,bar' in result.output def test_required_all_option_group(runner): group = RequiredAllOptionGroup() assert group.name_extra == ['required_all'] @click.command() @optgroup(cls=RequiredAllOptionGroup) @optgroup.option('--foo') @optgroup.option('--bar') def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert '[required_all]' in result.output result = runner.invoke(cli, []) assert result.exception assert result.exit_code == 2 assert 'Missing required options from' in result.output assert '--foo' in result.output assert '--bar' in result.output result = runner.invoke(cli, ['--foo', 'foo']) assert result.exception assert result.exit_code == 2 assert 'Missing required options from' in result.output assert '--foo' not in result.output assert '--bar' in result.output result = runner.invoke(cli, ['--bar', 'bar']) assert result.exception assert result.exit_code == 2 assert 'Missing required options from' in result.output assert '--foo' in result.output assert '--bar' not in result.output result = runner.invoke(cli, ['--foo', 'foo', '--bar', 'bar']) assert not result.exception assert result.exit_code == 0 assert 'foo,bar' in result.output def test_mutually_exclusive_option_group(runner): group = MutuallyExclusiveOptionGroup() assert group.name_extra == ['mutually_exclusive'] @click.command() @optgroup(cls=MutuallyExclusiveOptionGroup) @optgroup.option('--foo') @optgroup.option('--bar') @optgroup.option('--spam') def cli(foo, bar, spam): click.echo(f'{foo},{bar},{spam}') result = runner.invoke(cli, ['--help']) assert '[mutually_exclusive]' in result.output result = runner.invoke(cli, []) assert not result.exception assert result.exit_code == 0 assert 'None,None,None' in result.output result = runner.invoke(cli, ['--foo', 'foo', '--bar', 'bar']) assert result.exception assert result.exit_code == 2 assert 'Mutually exclusive options from' in result.output assert 'cannot be used at the same time' in result.output assert '--foo' in result.output assert '--bar' in result.output result = runner.invoke(cli, ['--foo', 'foo', '--spam', 'spam']) assert result.exception assert result.exit_code == 2 assert 'Mutually exclusive options from' in result.output assert 'cannot be used at the same time' in result.output assert '--foo' in result.output assert '--spam' in result.output result = runner.invoke(cli, ['--bar', 'bar', '--spam', 'spam']) assert result.exception assert result.exit_code == 2 assert 'Mutually exclusive options from' in result.output assert 'cannot be used at the same time' in result.output assert '--bar' in result.output assert '--spam' in result.output result = runner.invoke(cli, ['--foo', 'foo']) assert not result.exception assert result.exit_code == 0 assert 'foo,None,None' in result.output result = runner.invoke(cli, ['--bar', 'bar']) assert not result.exception assert result.exit_code == 0 assert 'None,bar,None' in result.output result = runner.invoke(cli, ['--spam', 'spam']) assert not result.exception assert result.exit_code == 0 assert 'None,None,spam' in result.output def test_required_mutually_exclusive_option_group(runner): group = RequiredMutuallyExclusiveOptionGroup() assert group.name_extra == ['mutually_exclusive', 'required'] @click.command() @optgroup(cls=RequiredMutuallyExclusiveOptionGroup) @optgroup.option('--foo') @optgroup.option('--bar') @optgroup.option('--spam') def cli(foo, bar, spam): click.echo(f'{foo},{bar},{spam}') result = runner.invoke(cli, ['--help']) assert '[mutually_exclusive, required]' in result.output result = runner.invoke(cli, []) assert result.exception assert result.exit_code == 2 assert 'Missing one of the required mutually exclusive options' in result.output assert '--foo' in result.output assert '--bar' in result.output assert '--spam' in result.output @pytest.mark.parametrize('cls', [ RequiredAnyOptionGroup, RequiredAllOptionGroup, MutuallyExclusiveOptionGroup, RequiredMutuallyExclusiveOptionGroup, ]) def test_forbidden_option_attrs(cls): with pytest.raises(TypeError, match=f"'required' attribute is not allowed for '{cls.__name__}' option `foo'"): @click.command() @optgroup(cls=cls) @optgroup.option('--foo', required=True) @optgroup.option('--bar') def cli(foo): pass def test_subcommand_first_api(runner): @click.group() @optgroup('Group 1', help='Group 1 description') @optgroup.option('--foo') @optgroup.option('--bar') def cli(foo, bar): click.echo(f'{foo},{bar}') @cli.command() @optgroup('Group 2', help='Group 2 description') @optgroup.option('--foo') @optgroup.option('--bar') def command(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert not result.exception assert 'Group 1:' in result.output assert 'Group 1 description' in result.output assert '--foo' in result.output assert '--bar' in result.output result = runner.invoke(cli, ['command', '--help']) assert not result.exception assert 'Group 2:' in result.output assert 'Group 2 description' in result.output assert '--foo' in result.output assert '--bar' in result.output result = runner.invoke(cli, ['--foo', 'foo', '--bar', 'bar', 'command', '--foo', 'foo1', '--bar', 'bar1']) assert not result.exception assert 'foo,bar\nfoo1,bar1' in result.output def test_subcommand_mix_decl_first_api(): with pytest.raises(TypeError, match=r"Check decorator position for \['--hello'\] option in 'cli1'"): @click.group() @optgroup('Group 1', help='Group 1 description') @optgroup.option('--foo') @click.option('--hello') @optgroup.option('--bar') def cli1(**params): pass @cli1.command() @optgroup('Group 2', help='Group 2 description') @optgroup.option('--foo') @optgroup.option('--bar') def command1(**params): pass with pytest.raises(TypeError, match=r"Check decorator position for \['--hello'\] option in 'command2'"): @click.group() @optgroup('Group 1', help='Group 1 description') @optgroup.option('--foo') @optgroup.option('--bar') def cli2(**params): pass @cli2.command() @optgroup('Group 2', help='Group 2 description') @optgroup.option('--foo') @click.option('--hello') @optgroup.option('--bar') def command2(**params): pass def test_subcommand_second_api(runner): group = OptionGroup('Group', help='Group description') @click.group() @group.option('--foo1') @group.option('--bar1') def cli(foo1, bar1): click.echo(f'{foo1},{bar1}') @cli.command() @group.option('--foo2') @group.option('--bar2') def command(foo2, bar2): click.echo(f'{foo2},{bar2}') result = runner.invoke(cli, ['--help']) assert not result.exception assert 'Group:' in result.output assert 'Group description' in result.output assert '--foo1' in result.output assert '--bar1' in result.output result = runner.invoke(cli, ['command', '--help']) assert not result.exception assert 'Group:' in result.output assert 'Group description' in result.output assert '--foo2' in result.output assert '--bar2' in result.output result = runner.invoke(cli, ['--foo1', 'foo1', '--bar1', 'bar1', 'command', '--foo2', 'foo2', '--bar2', 'bar2']) assert not result.exception assert 'foo1,bar1\nfoo2,bar2' in result.output def test_group_context_second_api(runner): group = OptionGroup('My Group') @click.command() @group.option('--foo1') @group.option('--bar1') def cli1(foo1, bar1): click.echo(f'{foo1},{bar1}') @click.command() @group.option('--foo2') @group.option('--bar2') def cli2(foo2, bar2): click.echo(f'{foo2},{bar2}') result = runner.invoke(cli1, ['--help']) assert not result.exception assert 'My Group:' in result.output assert '--foo1' in result.output assert '--bar1' in result.output result = runner.invoke(cli2, ['--help']) assert not result.exception assert 'My Group:' in result.output assert '--foo2' in result.output assert '--bar2' in result.output result = runner.invoke(cli1, ['--foo1', 'foo1', '--bar1', 'bar1']) assert not result.exception assert 'foo1,bar1' in result.output result = runner.invoke(cli2, ['--foo2', 'foo2', '--bar2', 'bar2']) assert not result.exception assert 'foo2,bar2' in result.output def test_subcommand_mix_decl_second_api(): group = OptionGroup() with pytest.raises(TypeError, match=r"Check decorator position for \['--hello'\] option in 'cli1'"): @click.group() @group.option('--foo') @click.option('--hello') @group.option('--bar') def cli1(**params): pass @cli1.command() @group.option('--foo') @group.option('--bar') def command1(**params): pass with pytest.raises(TypeError, match=r"Check decorator position for \['--hello'\] option in 'command2'"): @click.group() @group.option('--foo') @group.option('--bar') def cli2(**params): pass @cli2.command() @group.option('--foo') @click.option('--hello') @group.option('--bar') def command2(**params): pass def test_command_first_api(runner): @optgroup('Group 1') @optgroup.option('--foo') @optgroup.option('--bar') @click.command() def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert not result.exception assert 'Group 1:' in result.output assert '--foo' in result.output assert '--bar' in result.output result = runner.invoke(cli, ['--foo', 'foo', '--bar', 'bar']) assert not result.exception assert 'foo,bar' in result.output def test_hidden_option(runner): @click.command() @click.option('--hello') @optgroup('Group 1', help='Group 1 description') @optgroup.option('--foo1') @optgroup.option('--bar1', hidden=True) @click.option('--goodbye') def cli(hello, foo1, bar1, goodbye): click.echo(f'{foo1},{bar1}') result = runner.invoke(cli, ['--help']) assert not result.exception assert 'Group 1:' in result.output assert 'Group 1 description' in result.output assert 'bar1' not in result.output result = runner.invoke(cli, [ '--foo1', 'foo1', '--bar1', 'bar1', ]) assert not result.exception assert 'foo1,bar1' in result.output with pytest.raises( TypeError, match="'hidden' attribute is not allowed for 'RequiredAllOptionGroup' option `bar'"): @click.command() @optgroup(cls=RequiredAllOptionGroup) @optgroup.option('--foo') @optgroup.option('--bar', hidden=True) def cli(foo, bar): click.echo(f'{foo},{bar}') @click.command() @optgroup(cls=RequiredAnyOptionGroup) @optgroup.option('--foo', hidden=True) @optgroup.option('--bar', hidden=True) def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli,) assert isinstance(result.exception, TypeError) assert "Need at least one non-hidden" in str(result.exception) @click.command() @optgroup("Group 1", help="Group 1 description") @optgroup.option('--foo', hidden=True) @optgroup.option('--bar', hidden=True) def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert not result.exception assert "Group 1" not in result.output @click.command() @optgroup("Group 1", help="Group 1 description", hidden=True) @optgroup.option('--foo') @optgroup.option('--bar') def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert not result.exception assert "Group 1" not in result.output assert "foo" not in result.output assert "bar" not in result.output @click.command() @optgroup("Group 1", help="Group 1 description", hidden=True) @optgroup.option('--foo', hidden=False) # override hidden of group @optgroup.option('--bar') def cli(foo, bar): click.echo(f'{foo},{bar}') result = runner.invoke(cli, ['--help']) assert not result.exception assert "Group 1" in result.output assert "foo" in result.output assert "bar" not in result.output @pytest.mark.parametrize('param_decls, options, output', [ ((), ['--help'], '--help'), (('-h', '--help'), ['-h'], '-h, --help'), (('-h', '--help'), ['--help'], '-h, --help'), ]) def test_help_option(runner, param_decls, options, output): @click.command() @optgroup('Help Options') @optgroup.help_option(*param_decls) def cli() -> None: click.echo('Running command.') result = runner.invoke(cli) assert not result.exception assert 'Running command.' in result.output assert 'Usage:' not in result.output result = runner.invoke(cli, options) assert not result.exception assert 'Running command.' not in result.output assert 'Usage:' in result.output assert output in result.output def test_wrapped_functions(runner): def make_z(): """A unified option interface for making a `z`.""" def decorator(f): @optgroup.group("Group xyz") @optgroup.option("-x", type=int) @optgroup.option("-y", type=int) @wraps(f) def new_func(*args, x=0, y=0, **kwargs): # Here we handle every detail about how to make a `z` from the given options f(*args, z=x + y, **kwargs) return new_func return decorator def make_c(): """A unified option interface for making a `c`.""" def decorator(f): @optgroup.group("Group abc") @optgroup.option("-a", type=int) @optgroup.option("-b", type=int) @wraps(f) def new_func(*args, a=0, b=0, **kwargs): # Here we do the same, but for another object `c` (and many others to come) f(*args, c=a * b, **kwargs) return new_func return decorator # Here I want to create a script that has a commen UI to make a `z`. # I want to reuse a common set of options for how to make a `z` and don't want # to sweat the details. Also, I've decided that I also want a `c` for this script. @click.command() @make_z() @make_c() def f(z, c): print(z, c) # Test result = runner.invoke(f, ["--help"]) assert "Group xyz" in result.output assert "-x" in result.output assert "-y" in result.output assert "Group abc" in result.output assert "-a" in result.output assert "-b" in result.output