pax_global_header00006660000000000000000000000064145713375410014524gustar00rootroot0000000000000052 comment=c375313719a3c2f6e9679cb638b39a6934536771 cloup-3.0.5/000077500000000000000000000000001457133754100126535ustar00rootroot00000000000000cloup-3.0.5/.editorconfig000066400000000000000000000005121457133754100153260ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.{yml, yaml, ini}] indent_size = 2 [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab cloup-3.0.5/.gitattributes000066400000000000000000000000231457133754100155410ustar00rootroot00000000000000* text=auto eol=lf cloup-3.0.5/.github/000077500000000000000000000000001457133754100142135ustar00rootroot00000000000000cloup-3.0.5/.github/ISSUE_TEMPLATE/000077500000000000000000000000001457133754100163765ustar00rootroot00000000000000cloup-3.0.5/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000010501457133754100210640ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: 'bug' assignees: '' --- Please, make sure you are using the last version of Cloup before opening a bug report. #### Bug description A clear and concise description of what the bug is. #### To Reproduce Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error #### Expected behavior Delete this section if the expected behavior is obvious. #### Screenshots If applicable, add screenshots to help explain your problem. cloup-3.0.5/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000002561457133754100203710ustar00rootroot00000000000000contact_links: - name: Questions and discussions url: https://github.com/janluke/cloup/discussions about: For questions and discussions, prefer GitHub Discussions. cloup-3.0.5/.github/ISSUE_TEMPLATE/enhancement.md000066400000000000000000000004631457133754100212100ustar00rootroot00000000000000--- name: Feature or enhancement about: Propose how to improve library (not the project) title: '' labels: 'enhancement' assignees: '' --- Please, make sure all the relevant points are discussed: - what problem does it solve - describe the API you'd want and possibly the alternative you've considered. cloup-3.0.5/.github/ISSUE_TEMPLATE/other.md000066400000000000000000000001421457133754100200360ustar00rootroot00000000000000--- name: Other issues about: 'Any other type of issue' title: '' labels: '' assignees: '' --- cloup-3.0.5/.github/release.yaml000066400000000000000000000005401457133754100165160ustar00rootroot00000000000000changelog: exclude: labels: - exclude-from-release categories: - title: Breaking Changes labels: - 'breaking change' - title: New features and enhancements labels: - feature - enhancement - title: Bug fixes labels: - bug - title: Other Changes labels: - "*" cloup-3.0.5/.github/workflows/000077500000000000000000000000001457133754100162505ustar00rootroot00000000000000cloup-3.0.5/.github/workflows/tests.yaml000066400000000000000000000044651457133754100203070ustar00rootroot00000000000000name: Tests on: push: branches: [master] tags: [v*] pull_request: workflow_dispatch: schedule: - cron: "0 6 * * 0" jobs: tests: name: ${{ matrix.name }} runs-on: ubuntu-latest strategy: matrix: include: # Checks - { name: 'Lint', tox: lint, python: '3.8' } - { name: 'Type checking', tox: mypy, python: '3.8' } - { name: 'Twine check', tox: twine, python: '3.8' } # Test using Click 8 - { name: 'Python 3.7, Click 8', tox: 'py37-click8', python: '3.7' } - { name: 'Python 3.8, Click 8', tox: 'py38-click8', python: '3.8' } - { name: 'Python 3.9, Click 8', tox: 'py39-click8', python: '3.9' } - { name: 'Python 3.10, Click 8', tox: 'py310-click8', python: '3.10' } - { name: 'Python 3.11, Click 8', tox: 'py311-click8', python: '3.11' } # Docs - { name: 'Docs', tox: docs, python: '3.8' } steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Install dependencies run: | python -m pip install -U pip pip install -U wheel pip install -U setuptools pip install "tox < 4" - name: Run tox -e ${{ matrix.tox }} run: tox -e ${{ matrix.tox }} - name: Install and run codecov (py38-click8 only) if: ${{ matrix.tox == 'py38-click8' }} run: | pip install codecov codecov publish: # only if tests passed and this is a tagged commit. name: Publish package to PyPI needs: tests if: > startsWith(github.ref, 'refs/tags/v') && github.repository == 'janluke/cloup' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Python 3.7 uses: actions/setup-python@v5 with: python-version: 3.7 - name: Install pypa/build run: python -m pip install build --user - name: Build a binary wheel and a source tarball run: python -m build . --sdist --wheel --outdir dist/ - name: Upload to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} cloup-3.0.5/.gitignore000066400000000000000000000024131457133754100146430ustar00rootroot00000000000000# Version file generated by setuptools-scm cloup/_version.py act/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Sphinx documentation docs/_build/ # sphinx-autobuild docs/Repocloupdocs_build/ # sphinx-autoapi docs/autoapi # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json # IDE settings .vscode/ .idea/ cloup-3.0.5/.readthedocs.yml000066400000000000000000000003551457133754100157440ustar00rootroot00000000000000# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 sphinx: configuration: docs/conf.py python: version: 3.8 install: - method: pip path: . - requirements: requirements/docs.txt cloup-3.0.5/CHANGELOG.rst000066400000000000000000000475611457133754100147110ustar00rootroot00000000000000========= Changelog ========= .. v0.X.X (in development) ======================= New features and enhancements ----------------------------- Bug fixes --------- Breaking changes ---------------- Deprecated ---------- Newer releases ============== Release notes are now only on GitHub: https://github.com/janluke/cloup/releases. v1.0.2 (2022-11-04) ======================= - Skip constraints checking when passing ``--help`` to a subcommand. :issue:`129` v1.0.1 (2022-09-22) ======================= - Show a helpful error message when some tries to use command decorators without parenthesis. :pr:`128` v1.0.0 (2022-07-17) =================== - Drop support for Python 3.6. Python 3.6 ha reached end-of-life in Dec 2021 and it's not officially supported by Click 8. From https://pypistats.org/packages/cloup, I can see that about 37% of people are still using Python 3.6. If you can't upgrade, you should still be able to use Cloup by installing the ``dataclasses`` backport package and using a compatible version of Click. - Drop support for Click 7. - Run tests on Python 3.10. -------------------------------------------------------------------------------- v0.15.1 (2022-06-26) ==================== Bug fixes --------- - Fix type of ``type`` argument of ``@option`` and ``@argument``. Specifically, tuple types weren't correctly described. - Reimplement ``@argument`` and ``@option`` without calling the corresponding Click methods (:pr:`124`): - make decorators returned by ``@argument`` reusable by not relying on bugged Click implementation - remove unnecessary copy of ``**attrs`` in ``@option``. v0.15.0 (2022-06-16) ==================== New features and enhancements ----------------------------- - Renamed ``Argument.help_record`` to ``Argument.get_help_record``. :pr:`116` - Fixed typos and some type annotations. :pr:`116` - In mixins, moved call to ``super()`` at the beginning of ``__init__``. :pr:`119`. - Made Cloup pass ``mypy --strict``. :pr:`122` Bug fixes --------- - The title of the default option group was "Other options" when the only other parameter help section was "Positional arguments". It's now "Options". :pr:`120` Breaking changes ---------------- - Renamed ``Argument.help_record`` to ``Argument.get_help_record``. - In mixins, moved call to ``super()`` at the beginning of ``__init__``. :pr:`119`. -------------------------------------------------------------------------------- v0.14.0 (2022-05-09) ==================== New features and enhancements ----------------------------- - You can show a "Positional arguments" help section by passing a non-empty ``help`` description for at least one of the arguments of a command/group. :pr:`113` - ``cloup.Group`` now extends ``cloup.Command`` and, as a consequence, supports option groups and constraints. :pr:`113` - ``GroupedOption`` is now an alias of ``cloup.Option``. - Add the new ``params`` Click argument to ``@command`` and ``@group`` signatures. This argument is supported only for click >= 8.1.0. See https://github.com/pallets/click/pull/2203. Breaking changes ---------------- - ``BaseCommand`` was removed. This shouldn't cause any issue to anybody. - ``cloup.Group`` extends ``cloup.Command``, similarly as ``click.Group`` extends ``click.Command``. - ``OptionGroupMixin.format_options`` was renamed to ``format_params``. This means you can't just mix it with ``click.Command`` to print help sections for option groups, you have to override ``format_help`` and call ``format_params``. - The new ``format_params`` doesn't call ``super().format_commands`` as ``format_options`` did: that's what Click does and Cloup (reluctantly) did. Now, instead, ``cloup.Command`` calls ``format_params`` in ``format_help`` and then, for multi-commands, calls ``format_commands`` directly. - ``ConstraintMixin.format_help`` was removed. This means you can't just mix it with a click.Command to make it print the "Constraints" help section, you need to call ``format_constraints`` explicitly in your command ``format_help``. -------------------------------------------------------------------------------- v0.13.1 (2022-05-08) ==================== - Since version 8.1.0, Click does not normalize the command attributes ``help``, ``short_help`` and ``epilog`` while Cloup assumed them to be normalized as in previous releases. This lead to Cloup printing non-normalized help and epilog text when using click >= 8.1.0. v0.13.0 (2022-02-14) ==================== - Add shortcuts for ``click.Path`` types which uses ``pathlib.Path`` as ``path_type`` by default: - ``cloup.path`` - ``cloup.dir_path`` (``file_okay=False``) - ``cloup.file_path`` (``dir_okay=False``) -------------------------------------------------------------------------------- v0.12.1 (2021-10-14) ==================== - Fix: when ``OptionGroupMixin`` is mixed with ``Group``, subcommands weren't shown. v0.12.0 (2021-09-17) ==================== - Feature: when a subcommand is mistyped, show "did you mean ?". - Doc fixes. -------------------------------------------------------------------------------- v0.11.0 (2021-08-05) ==================== No major changes in this release, just refinements. - Attributes of parametric constraints are now public. :pr:`82` - Slightly changed the ``repr()`` of ``RequireExactly(3)``: from ``RequireExactly(n=3)`` to ``RequireExactly(3)``. - Minor code refactoring. - Docs fixes and improvements. -------------------------------------------------------------------------------- v0.10.0 (2021-07-14) ==================== New features and enhancements ----------------------------- - New feature: subcommand aliases. :issue:`64` :pr:`75` - Command decorators: improvements to type hints and other changes (:pr:`67`): - mypy can now infer the exact type of the instantiated command based on the ``cls`` argument. --- Unfortunately, this required the use of ``@overload`` to work around a mypy limitation - in ``@group``, allow ``cls`` to be any ``click.Group`` --- previously it had to be a subclass of ``cloup.Group`` - in ``Group.command`` and ``Group.group`` add type hints and make all arguments except ``name`` keyword-only. Technically this is a (minor) incompatibility with the ``click.Group`` superclass, but it's coherent with Cloup's ``@command`` and ``@group`` - add Cloup-specific arguments to the signature of ``@command``; if the developer uses one of such arguments with a ``cls`` that doesn't support them, Cloup augments the resulting ``TypeError`` with extra information. - Export Click types from Cloup namespace for convenience. :issue:`72` - In dark and light themes, the epilog is now left unstyled by default. :issue:`62` Bug fixes --------- - ``SectionMixin.add_section`` called ``super().add_command`` rather than ``self.add_command``. This caused ``add_command`` in subclasses not to be called. :issue:`69` - Fix ``Context.check_constraints_consistency`` not being propagated to descendant contexts. :issue:`74` Breaking changes ---------------- - In ``Group.command`` and ``Group.group`` all arguments except ``name`` are now keyword-only. - The ``name`` parameter/attribute of ``OptionGroup`` was renamed to ``title``. - In ``SectionMixin`` (thus, in ``Group``), added a ``ctx: Context`` attribute to make_commands_help_section and format_subcommand_name to support the ``show_subcommand_aliases`` setting. -------------------------------------------------------------------------------- v0.9.1 (2021-07-03) =================== - Fixed bug: shell completion breaking because of Cloup checking constraints despite ``ctx.resilient_parsing=True`` - Added public attributes to ``ConstraintMixin``: ``optgroup_constraints``, ``param_constraints`` and ``all_constraints``. - Cleaned up code and added other type hints (to internal code). - Docs fixes and improvements. Fixed dark theme styling. v0.9.0 (2021-06-30) =================== Fixed bugs ---------- - ``Context.show_constraints`` not having effect because of wrong default for ``Command.show_constraint``. :issue:`49` - ``Command`` (``OptionGroupMixin``) raising error if ``params`` is not provided. :issue:`58` New features and enhancements ----------------------------- - Add detailed type hints for ``@argument``, ``@option``, ``@command`` and ``@group``. This should greatly improve IDE code completion. :pr:`47`, :pr:`50` - You can now use **constraints as decorators** (or ``@constrained_params``) to constrain a group of "contiguous" parameters without repeating their names (see :ref:`Constraints as decorators `). This is a breaking change (see section below). :issue:`8` - Added the ``require_any`` and ``require_one`` constraints (as aliases). :issue:`57` - Simplify and improve the ``error`` argument of ``Rephraser`` (see :ref:`Rephrasing constraints `). :pr:`54` - The formatter setting ``row_sep`` can now take a ``RowSepPolicy`` that decides whether and which row separator to use for each definition list independently, e.g. based on the number of definitions taking multiple lines (see: :ref:`Row separators `). :issue:`37` - Added method ``format_subcommand_name(name, cmd)`` to ``SectionMixin`` to facilitate it combination with other Click extensions that override ``format_commands()``. :issue:`59` - ``@option_group`` and ``Section`` now show a better error message when one forgets to provide the name/title as first argument. - Fixed/improved some type hints and added others. Breaking changes ---------------- - Calling a constraint -- previously a shortcut to the :meth:`~Constraint.check` method -- now returns a decorator. Use the method :meth:`Constraint.check` to check a constraint inside a function. :issue:`8` - The semantics of ``row_sep`` changed. Now, it defaults to ``None`` and must not end with ``\n``, since the formatter writes a newline automatically after it. So, ``row_sep=""`` now corresponds to an empty line between rows. :issue:`41` - In ``@command`` and ``@group`` make all arguments but ``name`` keyword-only. :issue:`46` - In ``Context.settings`` and ``HelpFormatter.settings``, use a ``MISSING`` constant instead of ``None`` as a flag for "empty" arguments. :issue:`40` - ``Constraint.toggle_consistency_checks`` was replaced with a ``Context`` setting called ``check_constraints_consistency``. :issue:`33` - ``ConstraintViolated`` requires more parameters now. :pr:`54` Docs ---- - Restyling to improve readability: increased font size and vertical spacing, decreased line width. Restyled the table of contents on the right side. Ecc. - Reorganized and rewrote several parts. -------------------------------------------------------------------------------- v0.8.1-2 (2021-05-25) ===================== (I had to release v0.8.2 just after v0.8.1 to fix a docs issue) - Work around a minor Click 8.0.1 `issue `_ with boolean options which caused some Cloup tests to fail. - Cosmetic: use a nicer logo and add a GitHub "header" including it. - Slightly improved readme, docs and examples. v0.8.0 (2021-05-19) =================== Project changes --------------- - Cloup license changed from MIT to 3-clause BSD, the one used by Click. - Added a donation button. New features and enhancements ----------------------------- - Cloup now uses its own ``HelpFormatter``: * it supports alignment of multiple definition lists, so Cloup doesn't have to rely on a hack (padding) to align option groups and alike * it adds theming of the help page, i.e. styling of several elements of the help page * it has an additional way to format definition lists (implemented with the method ``write_linear_dl``) that kicks in when the available width for the standard 2-column format is not enough (precisely, when the width available for the 2nd column is below ``formatter.col2_min_width``) * it adds several attributes to fine-tune and customize the generated help: ``col1_max_width``, ``col_spacing`` and ``row_sep`` * it fixes a couple of Click minor bugs and decides the column width of definition lists in a slightly smarter way that makes a better use of the available space. - Added a custom ``Context`` that: * uses ``cloup.HelpFormatter`` as formatter class by default * adds a ``formatter_settings`` attributes that allows to set the default formatter keyword arguments (the same argument can be given to a command to override these defaults). You can use the static method ``HelpFormatter.settings`` to create such a dictionary * allows to set the default value for the following ``Command``/``Group`` args: * ``align_option_groups``, * ``align_sections`` * ``show_constraints`` * has a ``Context.setting`` static method that facilitates the creation of a ``context_settings`` dictionary (you get the help of your IDE). - Added a base class ``BaseCommand`` for ``Command`` and ``Group`` that: - extends ``click.Command`` - back-ports Click 8.0 class attribute ``context_class`` and set it to ``cloup.Context`` - adds the ``formatter_settings`` argument - Hidden option groups. An option group is hidden either if you pass ``hidden=True`` when you define it or if all its contained options are hidden. If you set ``hidden=True``, all contained options will have their ``hidden`` attribute set to ``True`` automatically. - Adds the conditions ``AllSet`` and ``AnySet``. * The ``and`` of two or more ``IsSet`` conditions returns an ``AllSet`` condition. * The ``or`` of two or more ``IsSet`` conditions returns an ``AnySet`` condition. - Changed the error messages of ``all_or_none`` and ``accept_none``. - The following Click decorators are now exported by Cloup: ``argument``, ``confirmation_option``, ``help_option``, ``pass_context``, ``pass_obj``, ``password_option`` and ``version_option``. Breaking changes ---------------- These incompatible changes don't affect the most "external" API used by most clients of this library. - Formatting methods of ``OptionGroupMixin`` and ``SectionMixin`` now expects the ``formatter`` to be a ``cloup.HelpFormatter``. If you used a custom ``click.HelpFormatter``, you'll need to change your code if you want to use this release. If you used ``click-help-colors``, keep in mind that the new formatter has built-in styling capabilities so you don't need ``click-help-colors`` anymore. - ``OptionGroupMixin.format_option_group`` was removed. - ``SectionMixin.format_section`` was removed. - The class ``MultiCommand`` was removed, being useless. - The ``OptionGroupMixin`` attribute ``align_option_groups`` is now ``None`` by default. Functionally, nothing changes: option groups are aligned by default. - The ``SectionMixin`` attribute ``align_sections`` is now ``None`` by default. Functionally, nothing changes: subcommand sections are aligned by default. - The ``ConstraintMixin`` attribute ``show_constraints`` is now ``None`` by default. Functionally, nothing changes: constraints are **not** shown by default. Docs ---- - Switch theme to ``furo``. - Added section "Help formatting and theming". - Improved all sections. -------------------------------------------------------------------------------- v0.7.1 (2021-05-02) =================== - Fixed a bug with ``&`` and ``|`` ``Predicate`` operators giving ``AttributeError`` when used. - Fixed the error message of ``accept_none`` which didn't include ``{param_list}``. - Improved ``all_or_none`` error message. - Minor docs fixes. v0.7.0 (2021-03-24) =================== New features and enhancements ----------------------------- - In constraint errors, the way the parameter list is formatted has changed. Instead of printing a comma-separated list of single labels: * each parameter is printed on a 2-space indented line and * both the short and long name of options are printed. See the relevant `commit `_. - Minor improvements to code and docs. -------------------------------------------------------------------------------- v0.6.1 (2021-03-01) =================== This patch release fixes some problems in the management and releasing of the package. - Add a ``py.typed`` file to ship the package with type hints (PEP 561). - Use ``setuptools-scm`` to automatically manage the version of the package *and* the content of the source distribution based on the git repository: * the source distribution now matches the git repository, with the only exception of ``_version.py``, which is not tracked by git; it's generated by ``setuptools-scm`` and included in the package; * tox.ini and Makefile were updated to account for the fact that ``_version.py`` doesn't exist in the repository before installing the package. - The new attribute ``cloup.__version_tuple__`` stores the version as a tuple (of *at least* 3 elements). v0.6.0 (2021-02-28) =================== New features and enhancements ----------------------------- - Slightly improved return type (hint) of command decorators. - Minor refactoring of ConstraintMixin. - Improved the documentation. Breaking changes ---------------- - Removed the deprecated ``GroupSection`` as previously announced. Use the new name instead: ``Section``. - In ``Group.group()`` and ``Group.command``, the argument ``section`` was moved after the ``cls`` argument so that the signatures are now fully compatible with those of the parent class (the Liskov substitution principle is now satisfied). If you (wisely) passed ``section`` and ``cls`` as keyword arguments in your code, you don't need to change anything. -------------------------------------------------------------------------------- v0.5.0 (2021-02-10) =================== Requirements ------------ - Drop support to Python 3.5. New features and enhancements ----------------------------- - Added a subpackage for defining **constraints** on parameters groups (including ``OptionGroup``'s). - The code for adding support to option groups was extracted to ``OptionGroupMixin``. - Most of the code for adding support to subcommand sections was extracted to ``SectionMixin``. Deprecated ---------- - ``GroupSection`` was renamed as ``Section``. Project changes --------------- - Migrated from TravisCI to GitHub Actions. -------------------------------------------------------------------------------- v0.4.0 (2021-01-10) =================== Requirements ------------ - This is the last release officially supporting Python 3.5. New features and enhancements ----------------------------- - Changed the internal (non-public) structure of the package. - Minor code improvements. Project changes --------------- - New documentation (hosted by ReadTheDocs) - Tox, TravisCI, Makefile completely rewritten. -------------------------------------------------------------------------------- v0.3.0 (2020-03-26) =================== Breaking changes ---------------- - ``option_groups`` decorator now takes options as positional arguments ``*options``; - ``Group.section`` decorator now takes sections as positional arguments ``*sections``; - ``align_sections_help`` was renamed to ``align_sections``; - ``GroupSection.__init__() sorted_`` argument was renamed to ``sorted``. Other changes ------------- - Additional signature for ``option_group``: you can pass the ``help`` argument as 2nd positional argument. - Aligned option groups (option ``align_option_groups`` with default ``True``). - More refactoring and testing. -------------------------------------------------------------------------------- v0.2.0 (2020-03-11) =================== - [Feature] Add possibility of organizing subcommands of a cloup.Group in multiple help sections. - Various code improvements. - Backward incompatible change: - rename ``CloupCommand`` and ``CloupGroup`` resp. to just ``Command`` and ``Group``. -------------------------------------------------------------------------------- v0.1.0 (2020-02-25) =================== - First release on PyPI. cloup-3.0.5/CONTRIBUTING.rst000066400000000000000000000034521457133754100153200ustar00rootroot00000000000000.. highlight:: none ============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. Get Started! ------------ Ready to contribute? Here's how to set up `cloup` for local development. 1. Fork the `cloup` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/cloup.git $ cd cloup 3. Create a development virtual environment in `venv` folder. If you have tox and Python 3.8 installed, you can create one with all dependencies running:: $ tox -e dev Otherwise, you can create one with ``venv`` (`see here for more `_):: $ python -m venv /venv then activate it (the right command depends on the platform/shell you are using):: $ source venv/bin/activate[.fish|.csh] # bash | fish ... $ venv/Scripts/activate.{bat|ps1} # Windows (cmd | Powershell) and install the requirements:: $ pip install requirements/dev.txt 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass linting, mypy and tests running tox:: $ tox -p # run all tests (env) in parallel $ tox -e # run only the specified env Alternatively, you can use ``make`` to run commands only in your dev environment if you have it installed. Run ``make help`` or read the ``Makefile`` to see the available commands. 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. cloup-3.0.5/CREDITS.rst000066400000000000000000000010401457133754100144750ustar00rootroot00000000000000======= Credits ======= Author ------ * Gianluca Gippetto Contributors ------------ See https://github.com/janluke/cloup/graphs/contributors. Credits ------- - This library stands on the shoulders of `Click `_. - The following `comment `_ by `@chrisjsewell `_ helped me getting started with the implementation of this library when I knew nothing about Click internals. cloup-3.0.5/LICENSE000066400000000000000000000027651457133754100136720ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2021, Gianluca Gippetto 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. cloup-3.0.5/Makefile000066400000000000000000000065761457133754100143310ustar00rootroot00000000000000.DEFAULT_GOAL := help REMOVE = python scripts/remove.py BROWSER = python scripts/browser.py COPYTREE = python scripts/copytree.py DOCS_HTML_DIR = docs/_build/html DOCS_HTML_STATIC = $(DOCS_HTML_DIR)/_static .PHONY: help help: @python scripts/make-help.py < $(MAKEFILE_LIST) .PHONY: venv venv: ## creates a virtualenv using tox tox -e dev .PHONY: install install: clean ## install the package in dev mode pip install -e . .PHONY: mypy mypy: ## check code, tests and examples with mypy mypy cloup --strict mypy tests examples .PHONY: lint lint: ## check code, tests and examples with flake8 flake8 cloup tests examples .PHONY: test test: install ## run tests quickly with the default Python pytest --cov=cloup -vv .PHONY: coverage coverage: test ## check code coverage quickly with the default Python coverage report -m coverage html $(BROWSER) htmlcov/index.html .PHONY: clean-docs clean-docs: ## clean the documentation $(MAKE) -C docs clean $(REMOVE) docs/autoapi .PHONY: docs docs: ## generate Sphinx HTML documentation $(MAKE) -C docs html .PHONY: view-docs view-docs: docs ## open the built docs in the default browser $(BROWSER) $(DOCS_HTML_DIR)/index.html .PHONY: re-docs re-docs: clean-docs view-docs ## (re)generate Sphinx HTML documentation from scratch LIVE_DOCS = sphinx-autobuild docs $(DOCS_HTML_DIR) \ --watch ./**.rst \ --watch ./cloup/**.py \ --ignore ./docs/autoapi/**.rst \ --open-browser .PHONY: live-docs live-docs: ## watch docs files and rebuild the docs when they change $(LIVE_DOCS) .PHONY: live-docs-all live-docs-all: ## write all files (useful when working on html/css) $(LIVE_DOCS) -a .PHONY: update-docs-static update-docs-static: ## copy docs static files into the build folder $(COPYTREE) docs/_static $(DOCS_HTML_STATIC) .PHONY: update-docs-css update-docs-css: ## copy docs css files into the build folder $(COPYTREE) docs/_static/styles $(DOCS_HTML_STATIC)/styles .PHONY: clean clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts .PHONY: clean-build clean-build: ## remove build artifacts $(REMOVE) build dist .eggs $(REMOVE) -r './**/*.egg-info' $(REMOVE) -r './**/*.egg' .PHONY: clean-pyc clean-pyc: ## remove Python file artifacts $(REMOVE) -r './**/__pycache__' $(REMOVE) -r './**/*.pyc' $(REMOVE) -r './**/*.pyo' .PHONY: clean-test clean-test: ## remove test and coverage artifacts $(REMOVE) .tox .coverage htmlcov .pytest_cache .PHONY: dist dist: clean-build ## builds source and wheel package python setup.py sdist bdist_wheel twine check dist/* .PHONY: release release: dist ## package and upload a release twine upload dist/* .PHONY: test-release test-release: dist ## package and upload a release twine upload --repository testpypi dist/* PIP_COMPILE := pip-compile --resolver=backtracking .PHONY: pip-compile pip-compile: ## pin dependencies in requirements/ using the current env $(PIP_COMPILE) requirements/test.in $(PIP_COMPILE) requirements/docs.in $(PIP_COMPILE) requirements/dev.in .PHONY: pip-upgrade pip-upgrade: ## upgrade pip and dependencies python -m pip install -U pip $(PIP_COMPILE) --upgrade requirements/test.in # $(PIP_COMPILE) --upgrade requirements/docs.in $(PIP_COMPILE) --upgrade requirements/dev.in .PHONY: pip-sync pip-sync: ## sync development environment with requirements/dev.txt pip install -U pip-tools pip-sync requirements/dev.txt pip install -e . cloup-3.0.5/README.rst000066400000000000000000000141161457133754100143450ustar00rootroot00000000000000.. raw:: html

Click + option groups + constraints + aliases + help themes + ...

https://cloup.readthedocs.io/ ---------- .. docs-index-start .. |pypi-release| image:: https://img.shields.io/pypi/v/cloup.svg :alt: Latest release on PyPI :target: https://pypi.org/project/cloup/ .. |tests-status| image:: https://github.com/janLuke/cloup/workflows/Tests/badge.svg :alt: Tests status :target: https://github.com/janLuke/cloup/actions?query=workflow%3ATests .. |coverage| image:: https://codecov.io/github/janLuke/cloup/coverage.svg?branch=master :alt: Coverage Status :target: https://app.codecov.io/github/janluke/cloup/tree/master .. |python-versions| image:: https://img.shields.io/pypi/pyversions/cloup.svg :alt: Supported versions :target: https://pypi.org/project/cloup .. |dev-docs| image:: https://readthedocs.org/projects/cloup/badge/?version=latest :alt: Documentation Status (master branch) :target: https://cloup.readthedocs.io/en/latest/ .. |release-docs| image:: https://readthedocs.org/projects/cloup/badge/?version=stable :alt: Documentation Status (latest release) :target: https://cloup.readthedocs.io/en/stable/ .. |downloads| image:: https://static.pepy.tech/personalized-badge/cloup?period=week&units=international_system&left_color=grey&right_color=blue&left_text=downloads%20/%20week :alt: PyPI - Downloads :target: https://pepy.tech/project/cloup .. |donate| image:: https://img.shields.io/badge/Donate-PayPal-green.svg :alt: Donate with PayPal :target: https://www.paypal.com/donate/?hosted_button_id=4GTN24HXPMNBJ ======== Overview ======== |pypi-release| |python-versions| |downloads| |tests-status| |coverage| |dev-docs| |donate| **Cloup** — originally from "**Cl**\ick + option gr\ **oup**\s" — enriches `Click `_ with several features that make it more expressive and configurable: - **option groups** and an (optional) help section for positional arguments - **constraints**, like ``mutually_exclusive``, that can be applied to option groups or to any group of parameters, even *conditionally* - **subcommand aliases** - **subcommands sections**, i.e. the possibility of organizing the subcommands of a ``Group`` in multiple help sections - a **themeable HelpFormatter** that: - has more parameters for adjusting widths and spacing, which can be provided at the context and command level - use a different layout when the terminal width is below a certain threshold in order to improve readability - suggestions like "did you mean ?" when you mistype a subcommand. Moreover, Cloup improves on **IDE support** providing decorators with *detailed* type hints and adding the static methods ``Context.settings()`` and ``HelpFormatter.settings()`` for creating dictionaries of settings. Cloup is **statically type-checked** with MyPy in strict mode and extensively **tested** against multiple versions of Python with nearly 100% coverage. A simple example ================ .. code-block:: python from cloup import ( HelpFormatter, HelpTheme, Style, command, option, option_group ) from cloup.constraints import RequireAtLeast, mutually_exclusive # Check the docs for all available arguments of HelpFormatter and HelpTheme. formatter_settings = HelpFormatter.settings( theme=HelpTheme( invoked_command=Style(fg='bright_yellow'), heading=Style(fg='bright_white', bold=True), constraint=Style(fg='magenta'), col1=Style(fg='bright_yellow'), ) ) # In a multi-command app, you can pass formatter_settings as part # of your context_settings so that they are propagated to subcommands. @command(formatter_settings=formatter_settings) @option_group( "Cool options", option('--foo', help='This text should describe the option --foo.'), option('--bar', help='This text should describe the option --bar.'), constraint=mutually_exclusive, ) @option_group( "Other cool options", "This is the optional description of this option group.", option('--pippo', help='This text should describe the option --pippo.'), option('--pluto', help='This text should describe the option --pluto.'), constraint=RequireAtLeast(1), ) def cmd(**kwargs): """This is the command description.""" pass if __name__ == '__main__': cmd(prog_name='invoked-command') .. image:: https://raw.githubusercontent.com/janLuke/cloup/master/docs/_static/basic-example.png :alt: Basic example --help screenshot If you don't provide ``--pippo`` or ``--pluto``: .. code-block:: text Usage: invoked-command [OPTIONS] Try 'invoked-command --help' for help. Error: at least 1 of the following parameters must be set: --pippo --pluto This simple example just scratches the surface. Read more in the documentation (links below). Supporting the project ====================== Designing, testing and documenting a library takes a lot of time. The most concrete way to show your appreciation and to support future development is by donating. Any amount is appreciated. |donate| Apart from that, you can help the project by starring it on GitHub, reporting issues, proposing improvements and contributing with your code! .. docs-index-end Links ===== * Documentation (release_ | development_) * `Changelog `_ * `GitHub repository `_ * `Q&A and discussions `_ .. _release: https://cloup.readthedocs.io/en/stable/#user-guide .. _development: https://cloup.readthedocs.io/en/latest/#user-guide cloup-3.0.5/cloup/000077500000000000000000000000001457133754100137755ustar00rootroot00000000000000cloup-3.0.5/cloup/__init__.py000066400000000000000000000040311457133754100161040ustar00rootroot00000000000000"""Top-level package for cloup.""" # WARNING: _version.py is generated by setuptools-scm upon package building/installation from . import _version __author__ = """Gianluca Gippetto""" __email__ = 'gianluca.gippetto@gmail.com' __version__ = _version.version __version_tuple__ = _version.version_tuple from click import ( # decorators confirmation_option, help_option, pass_obj, password_option, version_option, # types BOOL, Choice, DateTime, File, FLOAT, FloatRange, INT, IntRange, ParamType, Path, STRING, Tuple, UNPROCESSED, UUID, ) from . import warnings from .styling import ( HelpTheme, Style, Color, ) from .formatting import ( HelpFormatter, HelpSection, ) from ._context import Context, get_current_context, pass_context from ._params import Argument, Option, argument, option from ._option_groups import ( OptionGroup, OptionGroupMixin, option_group, ) from ._sections import ( Section, SectionMixin, ) from ._commands import ( Command, Group, command, group, ) from .constraints import ( ConstraintMixin, constrained_params, constraint, ) from .types import dir_path, file_path, path __all__ = [ "Argument", "BOOL", "Choice", "Color", "Command", "ConstraintMixin", "Context", "DateTime", "FLOAT", "File", "FloatRange", "Group", "HelpFormatter", "HelpSection", "HelpTheme", "INT", "IntRange", "Option", "OptionGroup", "OptionGroupMixin", "ParamType", "Path", "STRING", "Section", "SectionMixin", "Style", "Tuple", "UNPROCESSED", "UUID", "_version", "argument", "command", "confirmation_option", "constrained_params", "constraint", "dir_path", "file_path", "get_current_context", "group", "help_option", "option", "option_group", "pass_context", "pass_obj", "password_option", "path", "version_option", "warnings", ] cloup-3.0.5/cloup/_commands.py000066400000000000000000000725251457133754100163220ustar00rootroot00000000000000""" This modules contains Cloup command classes and decorators. Note that Cloup commands *are* Click commands. Apart from supporting more features, Cloup command decorators have detailed type hints and are generics so that type checkers can precisely infer the type of the returned command based on the ``cls`` argument. Why did you overload all decorators? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ I wanted that the return type of decorators depended from the ``cls`` argument but MyPy doesn't allow to set a default value on a generic argument, see: https://github.com/python/mypy/issues/3737. So I had to resort to a workaround using @overload which makes things more verbose. The ``@overload`` is on the ``cls`` argument: - in one signature, ``cls`` has type ``None`` and it's set to ``None``; in this case the type of the instantiated command is ``cloup.Command`` for ``@command`` and ``cloup.Group`` for ``@group`` - in the other signature, there's ``cls: C`` without a default, where ``C`` is a type variable; in this case the type of the instantiated command is ``C``. When and if the MyPy issue is resolved, the overloads will be removed. """ import inspect from typing import ( Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Sequence, Tuple, Type, TypeVar, Union, cast, overload, MutableMapping, Mapping, ) import click import cloup from ._context import Context from ._option_groups import OptionGroupMixin from ._sections import Section, SectionMixin from ._util import click_version_ge_8_1, first_bool, reindent from .constraints import ConstraintMixin from .styling import DEFAULT_THEME from .typing import AnyCallable # Generic types of ``cls`` args of ``@command`` and ``@group`` C = TypeVar('C', bound=click.Command) G = TypeVar('G', bound=click.Group) class Command(ConstraintMixin, OptionGroupMixin, click.Command): """A ``click.Command`` supporting option groups and constraints. Refer to superclasses for the documentation of all accepted parameters: - :class:`ConstraintMixin` - :class:`OptionGroupMixin` - :class:`click.Command` Besides other things, this class also: * adds a ``formatter_settings`` instance attribute. Refer to :class:`click.Command` for the documentation of all parameters. .. versionadded:: 0.8.0 """ context_class: Type[Context] = Context def __init__( self, *args: Any, aliases: Optional[Iterable[str]] = None, formatter_settings: Optional[Dict[str, Any]] = None, **kwargs: Any, ): super().__init__(*args, **kwargs) #: HelpFormatter options that are merged with ``Context.formatter_settings`` #: (eventually overriding some values). self.aliases: List[str] = [] if aliases is None else list(aliases) self.formatter_settings: Dict[str, Any] = ( {} if formatter_settings is None else formatter_settings) def get_normalized_epilog(self) -> str: if self.epilog and click_version_ge_8_1: return inspect.cleandoc(self.epilog) return self.epilog or "" # Differently from Click, this doesn't indent the epilog. def format_epilog(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: if self.epilog: assert isinstance(formatter, cloup.HelpFormatter) epilog = self.get_normalized_epilog() formatter.write_paragraph() formatter.write_epilog(epilog) def format_help_text( self, ctx: click.Context, formatter: click.HelpFormatter ) -> None: assert isinstance(formatter, cloup.HelpFormatter) formatter.write_command_help_text(self) def format_aliases(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: if not self.aliases: return assert isinstance(formatter, cloup.HelpFormatter) formatter.write_aliases(self.aliases) def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: self.format_usage(ctx, formatter) self.format_aliases(ctx, formatter) self.format_help_text(ctx, formatter) self.format_params(ctx, formatter) if self.must_show_constraints(ctx): self.format_constraints(ctx, formatter) # type: ignore if isinstance(self, click.MultiCommand): self.format_commands(ctx, formatter) self.format_epilog(ctx, formatter) class Group(SectionMixin, Command, click.Group): """ A ``click.Group`` that allows to organize its subcommands in multiple help sections and whose subcommands are, by default, of type :class:`cloup.Command`. Refer to superclasses for the documentation of all accepted parameters: - :class:`SectionMixin` - :class:`Command` - :class:`click.Group` Apart from superclasses arguments, the following is the only additional parameter: ``show_subcommand_aliases``: ``Optional[bool] = None`` whether to show subcommand aliases; aliases are shown by default and can be disabled using this argument or the homonym context setting. .. versionchanged:: 0.14.0 this class now supports option groups and constraints. .. versionadded:: 0.10.0 the "command aliases" feature, including the ``show_subcommand_aliases`` parameter/attribute. .. versionchanged:: 0.8.0 this class now inherits from :class:`cloup.BaseCommand`. """ SHOW_SUBCOMMAND_ALIASES: bool = False def __init__( self, *args: Any, show_subcommand_aliases: Optional[bool] = None, commands: Optional[ Union[MutableMapping[str, click.Command], Sequence[click.Command]] ] = None, **kwargs: Any ): super().__init__(*args, **kwargs) self.show_subcommand_aliases = show_subcommand_aliases """Whether to show subcommand aliases.""" self.alias2name: Dict[str, str] = {} """Dictionary mapping each alias to a command name.""" if commands: self.add_multiple_commands(commands) def add_multiple_commands( self, commands: Union[Mapping[str, click.Command], Sequence[click.Command]] ) -> None: if isinstance(commands, Mapping): for name, cmd in commands.items(): self.add_command(cmd, name=name) else: for cmd in commands: self.add_command(cmd) def add_command( self, cmd: click.Command, name: Optional[str] = None, section: Optional[Section] = None, fallback_to_default_section: bool = True, ) -> None: super().add_command(cmd, name, section, fallback_to_default_section) name = cast(str, cmd.name) if name is None else name aliases = getattr(cmd, 'aliases', []) for alias in aliases: self.alias2name[alias] = name def resolve_command_name(self, ctx: click.Context, name: str) -> Optional[str]: """Map a string supposed to be a command name or an alias to a normalized command name. If no match is found, it returns ``None``.""" if ctx.token_normalize_func: name = ctx.token_normalize_func(name) if name in self.commands: return name return self.alias2name.get(name) def resolve_command( self, ctx: click.Context, args: List[str] ) -> Tuple[Optional[str], Optional[click.Command], List[str]]: normalized_name = self.resolve_command_name(ctx, args[0]) if normalized_name: # Replacing this string ensures that super().resolve_command() returns a # normalized command name rather than an alias. The technique described in # Click's docs doesn't work if the subcommand is added using Group.group # passing the "name" argument. args[0] = normalized_name try: return super().resolve_command(ctx, args) except click.UsageError as error: new_error = self.handle_bad_command_name( bad_name=args[0], valid_names=[*self.commands, *self.alias2name], error=error ) raise new_error def handle_bad_command_name( self, bad_name: str, valid_names: List[str], error: click.UsageError ) -> click.UsageError: """This method is called when a command name cannot be resolved. Useful to implement the "Did you mean ?" feature. :param bad_name: the command name that could not be resolved. :param valid_names: the list of valid command names, including aliases. :param error: the original error coming from Click. :return: the original error or a new one. """ import difflib matches = difflib.get_close_matches(bad_name, valid_names) if not matches: return error elif len(matches) == 1: extra_msg = f"Did you mean '{matches[0]}'?" else: matches_list = "\n".join(" " + match for match in matches) extra_msg = 'Did you mean one of these?\n' + matches_list error_msg = str(error) + " " + extra_msg return click.exceptions.UsageError(error_msg, error.ctx) def must_show_subcommand_aliases(self, ctx: click.Context) -> bool: return first_bool( self.show_subcommand_aliases, getattr(ctx, 'show_subcommand_aliases', None), Group.SHOW_SUBCOMMAND_ALIASES, ) def format_subcommand_name( self, ctx: click.Context, name: str, cmd: click.Command ) -> str: aliases = getattr(cmd, 'aliases', None) if aliases and self.must_show_subcommand_aliases(ctx): assert isinstance(ctx, cloup.Context) theme = cast( cloup.HelpTheme, ctx.formatter_settings.get("theme", DEFAULT_THEME) ) alias_list = self.format_subcommand_aliases(aliases, theme) return f"{name} {alias_list}" return name @staticmethod def format_subcommand_aliases(aliases: Sequence[str], theme: cloup.HelpTheme) -> str: secondary_style = theme.alias_secondary if secondary_style is None or secondary_style == theme.alias: return theme.alias(f"({', '.join(aliases)})") else: return ( secondary_style("(") + secondary_style(", ").join(theme.alias(alias) for alias in aliases) + secondary_style(")") ) # MyPy complains because "Signature of "group" incompatible with supertype". # The supertype signature is (*args, **kwargs), which is compatible with # this provided that you pass all arguments (expect "name") as keyword arg. @overload # type: ignore def command( # Why overloading? Refer to module docstring. self, name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: None = None, # default to Group.command_class or cloup.Command section: Optional[Section] = None, context_settings: Optional[Dict[str, Any]] = None, formatter_settings: Optional[Dict[str, Any]] = None, help: Optional[str] = None, epilog: Optional[str] = None, short_help: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", add_help_option: bool = True, no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, align_option_groups: Optional[bool] = None, show_constraints: Optional[bool] = None, params: Optional[List[click.Parameter]] = None, ) -> Callable[[AnyCallable], click.Command]: ... @overload def command( # Why overloading? Refer to module docstring. self, name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: Type[C], section: Optional[Section] = None, context_settings: Optional[Dict[str, Any]] = None, help: Optional[str] = None, epilog: Optional[str] = None, short_help: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", add_help_option: bool = True, no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, params: Optional[List[click.Parameter]] = None, **kwargs: Any, ) -> Callable[[AnyCallable], C]: ... def command( self, name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: Optional[Type[C]] = None, section: Optional[Section] = None, **kwargs: Any ) -> Callable[[AnyCallable], Union[click.Command, C]]: """Return a decorator that creates a new subcommand of this ``Group`` using the decorated function as callback. It takes the same arguments of :func:`command` plus: ``section``: ``Optional[Section]`` if provided, put the subcommand in this section. .. versionchanged:: 0.10.0 all arguments but ``name`` are now keyword-only. """ make_command = command( name=name, cls=(self.command_class if cls is None else cls), aliases=aliases, **kwargs ) def decorator(f: AnyCallable) -> click.Command: cmd = make_command(f) self.add_command(cmd, section=section) return cmd return decorator # MyPy complains because "Signature of "group" incompatible with supertype". # The supertype signature is (*args, **kwargs), which is compatible with # this provided that you pass all arguments (expect "name") as keyword arg. @overload # type: ignore def group( # Why overloading? Refer to module docstring. self, name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: None = None, # cls not provided section: Optional[Section] = None, sections: Iterable[Section] = (), align_sections: Optional[bool] = None, invoke_without_command: bool = False, no_args_is_help: bool = False, context_settings: Optional[Dict[str, Any]] = None, formatter_settings: Dict[str, Any] = {}, help: Optional[str] = None, epilog: Optional[str] = None, short_help: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", subcommand_metavar: Optional[str] = None, add_help_option: bool = True, chain: bool = False, hidden: bool = False, deprecated: bool = False, ) -> Callable[[AnyCallable], click.Group]: ... @overload def group( # Why overloading? Refer to module docstring. self, name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: Optional[Type[G]] = None, section: Optional[Section] = None, invoke_without_command: bool = False, no_args_is_help: bool = False, context_settings: Optional[Dict[str, Any]] = None, help: Optional[str] = None, epilog: Optional[str] = None, short_help: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", subcommand_metavar: Optional[str] = None, add_help_option: bool = True, chain: bool = False, hidden: bool = False, deprecated: bool = False, params: Optional[List[click.Parameter]] = None, **kwargs: Any ) -> Callable[[AnyCallable], G]: ... def group( # type: ignore self, name: Optional[None] = None, *, cls: Optional[Type[G]] = None, aliases: Optional[Iterable[str]] = None, section: Optional[Section] = None, **kwargs: Any ) -> Callable[[AnyCallable], Union[click.Group, G]]: """Return a decorator that creates a new subcommand of this ``Group`` using the decorated function as callback. It takes the same argument of :func:`group` plus: ``section``: ``Optional[Section]`` if provided, put the subcommand in this section. .. versionchanged:: 0.10.0 all arguments but ``name`` are now keyword-only. """ make_group = group( name=name, cls=cls or self._default_group_class(), aliases=aliases, **kwargs ) def decorator(f: AnyCallable) -> Union[click.Group, G]: cmd = make_group(f) self.add_command(cmd, section=section) return cmd return decorator @classmethod def _default_group_class(cls) -> Optional[Type[click.Group]]: if cls.group_class is None: return None if cls.group_class is type: return cls else: return cast(Type[click.Group], cls.group_class) # Why overloading? Refer to module docstring. @overload # In this overload: "cls: None = None" def command( name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: None = None, context_settings: Optional[Dict[str, Any]] = None, formatter_settings: Optional[Dict[str, Any]] = None, help: Optional[str] = None, short_help: Optional[str] = None, epilog: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", add_help_option: bool = True, no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, align_option_groups: Optional[bool] = None, show_constraints: Optional[bool] = None, params: Optional[List[click.Parameter]] = None, ) -> Callable[[AnyCallable], Command]: ... @overload def command( # In this overload: "cls: ClickCommand" name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: Type[C], context_settings: Optional[Dict[str, Any]] = None, help: Optional[str] = None, short_help: Optional[str] = None, epilog: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", add_help_option: bool = True, no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, params: Optional[List[click.Parameter]] = None, **kwargs: Any ) -> Callable[[AnyCallable], C]: ... # noinspection PyIncorrectDocstring def command( name: Optional[str] = None, *, aliases: Optional[Iterable[str]] = None, cls: Optional[Type[C]] = None, **kwargs: Any ) -> Callable[[AnyCallable], Union[Command, C]]: """ Return a decorator that creates a new command using the decorated function as callback. The only differences with respect to ``click.command`` are: - the default command class is :class:`cloup.Command` - supports constraints, provided that ``cls`` inherits from ``ConstraintMixin`` like ``cloup.Command`` (the default) - this function has detailed type hints and uses generics for the ``cls`` argument and return type. Note that the following arguments are about Cloup-specific features and are not supported by all ``click.Command``, so if you provide a custom ``cls`` make sure you don't set these: - ``formatter_settings`` - ``align_option_groups`` (``cls`` needs to inherit from ``OptionGroupMixin``) - ``show_constraints`` (``cls`` needs to inherit ``ConstraintMixin``). .. versionchanged:: 0.10.0 this function is now generic: the return type depends on what you provide as ``cls`` argument. .. versionchanged:: 0.9.0 all arguments but ``name`` are now keyword-only arguments. :param name: the name of the command to use unless a group overrides it. :param aliases: alternative names for this command. If ``cls`` is not a Cloup command class, aliases will be stored in the instantiated command by monkey-patching and aliases won't be documented in the help page of the command. :param cls: the command class to instantiate. :param context_settings: an optional dictionary with defaults that are passed to the context object. :param formatter_settings: arguments for the formatter; you can use :meth:`HelpFormatter.settings` to build this dictionary. :param help: the help string to use for this command. :param epilog: like the help string but it's printed at the end of the help page after everything else. :param short_help: the short help to use for this command. This is shown on the command listing of the parent command. :param options_metavar: metavar for options shown in the command's usage string. :param add_help_option: by default each command registers a ``--help`` option. This can be disabled by this parameter. :param no_args_is_help: this controls what happens if no arguments are provided. This option is disabled by default. If enabled this will add ``--help`` as argument if no arguments are passed :param hidden: hide this command from help outputs. :param deprecated: issues a message indicating that the command is deprecated. :param align_option_groups: whether to align the columns of all option groups' help sections. This is also available as a context setting having a lower priority than this attribute. Given that this setting should be consistent across all you commands, you should probably use the context setting only. :param show_constraints: whether to include a "Constraint" section in the command help. This is also available as a context setting having a lower priority than this attribute. :param params: **(click >= 8.1.0)** a list of parameters (:class:`Argument` and :class:`Option` instances). Params added with ``@option`` and ``@argument`` are appended to the end of the list if given. :param kwargs: any other argument accepted by the instantiated command class (``cls``). """ if callable(name): raise Exception( f"you forgot parenthesis in the command decorator for `{name.__name__}`. " f"While parenthesis are optional in Click >= 8.1, they are required in Cloup." ) def decorator(f: AnyCallable) -> C: if hasattr(f, '__cloup_constraints__'): if cls and not issubclass(cls, ConstraintMixin): raise TypeError( f"a `Command` must inherit from `cloup.ConstraintMixin` to support " f"constraints; `{cls}` doesn't") constraints = tuple(reversed(f.__cloup_constraints__)) del f.__cloup_constraints__ kwargs['constraints'] = constraints cmd_cls = cls if cls is not None else Command try: cmd = cast(C, click.command(name, cls=cmd_cls, **kwargs)(f)) if aliases: cmd.aliases = list(aliases) # type: ignore return cmd except TypeError as error: raise _process_unexpected_kwarg_error(error, _ARGS_INFO, cmd_cls) return decorator @overload # Why overloading? Refer to module docstring. def group( name: Optional[str] = None, *, cls: None = None, aliases: Optional[Iterable[str]] = None, sections: Iterable[Section] = (), align_sections: Optional[bool] = None, invoke_without_command: bool = False, no_args_is_help: bool = False, context_settings: Optional[Dict[str, Any]] = None, formatter_settings: Dict[str, Any] = {}, help: Optional[str] = None, short_help: Optional[str] = None, epilog: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", subcommand_metavar: Optional[str] = None, add_help_option: bool = True, chain: bool = False, hidden: bool = False, deprecated: bool = False, params: Optional[List[click.Parameter]] = None, ) -> Callable[[AnyCallable], Group]: ... @overload def group( name: Optional[str] = None, *, cls: Type[G], aliases: Optional[Iterable[str]] = None, invoke_without_command: bool = False, no_args_is_help: bool = False, context_settings: Optional[Dict[str, Any]] = None, help: Optional[str] = None, short_help: Optional[str] = None, epilog: Optional[str] = None, options_metavar: Optional[str] = "[OPTIONS]", subcommand_metavar: Optional[str] = None, add_help_option: bool = True, chain: bool = False, hidden: bool = False, deprecated: bool = False, params: Optional[List[click.Parameter]] = None, **kwargs: Any ) -> Callable[[AnyCallable], G]: ... def group( name: Optional[str] = None, *, cls: Optional[Type[G]] = None, **kwargs: Any ) -> Callable[[AnyCallable], click.Group]: """ Return a decorator that instantiates a ``Group`` (or a subclass of it) using the decorated function as callback. .. versionchanged:: 0.10.0 the ``cls`` argument can now be any ``click.Group`` (previously had to be a ``cloup.Group``) and the type of the instantiated command matches it (previously, the type was ``cloup.Group`` even if ``cls`` was a subclass of it). .. versionchanged:: 0.9.0 all arguments but ``name`` are now keyword-only arguments. :param name: the name of the command to use unless a group overrides it. :param cls: the ``click.Group`` (sub)class to instantiate. This is ``cloup.Group`` by default. Note that some of the arguments are only supported by ``cloup.Group``. :param sections: a list of Section objects containing the subcommands of this ``Group``. This argument is only supported by commands inheriting from :class:`cloup.SectionMixin`. :param align_sections: whether to align the columns of all subcommands' help sections. This is also available as a context setting having a lower priority than this attribute. Given that this setting should be consistent across all you commands, you should probably use the context setting only. :param context_settings: an optional dictionary with defaults that are passed to the context object. :param formatter_settings: arguments for the formatter; you can use :meth:`HelpFormatter.settings` to build this dictionary. :param help: the help string to use for this command. :param short_help: the short help to use for this command. This is shown on the command listing of the parent command. :param epilog: like the help string but it's printed at the end of the help page after everything else. :param options_metavar: metavar for options shown in the command's usage string. :param add_help_option: by default each command registers a ``--help`` option. This can be disabled by this parameter. :param hidden: hide this command from help outputs. :param deprecated: issues a message indicating that the command is deprecated. :param invoke_without_command: this controls how the multi command itself is invoked. By default it's only invoked if a subcommand is provided. :param no_args_is_help: this controls what happens if no arguments are provided. This option is enabled by default if `invoke_without_command` is disabled or disabled if it's enabled. If enabled this will add ``--help`` as argument if no arguments are passed. :param subcommand_metavar: string used in the command's usage string to indicate the subcommand place. :param chain: if this is set to `True`, chaining of multiple subcommands is enabled. This restricts the form of commands in that they cannot have optional arguments but it allows multiple commands to be chained together. :param params: **(click >= 8.1.0)** a list of parameters (:class:`Argument` and :class:`Option` instances). Params added with ``@option`` and ``@argument`` are appended to the end of the list if given. :param kwargs: any other argument accepted by the instantiated command class. """ if cls is None: return command(name=name, cls=Group, **kwargs) elif issubclass(cls, click.Group): return command(name=name, cls=cls, **kwargs) else: raise TypeError( 'this decorator requires `cls` to be a `click.Group` (or a subclass)') # Side stuff for better error messages class _ArgInfo(NamedTuple): arg_name: str requires: Type[Any] supported_by: str = "" _ARGS_INFO = { info.arg_name: info for info in [ _ArgInfo('formatter_settings', Command, "both `Command` and `Group`"), _ArgInfo('align_option_groups', OptionGroupMixin, "both `Command` and `Group`"), _ArgInfo('show_constraints', ConstraintMixin, "both `Command` and `Group`"), _ArgInfo('align_sections', SectionMixin, "`Group`") ] } def _process_unexpected_kwarg_error( error: TypeError, args_info: Dict[str, _ArgInfo], cls: Type[Command] ) -> TypeError: """Check if the developer tried to pass a Cloup-specific argument to a ``cls`` that doesn't support it and if that's the case, augments the error message to provide useful more info about the error.""" import re message = str(error) match = re.search('|'.join(arg_name for arg_name in args_info), message) if match is None: return error arg = match.group() info = args_info[arg] extra_info = reindent(f"""\n Hint: you set `cls={cls}` but this class doesn't support the argument `{arg}`. In Cloup, this argument is supported by `{info.supported_by}` via `{info.requires.__name__}`. """, 4) new_message = message + '\n' + extra_info return TypeError(new_message) cloup-3.0.5/cloup/_context.py000066400000000000000000000270441457133754100162010ustar00rootroot00000000000000from __future__ import annotations import warnings from functools import update_wrapper from typing import ( Any, Callable, cast, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING, overload, ) import click import cloup from cloup._util import coalesce, pick_non_missing from cloup.formatting import HelpFormatter from cloup.typing import MISSING, Possibly if TYPE_CHECKING: import typing_extensions as te P = te.ParamSpec("P") R = TypeVar("R") @overload def get_current_context() -> "Context": ... @overload def get_current_context(silent: bool = False) -> "Optional[Context]": ... def get_current_context(silent: bool = False) -> "Optional[Context]": """Equivalent to :func:`click.get_current_context` but casts the returned :class:`click.Context` object to :class:`cloup.Context` (which is safe when using cloup commands classes and decorators).""" return cast(Optional[Context], click.get_current_context(silent=silent)) def pass_context(f: "Callable[te.Concatenate[Context, P], R]") -> "Callable[P, R]": """Marks a callback as wanting to receive the current context object as first argument. Equivalent to :func:`click.pass_context` but assumes the current context is of type :class:`cloup.Context`.""" def new_func(*args: "P.args", **kwargs: "P.kwargs") -> R: return f(get_current_context(), *args, **kwargs) return update_wrapper(new_func, f) def _warn_if_formatter_settings_conflict( ctx_key: str, formatter_key: str, ctx_kwargs: Dict[str, Any], formatter_settings: Dict[str, Any], ) -> None: if ctx_kwargs.get(ctx_key) and formatter_settings.get(formatter_key): from textwrap import dedent formatter_arg = f'formatter_settings.{formatter_key}' warnings.warn(dedent(f""" You provided both {ctx_key} and {formatter_arg} as arguments of a Context. Unless you have a particular reason, you should set only one of them.. If you use both, {formatter_arg} will be used by the formatter. You can suppress this warning by setting: cloup.warnings.formatter_settings_conflict = False """)) class Context(click.Context): """A custom context for Cloup. Look up :class:`click.Context` for the list of all arguments. .. versionadded:: 0.9.0 added the ``check_constraints_consistency`` parameter. .. versionadded:: 0.8.0 :param ctx_args: arguments forwarded to :class:`click.Context`. :param align_option_groups: if True, align the definition lists of all option groups of a command. You can override this by setting the corresponding argument of ``Command`` (but you probably shouldn't: be consistent). :param align_sections: if True, align the definition lists of all subcommands of a group. You can override this by setting the corresponding argument of ``Group`` (but you probably shouldn't: be consistent). :param show_subcommand_aliases: whether to show the aliases of subcommands in the help of a ``cloup.Group``. :param show_constraints: whether to include a "Constraint" section in the command help (if at least one constraint is defined). :param check_constraints_consistency: enable additional checks for constraints which detects mistakes of the developer (see :meth:`cloup.Constraint.check_consistency`). :param formatter_settings: keyword arguments forwarded to :class:`HelpFormatter` in ``make_formatter``. This args are merged with those of the (eventual) parent context and then merged again (being overridden) by those of the command. **Tip**: use the static method :meth:`HelpFormatter.settings` to create this dictionary, so that you can be guided by your IDE. :param ctx_kwargs: keyword arguments forwarded to :class:`click.Context`. """ formatter_class: Type[HelpFormatter] = HelpFormatter def __init__( self, *ctx_args: Any, align_option_groups: Optional[bool] = None, align_sections: Optional[bool] = None, show_subcommand_aliases: Optional[bool] = None, show_constraints: Optional[bool] = None, check_constraints_consistency: Optional[bool] = None, formatter_settings: Dict[str, Any] = {}, **ctx_kwargs: Any, ): super().__init__(*ctx_args, **ctx_kwargs) self.align_option_groups = coalesce( align_option_groups, getattr(self.parent, 'align_option_groups', None), ) self.align_sections = coalesce( align_sections, getattr(self.parent, 'align_sections', None), ) self.show_subcommand_aliases = coalesce( show_subcommand_aliases, getattr(self.parent, 'show_subcommand_aliases', None), ) self.show_constraints = coalesce( show_constraints, getattr(self.parent, 'show_constraints', None), ) self.check_constraints_consistency = coalesce( check_constraints_consistency, getattr(self.parent, 'check_constraints_consistency', None) ) if cloup.warnings.formatter_settings_conflict: _warn_if_formatter_settings_conflict( 'terminal_width', 'width', ctx_kwargs, formatter_settings) _warn_if_formatter_settings_conflict( 'max_content_width', 'max_width', ctx_kwargs, formatter_settings) #: Keyword arguments for the HelpFormatter. Obtained by merging the options #: of the parent context with the one passed to this context. Before creating #: the help formatter, these options are merged with the (eventual) options #: provided to the command (having higher priority). self.formatter_settings = { **getattr(self.parent, 'formatter_settings', {}), **formatter_settings, } def get_formatter_settings(self) -> Dict[str, Any]: return { 'width': self.terminal_width, 'max_width': self.max_content_width, **self.formatter_settings, **getattr(self.command, 'formatter_settings', {}) } def make_formatter(self) -> HelpFormatter: opts = self.get_formatter_settings() return self.formatter_class(**opts) @staticmethod def settings( *, auto_envvar_prefix: Possibly[str] = MISSING, default_map: Possibly[Dict[str, Any]] = MISSING, terminal_width: Possibly[int] = MISSING, max_content_width: Possibly[int] = MISSING, resilient_parsing: Possibly[bool] = MISSING, allow_extra_args: Possibly[bool] = MISSING, allow_interspersed_args: Possibly[bool] = MISSING, ignore_unknown_options: Possibly[bool] = MISSING, help_option_names: Possibly[List[str]] = MISSING, token_normalize_func: Possibly[Callable[[str], str]] = MISSING, color: Possibly[bool] = MISSING, show_default: Possibly[bool] = MISSING, align_option_groups: Possibly[bool] = MISSING, align_sections: Possibly[bool] = MISSING, show_subcommand_aliases: Possibly[bool] = MISSING, show_constraints: Possibly[bool] = MISSING, check_constraints_consistency: Possibly[bool] = MISSING, formatter_settings: Possibly[Dict[str, Any]] = MISSING, ) -> Dict[str, Any]: """Utility method for creating a ``context_settings`` dictionary. :param auto_envvar_prefix: the prefix to use for automatic environment variables. If this is `None` then reading from environment variables is disabled. This does not affect manually set environment variables which are always read. :param default_map: a dictionary (like object) with default values for parameters. :param terminal_width: the width of the terminal. The default is inherited from parent context. If no context defines the terminal width then auto-detection will be applied. :param max_content_width: the maximum width for content rendered by Click (this currently only affects help pages). This defaults to 80 characters if not overridden. In other words: even if the terminal is larger than that, Click will not format things wider than 80 characters by default. In addition to that, formatters might add some safety mapping on the right. :param resilient_parsing: if this flag is enabled then Click will parse without any interactivity or callback invocation. Default values will also be ignored. This is useful for implementing things such as completion support. :param allow_extra_args: if this is set to `True` then extra arguments at the end will not raise an error and will be kept on the context. The default is to inherit from the command. :param allow_interspersed_args: if this is set to `False` then options and arguments cannot be mixed. The default is to inherit from the command. :param ignore_unknown_options: instructs click to ignore options it does not know and keeps them for later processing. :param help_option_names: optionally a list of strings that define how the default help parameter is named. The default is ``['--help']``. :param token_normalize_func: an optional function that is used to normalize tokens (options, choices, etc.). This for instance can be used to implement case-insensitive behavior. :param color: controls if the terminal supports ANSI colors or not. The default is auto-detection. This is only needed if ANSI codes are used in texts that Click prints which is by default not the case. This for instance would affect help output. :param show_default: Show defaults for all options. If not set, defaults to the value from a parent context. Overrides an option's ``show_default`` argument. :param align_option_groups: if True, align the definition lists of all option groups of a command. You can override this by setting the corresponding argument of ``Command`` (but you probably shouldn't: be consistent). :param align_sections: if True, align the definition lists of all subcommands of a group. You can override this by setting the corresponding argument of ``Group`` (but you probably shouldn't: be consistent). :param show_subcommand_aliases: whether to show the aliases of subcommands in the help of a ``cloup.Group``. :param show_constraints: whether to include a "Constraint" section in the command help (if at least one constraint is defined). :param check_constraints_consistency: enable additional checks for constraints which detects mistakes of the developer (see :meth:`cloup.Constraint.check_consistency`). :param formatter_settings: keyword arguments forwarded to :class:`HelpFormatter` in ``make_formatter``. This args are merged with those of the (eventual) parent context and then merged again (being overridden) by those of the command. **Tip**: use the static method :meth:`HelpFormatter.settings` to create this dictionary, so that you can be guided by your IDE. """ return pick_non_missing(locals()) cloup-3.0.5/cloup/_option_groups.py000066400000000000000000000334671457133754100174320ustar00rootroot00000000000000""" Implements the "option groups" feature. """ from collections import defaultdict from typing import ( Any, Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, overload, ) import click from click import Option, Parameter import cloup from cloup._params import option from cloup._util import first_bool, make_repr from cloup.constraints import Constraint from cloup.formatting import HelpSection, ensure_is_cloup_formatter from cloup.typing import Decorator, F class OptionGroup: def __init__(self, title: str, help: Optional[str] = None, constraint: Optional[Constraint] = None, hidden: bool = False): """Contains the information of an option group and identifies it. Note that, as far as the clients of this library are concerned, an ``OptionGroups`` acts as a "marker" for options, not as a container for related options. When you call ``@optgroup.option(...)`` you are not adding an option to a container, you are just adding an option marked with this option group. .. versionadded:: 0.8.0 The ``hidden`` parameter. """ if not title: raise ValueError('name is a mandatory argument') # pragma: no cover self.title = title self.help = help self._options: Sequence[click.Option] = [] self.constraint = constraint self.hidden = hidden @property def options(self) -> Sequence[click.Option]: return self._options @options.setter def options(self, options: Iterable[click.Option]) -> None: self._options = opts = tuple(options) if self.hidden: for opt in opts: opt.hidden = True elif all(opt.hidden for opt in opts): self.hidden = True def get_help_records(self, ctx: click.Context) -> List[Tuple[str, str]]: if self.hidden: return [] return [ opt.get_help_record(ctx) for opt in self if not opt.hidden # type: ignore ] # get_help_record() should return None only if opt.hidden def option(self, *param_decls: str, **attrs: Any) -> Callable[[F], F]: """Refer to :func:`cloup.option`.""" return option(*param_decls, group=self, **attrs) def __iter__(self) -> Iterator[click.Option]: return iter(self.options) def __getitem__(self, i: int) -> click.Option: return self.options[i] def __len__(self) -> int: return len(self.options) def __repr__(self) -> str: return make_repr(self, self.title, help=self.help, options=self.options) def __str__(self) -> str: return make_repr( self, self.title, options=[opt.name for opt in self.options]) def has_option_group(param: click.Parameter) -> bool: return getattr(param, 'group', None) is not None def get_option_group_of(param: click.Option) -> Optional[OptionGroup]: return getattr(param, 'group', None) # noinspection PyMethodMayBeStatic class OptionGroupMixin: """Implements support for: - option groups - the "Positional arguments" help section; this section is shown only if at least one of your arguments has non-empty ``help``. .. important:: In order to check the constraints defined on the option groups, a command must inherits from :class:`cloup.ConstraintMixin` too! .. versionadded:: 0.14.0 added the "Positional arguments" help section. .. versionchanged:: 0.8.0 this mixin now relies on ``cloup.HelpFormatter`` to align help sections. If a ``click.HelpFormatter`` is used with a ``TypeError`` is raised. .. versionchanged:: 0.8.0 removed ``format_option_group``. Added ``get_default_option_group`` and ``make_option_group_help_section``. .. versionadded:: 0.5.0 """ def __init__( self, *args: Any, align_option_groups: Optional[bool] = None, **kwargs: Any ) -> None: """ :param align_option_groups: whether to align the columns of all option groups' help sections. This is also available as a context setting having a lower priority than this attribute. Given that this setting should be consistent across all you commands, you should probably use the context setting only. :param args: positional arguments forwarded to the next class in the MRO :param kwargs: keyword arguments forwarded to the next class in the MRO """ super().__init__(*args, **kwargs) self.align_option_groups = align_option_groups params = kwargs.get('params') or [] arguments, option_groups, ungrouped_options = self._group_params(params) self.arguments = arguments self.option_groups = option_groups """List of all option groups, except the "default option group".""" self.ungrouped_options = ungrouped_options """List of options not explicitly assigned to an user-defined option group. These options will be included in the "default option group". **Note:** this list does not include options added automatically by Click based on context settings, like the ``--help`` option; use the :meth:`get_ungrouped_options` method if you need the real full list (which needs a ``Context`` object).""" @staticmethod def _group_params( params: List[Parameter] ) -> Tuple[List[click.Argument], List[OptionGroup], List[Option]]: options_by_group: Dict[OptionGroup, List[click.Option]] = defaultdict(list) arguments: List[click.Argument] = [] ungrouped_options: List[click.Option] = [] for param in params: if isinstance(param, click.Argument): arguments.append(param) elif isinstance(param, click.Option): grp = get_option_group_of(param) if grp is None: ungrouped_options.append(param) else: options_by_group[grp].append(param) option_groups = list(options_by_group.keys()) for group, options in options_by_group.items(): group.options = options return arguments, option_groups, ungrouped_options def get_ungrouped_options(self, ctx: click.Context) -> Sequence[click.Option]: """Return options not explicitly assigned to an option group (eventually including the ``--help`` option), i.e. options that will be part of the "default option group".""" help_option = ctx.command.get_help_option(ctx) if help_option is not None: return self.ungrouped_options + [help_option] else: return self.ungrouped_options def get_argument_help_record( self, arg: click.Argument, ctx: click.Context ) -> Tuple[str, str]: if isinstance(arg, cloup.Argument): return arg.get_help_record(ctx) return arg.make_metavar(), "" def get_arguments_help_section(self, ctx: click.Context) -> Optional[HelpSection]: args_with_help = (arg for arg in self.arguments if getattr(arg, "help", None)) if not any(args_with_help): return None return HelpSection( heading="Positional arguments", definitions=[ self.get_argument_help_record(arg, ctx) for arg in self.arguments ], ) def make_option_group_help_section( self, group: OptionGroup, ctx: click.Context ) -> HelpSection: """Return a ``HelpSection`` for an ``OptionGroup``, i.e. an object containing the title, the optional description and the options' definitions for this option group. .. versionadded:: 0.8.0 """ return HelpSection( heading=group.title, definitions=group.get_help_records(ctx), help=group.help, constraint=group.constraint.help(ctx) if group.constraint else None ) def must_align_option_groups( self, ctx: Optional[click.Context], default: bool = True ) -> bool: """ Return ``True`` if the help sections of all options groups should have their columns aligned. .. versionadded:: 0.8.0 """ return first_bool( self.align_option_groups, getattr(ctx, 'align_option_groups', None), default, ) def get_default_option_group( self, ctx: click.Context, is_the_only_visible_option_group: bool = False ) -> OptionGroup: """ Return an ``OptionGroup`` instance for the options not explicitly assigned to an option group, eventually including the ``--help`` option. .. versionadded:: 0.8.0 """ default_group = OptionGroup( "Options" if is_the_only_visible_option_group else "Other options") default_group.options = self.get_ungrouped_options(ctx) return default_group def format_params( self, ctx: click.Context, formatter: click.HelpFormatter ) -> None: formatter = ensure_is_cloup_formatter(formatter) visible_sections = [] # Positional arguments positional_arguments_section = self.get_arguments_help_section(ctx) if positional_arguments_section: visible_sections.append(positional_arguments_section) # Option groups option_group_sections = [ self.make_option_group_help_section(group, ctx) for group in self.option_groups if not group.hidden ] default_group = self.get_default_option_group( ctx, is_the_only_visible_option_group=not option_group_sections ) if not default_group.hidden: option_group_sections.append( self.make_option_group_help_section(default_group, ctx)) visible_sections += option_group_sections formatter.write_many_sections( visible_sections, aligned=self.must_align_option_groups(ctx), ) @overload def option_group( title: str, help: str, *options: Decorator, constraint: Optional[Constraint] = None, hidden: bool = False, ) -> Callable[[F], F]: ... @overload def option_group( title: str, *options: Decorator, help: Optional[str] = None, constraint: Optional[Constraint] = None, hidden: bool = False, ) -> Callable[[F], F]: ... # noinspection PyIncorrectDocstring def option_group(title: str, *args: Any, **kwargs: Any) -> Callable[[F], F]: """ Return a decorator that annotates a function with an option group. The ``help`` argument is an optional description and can be provided either as keyword argument or as 2nd positional argument after the ``name`` of the group:: # help as keyword argument @option_group(name, *options, help=None, ...) # help as 2nd positional argument @option_group(name, help, *options, ...) .. versionchanged:: 0.9.0 in order to support the decorator :func:`cloup.constrained_params`, ``@option_group`` now allows each input decorators to add multiple options. :param title: title of the help section describing the option group. :param help: an optional description shown below the name; can be provided as keyword argument or 2nd positional argument. :param options: an arbitrary number of decorators like ``click.option``, which attach one or multiple options to the decorated command function. :param constraint: an optional instance of :class:`~cloup.constraints.Constraint` (see :doc:`Constraints ` for more info); a description of the constraint will be shown between squared brackets aside the option group title (or below it if too long). :param hidden: if ``True``, the option group and all its options are hidden from the help page (all contained options will have their ``hidden`` attribute set to ``True``). """ if args and isinstance(args[0], str): return _option_group(title, options=args[1:], help=args[0], **kwargs) else: return _option_group(title, options=args, **kwargs) def _option_group( title: str, options: Sequence[Callable[[F], F]], help: Optional[str] = None, constraint: Optional[Constraint] = None, hidden: bool = False, ) -> Callable[[F], F]: if not isinstance(title, str): raise TypeError( 'the first argument of `@option_group` must be its title, a string; ' 'you probably forgot it' ) if not options: raise ValueError('you must provide at least one option') def decorator(f: F) -> F: opt_group = OptionGroup(title, help=help, constraint=constraint, hidden=hidden) if not hasattr(f, '__click_params__'): f.__click_params__ = [] # type: ignore cli_params = f.__click_params__ # type: ignore for add_option in reversed(options): prev_len = len(cli_params) add_option(f) added_options = cli_params[prev_len:] for new_option in added_options: if not isinstance(new_option, Option): raise TypeError( "only parameter of type `Option` can be added to option groups") existing_group = get_option_group_of(new_option) if existing_group is not None: raise ValueError( f'Option "{new_option}" was first assigned to group ' f'"{existing_group}" and then passed as argument to ' f'`@option_group({title!r}, ...)`' ) new_option.group = opt_group # type: ignore if hidden: new_option.hidden = True return f return decorator cloup-3.0.5/cloup/_params.py000066400000000000000000000032121457133754100157670ustar00rootroot00000000000000import click from click.decorators import _param_memo class Argument(click.Argument): """A :class:`click.Argument` with help text.""" def __init__(self, *args, help=None, **attrs): super().__init__(*args, **attrs) self.help = help def get_help_record(self, ctx): return self.make_metavar(), self.help or "" class Option(click.Option): """A :class:`click.Option` with an extra field ``group`` of type ``OptionGroup``.""" def __init__(self, *args, group=None, **attrs): super().__init__(*args, **attrs) self.group = group GroupedOption = Option """Alias of ``Option``.""" def argument(*param_decls, cls=None, **attrs): ArgumentClass = cls or Argument def decorator(f): _param_memo(f, ArgumentClass(param_decls, **attrs)) return f return decorator def option(*param_decls, cls=None, group=None, **attrs): """Attach an ``Option`` to the command. Refer to :class:`click.Option` and :class:`click.Parameter` for more info about the accepted parameters. In your IDE, you won't see arguments relating to shell completion, because they are different in Click 7 and 8 (both supported by Cloup): - in Click 7, it's ``autocompletion`` - in Click 8, it's ``shell_complete``. These arguments have different semantics, refer to Click's docs. """ OptionClass = cls or Option def decorator(f): _param_memo(f, OptionClass(param_decls, **attrs)) new_option = f.__click_params__[-1] new_option.group = group if group and group.hidden: new_option.hidden = True return f return decorator cloup-3.0.5/cloup/_params.pyi000066400000000000000000000054501457133754100161460ustar00rootroot00000000000000""" Types for parameter decorators are in this stub for convenience of implementation. """ from typing import Any, Callable, List, Optional, Sequence, Tuple, Type, TypeVar, Union import click from click.shell_completion import CompletionItem from cloup import OptionGroup F = TypeVar('F', bound=Callable[..., Any]) P = TypeVar('P', bound=click.Parameter) SimpleParamTypeLike = Union[click.ParamType, Type[float], Type[int], Type[str]] ParamTypeLike = Union[SimpleParamTypeLike, Tuple[SimpleParamTypeLike, ...]] ParamDefault = Union[Any, Callable[[], Any]] ParamCallback = Callable[[click.Context, P, Any], Any] ShellCompleteArg = Callable[ [click.Context, P, str], Union[List[CompletionItem], List[str]], ] class Argument(click.Argument): def __init__(self, *args: Any, help: Optional[str] = None, **attrs: Any): ... def get_help_record(self, ctx: click.Context) -> Tuple[str, str]: ... class Option(click.Option): def __init__(self, *args: Any, group: Optional[OptionGroup] = None, **attrs: Any): ... def argument( *param_decls: str, cls: Optional[Type[Argument]] = None, help: Optional[str] = None, type: Optional[ParamTypeLike] = None, required: Optional[bool] = None, default: Optional[ParamDefault] = None, callback: Optional[ParamCallback[click.Argument]] = None, nargs: Optional[int] = None, metavar: Optional[str] = None, expose_value: bool = True, envvar: Optional[Union[str, Sequence[str]]] = None, shell_complete: Optional[ShellCompleteArg[click.Argument]] = None, **kwargs: Any, ) -> Callable[[F], F]: ... def option( *param_decls: str, cls: Optional[Type[click.Option]] = None, # Commonly used metavar: Optional[str] = None, type: Optional[ParamTypeLike] = None, is_flag: Optional[bool] = None, default: Optional[ParamDefault] = None, required: Optional[bool] = None, help: Optional[str] = None, # Processing callback: Optional[ParamCallback[click.Option]] = None, is_eager: bool = False, # Help text tuning show_choices: bool = True, show_default: bool = False, show_envvar: bool = False, # Flag options flag_value: Optional[Any] = None, count: bool = False, # Multiple values nargs: Optional[int] = None, multiple: bool = False, # Prompt prompt: Union[bool, str] = False, confirmation_prompt: Union[bool, str] = False, prompt_required: bool = True, hide_input: bool = False, # Environment allow_from_autoenv: bool = True, envvar: Optional[Union[str, Sequence[str]]] = None, # Hiding hidden: bool = False, expose_value: bool = True, # Others group: Optional[OptionGroup] = None, shell_complete: Optional[ShellCompleteArg[click.Option]] = None, **kwargs: Any ) -> Callable[[F], F]: ... cloup-3.0.5/cloup/_sections.py000066400000000000000000000240171457133754100163410ustar00rootroot00000000000000from collections import OrderedDict from typing import ( Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type, TypeVar, Union, ) import click from cloup._util import first_bool, pick_not_none from cloup.formatting import HelpSection, ensure_is_cloup_formatter CommandType = TypeVar('CommandType', bound=Type[click.Command]) Subcommands = Union[Iterable[click.Command], Dict[str, click.Command]] class Section: """ A group of (sub)commands to show in the same help section of a ``MultiCommand``. You can use sections with any `Command` that inherits from :class:`SectionMixin`. .. versionchanged:: 0.6.0 removed the deprecated old name ``GroupSection``. .. versionchanged:: 0.5.0 introduced the new name ``Section`` and deprecated the old ``GroupSection``. """ def __init__(self, title: str, commands: Subcommands = (), is_sorted: bool = False): # noqa """ :param title: :param commands: sequence of commands or dict of commands keyed by name :param is_sorted: if True, ``list_commands()`` returns the commands in lexicographic order """ if not isinstance(title, str): raise TypeError( 'the first argument must be a string, the title; you probably forgot it') self.title = title self.is_sorted = is_sorted self.commands: OrderedDict[str, click.Command] = OrderedDict() if isinstance(commands, Sequence): self.commands = OrderedDict() for cmd in commands: self.add_command(cmd) elif isinstance(commands, dict): self.commands = OrderedDict(commands) else: raise TypeError('argument `commands` must be a sequence of commands ' 'or a dict of commands keyed by name') @classmethod def sorted(cls, title: str, commands: Subcommands = ()) -> 'Section': return cls(title, commands, is_sorted=True) def add_command(self, cmd: click.Command, name: Optional[str] = None) -> None: name = name or cmd.name if not name: raise TypeError('missing command name') if name in self.commands: raise Exception(f'command "{name}" already exists') self.commands[name] = cmd def list_commands(self) -> List[Tuple[str, click.Command]]: command_list = [(name, cmd) for name, cmd in self.commands.items() if not cmd.hidden] if self.is_sorted: command_list.sort() return command_list def __len__(self) -> int: return len(self.commands) def __repr__(self) -> str: return 'Section({}, is_sorted={})'.format(self.title, self.is_sorted) class SectionMixin: """ Adds to a :class:`click.MultiCommand` the possibility of organizing its subcommands into multiple help sections. Sections can be specified in the following ways: #. passing a list of :class:`Section` objects to the constructor setting the argument ``sections`` #. using :meth:`add_section` to add a single section #. using :meth:`add_command` with the argument `section` set Commands not assigned to any user-defined section are added to the "default section", whose title is "Commands" or "Other commands" depending on whether it is the only section or not. The default section is the last shown section in the help and its commands are listed in lexicographic order. .. versionchanged:: 0.8.0 this mixin now relies on ``cloup.HelpFormatter`` to align help sections. If a ``click.HelpFormatter`` is used with a ``TypeError`` is raised. .. versionchanged:: 0.8.0 removed ``format_section``. Added ``make_commands_help_section``. .. versionadded:: 0.5.0 """ def __init__( self, *args: Any, commands: Optional[Dict[str, click.Command]] = None, sections: Iterable[Section] = (), align_sections: Optional[bool] = None, **kwargs: Any, ): """ :param align_sections: whether to align the columns of all subcommands' help sections. This is also available as a context setting having a lower priority than this attribute. Given that this setting should be consistent across all you commands, you should probably use the context setting only. :param args: positional arguments forwarded to the next class in the MRO :param kwargs: keyword arguments forwarded to the next class in the MRO """ super().__init__(*args, commands=commands, **kwargs) # type: ignore self.align_sections = align_sections self._default_section = Section('__DEFAULT', commands=commands or []) self._user_sections: List[Section] = [] self._section_set = {self._default_section} for section in sections: self.add_section(section) def _add_command_to_section( self, cmd: click.Command, name: Optional[str] = None, section: Optional[Section] = None ) -> None: """Add a command to the section (if specified) or to the default section.""" name = name or cmd.name if section is None: section = self._default_section section.add_command(cmd, name) if section not in self._section_set: self._user_sections.append(section) self._section_set.add(section) def add_section(self, section: Section) -> None: """Add a :class:`Section` to this group. You can add the same section object only a single time. See Also: :meth:`section` """ if section in self._section_set: raise ValueError(f'section "{section}" was already added') self._user_sections.append(section) self._section_set.add(section) for name, cmd in section.commands.items(): # It's important to call self.add_command() and not super().add_command() here # otherwise subclasses' add_command() is not called. self.add_command(cmd, name, fallback_to_default_section=False) def section(self, title: str, *commands: click.Command, **attrs: Any) -> Section: """Create a new :class:`Section`, adds it to this group and returns it.""" section = Section(title, commands, **attrs) self.add_section(section) return section def add_command( self, cmd: click.Command, name: Optional[str] = None, section: Optional[Section] = None, fallback_to_default_section: bool = True, ) -> None: """ Add a subcommand to this ``Group``. **Implementation note:** ``fallback_to_default_section`` looks not very clean but, even if it's not immediate to see (it wasn't for me), I chose it over apparently cleaner options. :param cmd: :param name: :param section: a ``Section`` instance. The command must not be in the section already. :param fallback_to_default_section: if ``section`` is None and this option is enabled, the command is added to the "default section". If disabled, the command is not added to any section unless ``section`` is provided. This is useful for internal code and subclasses. Don't disable it unless you know what you are doing. """ super().add_command(cmd, name) # type: ignore if section or fallback_to_default_section: self._add_command_to_section(cmd, name, section) def list_sections( self, ctx: click.Context, include_default_section: bool = True ) -> List[Section]: """ Return the list of all sections in the "correct order". If ``include_default_section=True`` and the default section is non-empty, it will be included at the end of the list. """ section_list = list(self._user_sections) if include_default_section and len(self._default_section) > 0: default_section = Section.sorted( title='Other commands' if len(self._user_sections) > 0 else 'Commands', commands=self._default_section.commands) section_list.append(default_section) return section_list def format_subcommand_name( self, ctx: click.Context, name: str, cmd: click.Command ) -> str: """Used to format the name of the subcommands. This method is useful when you combine this extension with other click extensions that override :meth:`format_commands`. Most of these, like click-default-group, just add something to the name of the subcommands, which is exactly what this method allows you to do without overriding bigger methods. """ return name def make_commands_help_section( self, ctx: click.Context, section: Section ) -> Optional[HelpSection]: visible_subcommands = section.list_commands() if not visible_subcommands: return None return HelpSection( heading=section.title, definitions=[ (self.format_subcommand_name(ctx, name, cmd), cmd.get_short_help_str) for name, cmd in visible_subcommands ] ) def must_align_sections( self, ctx: Optional[click.Context], default: bool = True ) -> bool: return first_bool( self.align_sections, getattr(ctx, 'align_sections', None), default, ) def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: formatter = ensure_is_cloup_formatter(formatter) subcommand_sections = self.list_sections(ctx) help_sections = pick_not_none( self.make_commands_help_section(ctx, section) for section in subcommand_sections ) if not help_sections: return formatter.write_many_sections( help_sections, aligned=self.must_align_sections(ctx) ) cloup-3.0.5/cloup/_util.py000066400000000000000000000112531457133754100154650ustar00rootroot00000000000000"""Generic utilities.""" from typing import ( Any, Dict, Hashable, Iterable, List, Optional, Sequence, Type, TypeVar, ) import click from cloup.typing import MISSING, Possibly click_version_tuple = tuple(click.__version__.split('.')) click_major = int(click_version_tuple[0]) click_minor = int(click_version_tuple[1]) click_version_ge_8_1 = (click_major, click_minor) >= (8, 1) T = TypeVar('T') K = TypeVar('K', bound=Hashable) V = TypeVar('V') def pick_non_missing(d: Dict[K, Possibly[V]]) -> Dict[K, V]: return {key: val for key, val in d.items() if val is not MISSING} def class_name(obj: object) -> str: return obj.__class__.__name__ def check_arg(condition: bool, msg: str = '') -> None: if not condition: raise ValueError(msg) def indent_lines(lines: Iterable[str], width: int = 2) -> List[str]: spaces = ' ' * width return [spaces + line for line in lines] def make_repr( obj: Any, *args: Any, _line_len: int = 60, _indent: int = 2, **kwargs: Any ) -> str: """ Generate repr(obj). :param obj: object to represent :param args: positional arguments in the repr :param _line_len: if the repr length exceeds this, arguments will be on their own line; if negative, the repr will be in a single line regardless of its length :param _indent: indentation width of arguments in case they are shown in their own line :param kwargs: keyword arguments in the repr :return: str """ cls_name = obj.__class__.__name__ arglist = [ *(repr(arg) for arg in args), *(f'{key}={value!r}' for key, value in kwargs.items()), ] len_arglist = sum(len(s) for s in arglist) total_len = len(cls_name) + len_arglist + 2 * len(arglist) if 0 <= _line_len < total_len: lines = indent_lines(arglist, width=_indent) args_text = ',\n'.join(lines) return f'{cls_name}(\n{args_text}\n)' else: args_text = ', '.join(arglist) return f'{cls_name}({args_text})' def make_one_line_repr(obj: object, *args: Any, **kwargs: Any) -> str: return make_repr(obj, *args, _line_len=-1, **kwargs) def pluralize( count: int, zero: str = '', one: str = '', many: str = '', ) -> str: if count == 0 and zero: return zero if count == 1 and one: return one return many.format(count=count) def coalesce(*values: Optional[T]) -> Optional[T]: """Return the first value that is not ``None`` (or ``None`` if no such value exists).""" return next((val for val in values if val is not None), None) def first_bool(*values: Any) -> bool: """Return the first bool (or raises ``StopIteration`` if no bool is found).""" return next(val for val in values if isinstance(val, bool)) def pick_not_none(iterable: Iterable[Optional[T]]) -> List[T]: return [x for x in iterable if x is not None] def check_positive_int(value: Any, arg_name: str) -> None: error_type: Optional[Type[Exception]] = None if not isinstance(value, int): error_type = TypeError elif value <= 0: error_type = ValueError if error_type: raise error_type( f'argument `{arg_name}` should be a positive integer; it is {value!r}' ) def identity(x: T) -> T: return x class FrozenSpaceMeta(type): def __init__(cls, *args: Any): super().__init__(*args) d = {k: v for k, v in vars(cls).items() if not k.startswith('_')} type.__setattr__(cls, '_dict', d) def __setattr__(cls, key: str, value: Any) -> None: if key.startswith("__"): return super().__setattr__(key, value) else: raise Exception( "you can't set attributes on this class; only special dunder attributes " "(e.g. __annotations__) are allowed to be set for compatibility reasons." ) def asdict(cls) -> Dict[str, Any]: return cls._dict # type: ignore def __contains__(cls, item: str) -> bool: return item in cls.asdict() def __getitem__(cls, item: str) -> Any: return cls._dict[item] # type: ignore class FrozenSpace(metaclass=FrozenSpaceMeta): """A class used just as frozen namespace for constants.""" def __init__(self) -> None: raise Exception( "this class is just a namespace for constants, it's not instantiable.") def delete_keys(d: Dict[Any, Any], keys: Sequence[str]) -> None: for key in keys: del d[key] def reindent(text: str, indent: int = 0) -> str: import textwrap as tw if text.startswith('\n'): text = text[1:] text = tw.dedent(text) if indent: return tw.indent(text, ' ' * indent) return text cloup-3.0.5/cloup/constraints/000077500000000000000000000000001457133754100163445ustar00rootroot00000000000000cloup-3.0.5/cloup/constraints/__init__.py000066400000000000000000000023711457133754100204600ustar00rootroot00000000000000""" Constraints for parameter groups. .. versionadded:: v0.5.0 """ from ._conditional import If from ._core import ( AcceptAtMost, AcceptBetween, And, Constraint, ErrorFmt, ErrorRephraser, HelpRephraser, Operator, Or, Rephraser, RequireAtLeast, RequireExactly, WrapperConstraint, accept_none, all_or_none, mutually_exclusive, require_all, require_any, require_one, ) from ._support import ( BoundConstraintSpec, ConstraintMixin, constrained_params, constraint ) from .conditions import AllSet, AnySet, Equal, IsSet, Not from .exceptions import ConstraintViolated, UnsatisfiableConstraint __all__ = [ "AcceptAtMost", "AcceptBetween", "AllSet", "And", "AnySet", "BoundConstraintSpec", "Constraint", "ConstraintMixin", "ConstraintViolated", "Equal", "ErrorFmt", "ErrorRephraser", "HelpRephraser", "If", "IsSet", "Not", "Operator", "Or", "Rephraser", "RequireAtLeast", "RequireExactly", "UnsatisfiableConstraint", "WrapperConstraint", "accept_none", "all_or_none", "constrained_params", "constraint", "mutually_exclusive", "require_all", "require_any", "require_one", ] cloup-3.0.5/cloup/constraints/_conditional.py000066400000000000000000000057211457133754100213650ustar00rootroot00000000000000""" This modules contains classes for creating conditional constraints. """ from typing import Optional, Sequence, Union from click import Context, Parameter from ._core import Constraint from .conditions import AllSet, IsSet, Predicate from .exceptions import ConstraintViolated from .._util import make_repr def as_predicate(arg: Union[str, Sequence[str], Predicate]) -> Predicate: if isinstance(arg, str): return IsSet(arg) elif isinstance(arg, Predicate): return arg elif isinstance(arg, Sequence): return AllSet(*arg) else: raise TypeError("`arg` should be a string, a list of strings or a `Predicate`") class If(Constraint): """ Checks one constraint or another depending on the truth value of the condition. .. versionadded:: 0.8.0 you can now pass a sequence of parameter names as condition, which corresponds to the predicate ``AllSet(*param_names)``. :param condition: can be either an instance of ``Predicate`` or (more often) the name of a parameter or a list/tuple of parameters that must be all set for the condition to be true. :param then: a constraint checked if the condition is true. :param else_: an (optional) constraint checked if the condition is false. """ def __init__( self, condition: Union[str, Sequence[str], Predicate], then: Constraint, else_: Optional[Constraint] = None, ): self._condition = as_predicate(condition) self._then = then self._else = else_ def help(self, ctx: Context) -> str: condition = self._condition.description(ctx) then_help = self._then.help(ctx) else_help = self._else.help(ctx) if self._else else None if not self._else: return f"{then_help} if {condition}" else: return f"{then_help} if {condition}, otherwise {else_help}" def check_consistency(self, params: Sequence[Parameter]) -> None: self._then.check_consistency(params) if self._else: self._else.check_consistency(params) def check_values(self, params: Sequence[Parameter], ctx: Context) -> None: condition = self._condition condition_is_true = condition(ctx) branch = self._then if condition_is_true else self._else if branch is None: return try: branch.check_values(params, ctx=ctx) except ConstraintViolated as err: desc = ( condition.description(ctx) if condition_is_true else condition.negated_description(ctx) ) raise ConstraintViolated( f"when {desc}, {err}", ctx=ctx, constraint=self, params=params ) def __repr__(self) -> str: if self._else: return make_repr(self, self._condition, then=self._then, else_=self._else) return make_repr(self, self._condition, then=self._then) cloup-3.0.5/cloup/constraints/_core.py000066400000000000000000000514061457133754100200130ustar00rootroot00000000000000import abc from typing import ( Any, Callable, Optional, Sequence, TypeVar, Union, cast, overload, ) import click from cloup._util import ( FrozenSpace, check_arg, class_name, first_bool, make_one_line_repr, make_repr, pluralize, reindent, ) from .common import ( format_param_list, get_param_label, get_param_name, get_params_whose_value_is_set, get_required_params, param_value_is_set, ) from .exceptions import ConstraintViolated, UnsatisfiableConstraint from ..typing import Decorator, F Op = TypeVar('Op', bound='Operator') HelpRephraser = Callable[[click.Context, 'Constraint'], str] ErrorRephraser = Callable[[ConstraintViolated], str] class Constraint(abc.ABC): """ A constraint that can be checked against an arbitrary collection of CLI parameters with respect to a specific :class:`click.Context` (which contains the values assigned to the parameters in ``ctx.params``). .. versionchanged:: 0.9.0 calling a constraint, previously equivalent to :meth:`~Constraint.check`, is now equivalent to calling :func:`cloup.constrained_params` with this constraint as first argument. """ @staticmethod def must_check_consistency(ctx: click.Context) -> bool: """Return ``True`` if consistency checks are enabled. .. versionchanged:: 0.9.0 this method now a static method and takes a ``click.Context`` in input. """ return first_bool( getattr(ctx, 'check_constraints_consistency', True), True, ) def __getattr__(self, attr: str) -> Any: removed_attrs = ('toggle_consistency_checks', 'consistency_checks_toggled') if attr in removed_attrs: raise AttributeError( f'attribute `{attr}` was removed in v0.9. You can now enable/disable ' f'consistency checks using the `click.Context` parameter ' f'`check_constraints_consistency`. ' f'Pass it as part of your `context_settings`.' ) else: raise AttributeError(attr) @abc.abstractmethod def help(self, ctx: click.Context) -> str: """A description of the constraint. """ def check_consistency(self, params: Sequence[click.Parameter]) -> None: """ Perform some sanity checks that detect inconsistencies between these constraints and the properties of the input parameters (e.g. required). For example, a constraint that requires the parameters to be mutually exclusive is not consistent with a group of parameters with multiple required options. These sanity checks are meant to catch developer's mistakes and don't depend on the values assigned to the parameters; therefore: - they can be performed before any parameter parsing - they can be disabled in production (setting ``check_constraints_consistency=False`` in ``context_settings``) :param params: list of :class:`click.Parameter` instances :raises: :exc:`~cloup.constraints.errors.UnsatisfiableConstraint` if the constraint cannot be satisfied independently from the values provided by the user """ @abc.abstractmethod def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: """ Check that the constraint is satisfied by the input parameters in the given context, which (among other things) contains the values assigned to the parameters in ``ctx.params``. You probably don't want to call this method directly. Use :meth:`check` instead. :param params: list of :class:`click.Parameter` instances :param ctx: :class:`click.Context` :raises: :exc:`~cloup.constraints.ConstraintViolated` """ @overload def check( self, params: Sequence[click.Parameter], ctx: Optional[click.Context] = None ) -> None: ... @overload def check(self, params: Sequence[str], ctx: Optional[click.Context] = None) -> None: ... def check( self, params: Union[Sequence[click.Parameter], Sequence[str]], ctx: Optional[click.Context] = None ) -> None: """ Raise an exception if the constraint is not satisfied by the input parameters in the given (or current) context. This method calls both :meth:`check_consistency` (if enabled) and :meth:`check_values`. .. tip:: By default :meth:`check_consistency` is called since it shouldn't have any performance impact. Nonetheless, you can disable it in production passing ``check_constraints_consistency=False`` as part of your ``context_settings``. :param params: an iterable of parameter names or a sequence of :class:`click.Parameter` :param ctx: a `click.Context`; if not provided, :func:`click.get_current_context` is used :raises: :exc:`~cloup.constraints.ConstraintViolated` :exc:`~cloup.constraints.UnsatisfiableConstraint` """ from ._support import ConstraintMixin if not params: raise ValueError("argument `params` can't be empty") ctx = click.get_current_context() if ctx is None else ctx if not isinstance(ctx.command, ConstraintMixin): # this is needed for mypy raise TypeError('constraints work only if the command inherits from ' '`ConstraintMixin`') if isinstance(params[0], str): param_names = cast(Sequence[str], params) params_objects = ctx.command.get_params_by_name(param_names) else: params_objects = cast(Sequence[click.Parameter], params) if Constraint.must_check_consistency(ctx): self.check_consistency(params_objects) return self.check_values(params_objects, ctx) def rephrased( self, help: Union[None, str, HelpRephraser] = None, error: Union[None, str, ErrorRephraser] = None, ) -> 'Rephraser': """ Override the help string and/or the error message of this constraint wrapping it with a :class:`Rephraser`. :param help: if provided, overrides the help string of this constraint. It can be a string or a function ``(ctx: click.Context, constr: Constraint) -> str``. If you want to hide this constraint from the help, pass ``help=""``. :param error: if provided, overrides the error message of this constraint. It can be: - a string, eventually a ``format`` string supporting the replacement fields described in :class:`ErrorFmt`. - or a function ``(err: ConstraintViolated) -> str``; note that a :class:`ConstraintViolated` error has fields for ``ctx``, ``constraint`` and ``params``, so it's a complete description of what happened. """ return Rephraser(self, help=help, error=error) def hidden(self) -> 'Rephraser': """Hide this constraint from the command help.""" return Rephraser(self, help='') def __call__(self, *param_adders: Decorator) -> Callable[[F], F]: """Equivalent to calling :func:`cloup.constrained_params` with this constraint as first argument. .. versionchanged:: 0.9.0 this method, previously equivalent to :meth:`~Constraint.check`, is now equivalent to calling :func:`cloup.constrained_params` with this constraint as first argument. """ from ._support import constrained_params # TODO: remove this check in the future if not callable(param_adders[0]): from cloup import __version__ raise TypeError(reindent(f"""\n since Cloup v0.9, calling a constraint has a completely different semantics and takes parameter decorators as arguments, see: https://cloup.readthedocs.io/en/v{__version__}/pages/constraints.html#constraints-as-decorators To check a constraint imperatively, you can use the check() method. """, 4)) return constrained_params(self, *param_adders) def __or__(self, other: 'Constraint') -> 'Or': return Or(self, other) def __and__(self, other: 'Constraint') -> 'And': return And(self, other) def __repr__(self) -> str: return f'{class_name(self)}()' class Operator(Constraint, abc.ABC): """Base class for all n-ary operators defined on constraints. """ HELP_SEP: str """Used as separator of all constraints' help strings.""" def __init__(self, *constraints: Constraint): """N-ary operator for constraints. :param constraints: operands """ self.constraints = constraints def help(self, ctx: click.Context) -> str: return self.HELP_SEP.join( '(%s)' % c.help(ctx) if isinstance(c, Operator) else c.help(ctx) for c in self.constraints ) def check_consistency(self, params: Sequence[click.Parameter]) -> None: for c in self.constraints: c.check_consistency(params) def __repr__(self) -> str: return make_repr(self, *self.constraints) class And(Operator): """It's satisfied if all operands are satisfied.""" HELP_SEP = ' and ' def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: for c in self.constraints: c.check_values(params, ctx) def __and__(self, other: Constraint) -> 'And': if isinstance(other, And): return And(*self.constraints, *other.constraints) return And(*self.constraints, other) class Or(Operator): """It's satisfied if at least one of the operands is satisfied.""" HELP_SEP = ' or ' def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: for c in self.constraints: try: c.check_values(params, ctx) return except ConstraintViolated: pass raise ConstraintViolated.default( self.help(ctx), ctx=ctx, constraint=self, params=params ) def __or__(self, other: Constraint) -> 'Or': if isinstance(other, Or): return Or(*self.constraints, *other.constraints) return Or(*self.constraints, other) class ErrorFmt(FrozenSpace): """:class:`Rephraser` allows you to pass a ``format`` string as ``error`` argument; this class contains the "replacement fields" supported by such format string. You can use them as following:: mutually_exclusive.rephrased( error=f"{ErrorFmt.error}\\n" f"Some extra information here." ) """ error = '{error}' """Replaced by the original error message. Useful if all you want is to append or prepend some extra info to the original error message.""" param_list = '{param_list}' """Replaced by a 2-space indented list of the constrained parameters.""" class Rephraser(Constraint): """A constraint decorator that can override the help and/or the error message of the wrapped constraint. You'll rarely (if ever) use this class directly. In most cases, you'll use the method :meth:`Constraint.rephrased`. Refer to it for more info. .. seealso:: - :meth:`Constraint.rephrased` -- wraps a constraint with a ``Rephraser``. - :class:`WrapperConstraint` -- alternative to ``Rephraser``. - :class:`ErrorFmt` -- describes the keyword you can use in an error format string. """ def __init__( self, constraint: Constraint, help: Union[None, str, HelpRephraser] = None, error: Union[None, str, ErrorRephraser] = None, ): if help is None and error is None: raise ValueError('`help` and `error` cannot both be `None`') self.constraint = constraint self._help = help self._error = error def help(self, ctx: click.Context) -> str: if self._help is None: return self.constraint.help(ctx) elif isinstance(self._help, str): return self._help else: return self._help(ctx, self.constraint) def _get_rephrased_error(self, err: ConstraintViolated) -> Optional[str]: if self._error is None: return None elif isinstance(self._error, str): return self._error.format( error=str(err), param_list=format_param_list(err.params), ) else: return self._error(err) def check_consistency(self, params: Sequence[click.Parameter]) -> None: try: self.constraint.check_consistency(params) except UnsatisfiableConstraint as exc: raise UnsatisfiableConstraint( self, params=params, reason=exc.reason) def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: try: self.constraint.check_values(params, ctx) except ConstraintViolated as err: rephrased_error = self._get_rephrased_error(err) if rephrased_error: raise ConstraintViolated( rephrased_error, ctx=ctx, constraint=self, params=params) raise def __repr__(self) -> str: return make_one_line_repr(self, help=self._help) class WrapperConstraint(Constraint, metaclass=abc.ABCMeta): """Abstract class that wraps another constraint and delegates all methods to it. Useful when you want to define a parametric constraint combining other existing constraints minimizing the boilerplate. This is an alternative to defining a function and using :class:`Rephraser`. Feel free to do that in your code, but cloup will stick to the convention that parametric constraints are defined as classes and written in camel-case.""" def __init__(self, constraint: Constraint, **attrs: Any): """ :param constraint: the constraint to wrap :param attrs: these are just used to generate a ``__repr__`` method """ self._constraint = constraint self._attrs = attrs def help(self, ctx: click.Context) -> str: return self._constraint.help(ctx) def check_consistency(self, params: Sequence[click.Parameter]) -> None: try: self._constraint.check_consistency(params) except UnsatisfiableConstraint as exc: raise UnsatisfiableConstraint(self, params=params, reason=exc.reason) def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: self._constraint.check_values(params, ctx) def __repr__(self) -> str: return make_repr(self, **self._attrs) class _RequireAll(Constraint): """Satisfied if all parameters are set.""" def help(self, ctx: click.Context) -> str: return 'all required' def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: values = ctx.params unset_params = [param for param in params if not param_value_is_set(param, values[get_param_name(param)])] if any(unset_params): raise ConstraintViolated( pluralize( len(unset_params), one=f"{get_param_label(unset_params[0])} is required", many=f"the following parameters are required:\n" f"{format_param_list(unset_params)}"), ctx=ctx, constraint=self, params=params, ) class RequireAtLeast(Constraint): """Satisfied if the number of set parameters is >= n.""" def __init__(self, n: int): check_arg(n >= 0) self.min_num_params = n def help(self, ctx: click.Context) -> str: return f'at least {self.min_num_params} required' def check_consistency(self, params: Sequence[click.Parameter]) -> None: n = self.min_num_params if len(params) < n: reason = ( f'the constraint requires a minimum of {n} parameters but ' f'it is applied on a group of only {len(params)} parameters!' ) raise UnsatisfiableConstraint(self, params, reason) def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: n = self.min_num_params given_params = get_params_whose_value_is_set(params, ctx.params) if len(given_params) < n: raise ConstraintViolated( f"at least {n} of the following parameters must be set:\n" f"{format_param_list(params)}", ctx=ctx, constraint=self, params=params, ) def __repr__(self) -> str: return make_repr(self, self.min_num_params) class AcceptAtMost(Constraint): """Satisfied if the number of set parameters is <= n.""" def __init__(self, n: int): check_arg(n >= 0) self.max_num_params = n def help(self, ctx: click.Context) -> str: return f'at most {self.max_num_params} accepted' def check_consistency(self, params: Sequence[click.Parameter]) -> None: num_required_params = len(get_required_params(params)) if num_required_params > self.max_num_params: reason = f'{num_required_params} of the parameters are required' raise UnsatisfiableConstraint(self, params, reason) def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: n = self.max_num_params given_params = get_params_whose_value_is_set(params, ctx.params) if len(given_params) > n: raise ConstraintViolated( f"no more than {n} of the following parameters can be set:\n" f"{format_param_list(params)}", ctx=ctx, constraint=self, params=params, ) def __repr__(self) -> str: return make_repr(self, self.max_num_params) class RequireExactly(WrapperConstraint): """Requires an exact number of parameters to be set.""" def __init__(self, n: int): check_arg(n > 0) # Defined as a wrapper to reuse check_consistency() of the wrapped constraint. super().__init__(RequireAtLeast(n) & AcceptAtMost(n)) self.num_params = n def help(self, ctx: click.Context) -> str: return f'exactly {self.num_params} required' def check_values(self, params: Sequence[click.Parameter], ctx: click.Context) -> None: n = self.num_params given_params = get_params_whose_value_is_set(params, ctx.params) if len(given_params) != n: reason = pluralize( count=n, zero='none of the following parameters must be set:\n', many=f'exactly {n} of the following parameters must be set:\n' ) + format_param_list(params) raise ConstraintViolated( reason, ctx=ctx, constraint=self, params=params) def __repr__(self) -> str: return make_repr(self, self.num_params) class AcceptBetween(WrapperConstraint): def __init__(self, min: int, max: int): # noqa """Satisfied if the number of set parameters is between ``min`` and ``max`` (included). :param min: must be an integer >= 0 :param max: must be an integer > min """ check_arg(min >= 0, 'min must be non-negative') if max is not None: check_arg(min < max, 'must be: min < max.') super().__init__(RequireAtLeast(min) & AcceptAtMost(max), min=min, max=max) self.min_num_params = min self.max_num_params = max def help(self, ctx: click.Context) -> str: return f'at least {self.min_num_params} required, ' \ f'at most {self.max_num_params} accepted' require_all = _RequireAll() """Satisfied if all parameters are set.""" accept_none = AcceptAtMost(0).rephrased( help='all forbidden', error=f'the following parameters should not be provided:\n' f'{ErrorFmt.param_list}' ) """Satisfied if none of the parameters is set. Useful only in conditional constraints.""" all_or_none = (require_all | accept_none).rephrased( help='provide all or none', error=f'the following parameters should be provided together (or none of ' f'them should be provided):\n' f'{ErrorFmt.param_list}', ) """Satisfied if either all or none of the parameters are set.""" mutually_exclusive = AcceptAtMost(1).rephrased( help='mutually exclusive', error=f'the following parameters are mutually exclusive:\n' f'{ErrorFmt.param_list}' ) """Satisfied if at most one of the parameters is set.""" require_any = RequireAtLeast(1) """Alias for ``RequireAtLeast(1)``.""" require_one = RequireExactly(1) """Alias for ``RequireExactly(1)``.""" cloup-3.0.5/cloup/constraints/_support.py000066400000000000000000000175721457133754100206050ustar00rootroot00000000000000from typing import ( Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Sequence, TYPE_CHECKING, Tuple, Union, ) import click from ._core import Constraint from .common import join_param_labels from .._util import first_bool from ..typing import Decorator, F if TYPE_CHECKING: from cloup import HelpFormatter, OptionGroup class BoundConstraintSpec(NamedTuple): """A NamedTuple storing a ``Constraint`` and the **names of the parameters** it has to check.""" constraint: Constraint param_names: Union[Sequence[str]] def resolve_params(self, cmd: 'ConstraintMixin') -> 'BoundConstraint': return BoundConstraint( self.constraint, cmd.get_params_by_name(self.param_names) ) def _constraint_memo( f: Any, constr: Union[BoundConstraintSpec, 'BoundConstraint'] ) -> None: if not hasattr(f, '__cloup_constraints__'): f.__cloup_constraints__ = [] f.__cloup_constraints__.append(constr) def constraint(constr: Constraint, params: Iterable[str]) -> Callable[[F], F]: """Register a constraint on a list of parameters specified by (destination) name (e.g. the default name of ``--input-file`` is ``input_file``).""" spec = BoundConstraintSpec(constr, tuple(params)) def decorator(f: F) -> F: _constraint_memo(f, spec) return f return decorator def constrained_params( constr: Constraint, *param_adders: Decorator, ) -> Callable[[F], F]: """ Return a decorator that adds the given parameters and applies a constraint to them. Equivalent to:: @param_adders[0] ... @param_adders[-1] @constraint(constr, ) This decorator saves you to manually (re)type the parameter names. It can also be used inside ``@option_group``. Instead of using this decorator, you can also call the constraint itself:: @constr(*param_adders) but remember that: - Python 3.9 is the first that allows arbitrary expressions on the right of ``@``; - using a long conditional/composite constraint as decorator may be less readable. In these cases, you may consider using ``@constrained_params``. .. versionadded:: 0.9.0 :param constr: an instance of :class:`Constraint` :param param_adders: function decorators, each attaching a single parameter to the decorated function. """ def decorator(f: F) -> F: reversed_params = [] for add_param in reversed(param_adders): add_param(f) param = f.__click_params__[-1] # type: ignore reversed_params.append(param) bound_constr = BoundConstraint(constr, tuple(reversed_params[::-1])) _constraint_memo(f, bound_constr) return f return decorator class BoundConstraint(NamedTuple): """Internal utility ``NamedTuple`` that represents a ``Constraint`` bound to a collection of ``click.Parameter`` instances. Note: this is not a subclass of Constraint.""" constraint: Constraint params: Sequence[click.Parameter] def check_consistency(self) -> None: self.constraint.check_consistency(self.params) def check_values(self, ctx: click.Context) -> None: self.constraint.check_values(self.params, ctx) def get_help_record(self, ctx: click.Context) -> Optional[Tuple[str, str]]: constr_help = self.constraint.help(ctx) if not constr_help: return None param_list = '{%s}' % join_param_labels(self.params) return param_list, constr_help class ConstraintMixin: """Provides support for constraints.""" def __init__( self, *args: Any, constraints: Sequence[Union[BoundConstraintSpec, BoundConstraint]] = (), show_constraints: Optional[bool] = None, **kwargs: Any, ): """ :param constraints: sequence of constraints bound to specific groups of parameters. Note that constraints applied to option groups are collected from the option groups themselves, so they don't need to be included in this argument. :param show_constraints: whether to include a "Constraint" section in the command help. This is also available as a context setting having a lower priority than this attribute. :param args: positional arguments forwarded to the next class in the MRO :param kwargs: keyword arguments forwarded to the next class in the MRO """ super().__init__(*args, **kwargs) self.show_constraints = show_constraints # This allows constraints to efficiently access parameters by name self._params_by_name: Dict[str, click.Parameter] = { param.name: param for param in self.params # type: ignore } # Collect constraints applied to option groups and bind them to the # corresponding Option instances option_groups: Tuple[OptionGroup, ...] = getattr(self, 'option_groups', tuple()) self.optgroup_constraints = tuple( BoundConstraint(grp.constraint, grp.options) for grp in option_groups if grp.constraint is not None ) """Constraints applied to ``OptionGroup`` instances.""" # Bind constraints defined via @constraint to click.Parameter instances self.param_constraints: Tuple[BoundConstraint, ...] = tuple( ( constr if isinstance(constr, BoundConstraint) else constr.resolve_params(self) ) for constr in constraints ) """Constraints registered using ``@constraint`` (or equivalent method).""" self.all_constraints = self.optgroup_constraints + self.param_constraints """All constraints applied to parameter/option groups of this command.""" def parse_args(self, ctx: click.Context, args: List[str]) -> List[str]: # Check constraints' consistency *before* parsing if not ctx.resilient_parsing and Constraint.must_check_consistency(ctx): for constr in self.all_constraints: constr.check_consistency() args = super().parse_args(ctx, args) # type: ignore # Skip constraints checking if the user wants to see --help for subcommand # or if resilient parsing is enabled should_show_subcommand_help = isinstance(ctx.command, click.Group) and any( help_flag in args for help_flag in ctx.help_option_names ) if ctx.resilient_parsing or should_show_subcommand_help: return args # Check constraints for constr in self.all_constraints: constr.check_values(ctx) return args def get_param_by_name(self, name: str) -> click.Parameter: try: return self._params_by_name[name] except KeyError: raise KeyError(f"there's no CLI parameter named '{name}'") def get_params_by_name(self, names: Iterable[str]) -> Sequence[click.Parameter]: return tuple(self.get_param_by_name(name) for name in names) def format_constraints(self, ctx: click.Context, formatter: "HelpFormatter") -> None: records_gen = (constr.get_help_record(ctx) for constr in self.param_constraints) records = [rec for rec in records_gen if rec is not None] if records: with formatter.section('Constraints'): formatter.write_dl(records) def must_show_constraints(self, ctx: click.Context) -> bool: # By default, don't show constraints return first_bool( self.show_constraints, getattr(ctx, "show_constraints", None), False, ) def ensure_constraints_support(command: click.Command) -> ConstraintMixin: if isinstance(command, ConstraintMixin): return command raise TypeError( 'a Command must inherits from ConstraintMixin to support constraints') cloup-3.0.5/cloup/constraints/common.py000066400000000000000000000070161457133754100202120ustar00rootroot00000000000000""" Useful functions used to implement constraints and predicates. """ from typing import Any, Dict, Iterable, List, Sequence from click import Argument, Context, Option, Parameter def param_value_is_set(param: Parameter, value: Any) -> bool: """Define what it means for a parameter of a specific kind to be "set". All cases are obvious besides that of boolean options: - (common rule) if the value is ``None``, the parameter is unset; - a parameter that takes multiple values is set if at least one argument is provided; - a boolean **flag** is set only if True; - a boolean option is set if not None, even if it's False. """ if value is None: return False # checking for param.is_flag is redundant but necessary to work around # Click 8.0.1 issue: https://github.com/pallets/click/issues/1925 elif isinstance(param, Option) and param.is_flag and param.is_bool_flag: return bool(value) elif param.nargs != 1 or param.multiple: return len(value) > 0 return True def get_param_name(param: Parameter) -> str: """Return the name of a parameter casted to ``str``. Use this function to avoid typing errors in places where you expect a parameter having a name. """ if param.name is None: raise TypeError( '`param.name` is required to be a string in this context.\n' 'Hint: `param.name` is None only when `parameter.expose_value` is False, ' 'so you are probably using this option incorrectly.' ) return param.name def get_params_whose_value_is_set( params: Iterable[Parameter], values: Dict[str, Any] ) -> List[Parameter]: """Filter ``params``, returning only the parameters that have a value. Boolean flags are considered "set" if their value is ``True``.""" return [p for p in params if param_value_is_set(p, values[get_param_name(p)])] def get_required_params(params: Iterable[Parameter]) -> List[Parameter]: return [p for p in params if p.required] def get_param_label(param: Parameter) -> str: if param.param_type_name == 'argument': return param.human_readable_name return sorted(param.opts, key=len)[-1] def join_param_labels(params: Iterable[Parameter], sep: str = ', ') -> str: return sep.join(get_param_label(p) for p in params) def join_with_and(strings: Sequence[str], sep: str = ', ') -> str: if not strings: return '' if len(strings) == 1: return strings[0] return sep.join(strings[:-1]) + ' and ' + strings[-1] def format_param(param: Parameter) -> str: if isinstance(param, Argument): return param.human_readable_name # return ' | '.join(param.opts) if len(param.opts) == 1: return param.opts[0] short, long = sorted(param.opts, key=len) return f'{long} ({short})' def format_param_list(param_list: Iterable[Parameter], indent: int = 2) -> str: lines = map(format_param, param_list) indentation = ' ' * indent return ''.join(indentation + line + '\n' for line in lines) def param_label_by_name(ctx: Any, name: str) -> str: return get_param_label(ctx.command.get_param_by_name(name)) def get_param_labels(ctx: Any, param_names: Iterable[str]) -> List[str]: params = ctx.command.get_params_by_name(param_names) return [get_param_label(param) for param in params] def param_value_by_name(ctx: Context, name: str) -> Any: try: return ctx.params[name] except KeyError: raise KeyError(f'"{name}" is not the name of a CLI parameter') cloup-3.0.5/cloup/constraints/conditions.py000066400000000000000000000223141457133754100210710ustar00rootroot00000000000000""" This modules contains predicates with an associated description that you can use as conditions of conditional constraints (see :class:`cloup.constraints.If`). Predicates should be treated as immutable objects, even though immutability is not (at the moment) enforced. """ import abc from typing import Any, Dict, Generic, TypeVar import click from ._support import ensure_constraints_support from .common import ( get_param_labels, get_param_name, join_with_and, param_label_by_name, param_value_by_name, param_value_is_set, ) from .._util import make_repr P = TypeVar('P', bound='Predicate') class Predicate(abc.ABC): """ A ``Callable`` that takes a ``click.Context`` and returns a boolean, with an associated description. Meant to be used as condition in a conditional constraint (see :class:`~cloup.constraints.If`). """ @abc.abstractmethod def description(self, ctx: click.Context) -> str: """Succinct description of the predicate (alias: `desc`).""" def negated_description(self, ctx: click.Context) -> str: """Succinct description of the negation of this predicate (alias: `neg_desc`).""" return 'NOT(%s)' % self.description(ctx) def desc(self, ctx: click.Context) -> str: """Short alias for :meth:`description`.""" return self.description(ctx) def neg_desc(self, ctx: click.Context) -> str: """Short alias for :meth:`negated_description`.""" return self.negated_description(ctx) def negated(self) -> "Predicate": return ~self @abc.abstractmethod def __call__(self, ctx: click.Context) -> bool: """Evaluate the predicate on the given context.""" def __invert__(self) -> "Predicate": return Not(self) def __or__(self, other: "Predicate") -> "Predicate": return _Or(self, other) def __and__(self, other: "Predicate") -> "Predicate": return _And(self, other) def __repr__(self) -> str: return make_repr(self, *self._public_fields().values()) def _public_fields(self) -> Dict[str, Any]: return {k: v for k, v in vars(self).items() if not k.startswith('_')} def __eq__(self, other: object) -> bool: return isinstance(other, self.__class__) and ( self._public_fields() == other._public_fields() ) class Not(Predicate, Generic[P]): """Logical NOT of a predicate.""" def __init__(self, predicate: P): self.predicate = predicate def description(self, ctx: click.Context) -> str: return self.predicate.negated_description(ctx) def negated_description(self, ctx: click.Context) -> str: return self.predicate.description(ctx) def __call__(self, ctx: click.Context) -> bool: return not self.predicate(ctx) def __invert__(self) -> P: return self.predicate def __repr__(self) -> str: return 'Not(%r)' % self.predicate class _Operator(Predicate, metaclass=abc.ABCMeta): """Operator between two or more predicates.""" DESC_SEP: str def __init__(self, *predicates: Predicate): if len(predicates) < 2: raise ValueError('provide at least 2 predicates') self.predicates = predicates def description(self, ctx: click.Context) -> str: return self.DESC_SEP.join( '(%s)' % p.description(ctx) if isinstance(p, _Operator) else p.description(ctx) for p in self.predicates ) def __repr__(self) -> str: return make_repr(self, *self.predicates) class _And(_Operator): """Logical AND of two or more predicates.""" DESC_SEP = ' and ' def negated_description(self, ctx: click.Context) -> str: return ' or '.join( '(%s)' % p.neg_desc(ctx) if isinstance(p, _Operator) else p.neg_desc(ctx) for p in self.predicates ) def __call__(self, ctx: click.Context) -> bool: return all(p(ctx) for p in self.predicates) def __and__(self, other: 'Predicate') -> Predicate: if isinstance(other, _And): return _And(*self.predicates, *other.predicates) return _And(*self.predicates, other) class _Or(_Operator): """Logical OR of two or more predicates.""" DESC_SEP = ' or ' def negated_description(self, ctx: click.Context) -> str: return ' and '.join( '(%s)' % p.neg_desc(ctx) if isinstance(p, _Operator) else p.neg_desc(ctx) for p in self.predicates ) def __call__(self, ctx: click.Context) -> bool: return any(p(ctx) for p in self.predicates) def __or__(self, other: 'Predicate') -> Predicate: if isinstance(other, _Or): return _Or(*self.predicates, *other.predicates) return _Or(*self.predicates, other) class IsSet(Predicate): """True if the parameter is set.""" def __init__(self, param_name: str): self.param_name = param_name def description(self, ctx: click.Context) -> str: return '%s is set' % param_label_by_name(ctx, self.param_name) def negated_description(self, ctx: click.Context) -> str: return '%s is not set' % param_label_by_name(ctx, self.param_name) def __call__(self, ctx: click.Context) -> bool: command = ensure_constraints_support(ctx.command) param = command.get_param_by_name(self.param_name) value = param_value_by_name(ctx, self.param_name) return param_value_is_set(param, value) def __and__(self, other: Predicate) -> Predicate: if isinstance(other, IsSet): return AllSet(self.param_name, other.param_name) return super().__and__(other) def __or__(self, other: Predicate) -> Predicate: if isinstance(other, IsSet): return AnySet(self.param_name, other.param_name) return super().__or__(other) class AllSet(Predicate): """True if all listed parameters are set. .. versionadded:: 0.8.0 """ def __init__(self, *param_names: str): if not param_names: raise ValueError('you must provide at least one param name') self.param_names = param_names def negated_description(self, ctx: click.Context) -> str: labels = get_param_labels(ctx, self.param_names) if len(labels) == 1: return f'{labels[0]} is not set' pronoun = 'both' if len(labels) == 2 else 'all' return f'{join_with_and(labels)} are not {pronoun} set' def description(self, ctx: click.Context) -> str: labels = get_param_labels(ctx, self.param_names) if len(labels) == 1: return f'{labels[0]} is set' pronoun = 'both' if len(labels) == 2 else 'all' return f'{join_with_and(labels)} are {pronoun} set' def __call__(self, ctx: click.Context) -> bool: command = ensure_constraints_support(ctx.command) params = command.get_params_by_name(self.param_names) return all(param_value_is_set(param, ctx.params[get_param_name(param)]) for param in params) def __and__(self, other: Predicate) -> Predicate: if isinstance(other, AllSet): return AllSet(*self.param_names, *other.param_names) return super().__and__(other) class AnySet(Predicate): """True if any of the listed parameters is set. .. versionadded:: 0.8.0 """ def __init__(self, *param_names: str): if not param_names: raise ValueError('you must provide at least one param name') self.param_names = param_names def negated_description(self, ctx: click.Context) -> str: labels = get_param_labels(ctx, self.param_names) if len(labels) == 1: return f'{labels[0]} is not set' if len(labels) == 2: return 'neither {} nor {} is set'.format(*labels) return f'none of {join_with_and(labels)} is set' def description(self, ctx: click.Context) -> str: labels = get_param_labels(ctx, self.param_names) if len(labels) == 1: return f'{labels[0]} is set' if len(labels) == 2: return 'either {} or {} is set'.format(*labels) return f'any of {join_with_and(labels)} is set' def __call__(self, ctx: click.Context) -> bool: command = ensure_constraints_support(ctx.command) params = command.get_params_by_name(self.param_names) return any(param_value_is_set(param, ctx.params[get_param_name(param)]) for param in params) def __or__(self, other: Predicate) -> Predicate: if isinstance(other, AnySet): return AnySet(*self.param_names, *other.param_names) return super().__or__(other) class Equal(Predicate): """True if the parameter value equals ``value``.""" def __init__(self, param_name: str, value: Any): self.param_name = param_name self.value = value def description(self, ctx: click.Context) -> str: param_label = param_label_by_name(ctx, self.param_name) return f'{param_label}="{self.value}"' def negated_description(self, ctx: click.Context) -> str: param_label = param_label_by_name(ctx, self.param_name) return f'{param_label}!="{self.value}"' def __call__(self, ctx: click.Context) -> bool: return param_value_by_name(ctx, self.param_name) == self.value # type: ignore cloup-3.0.5/cloup/constraints/exceptions.py000066400000000000000000000034301457133754100210770ustar00rootroot00000000000000from typing import Iterable, Sequence, TYPE_CHECKING import click from click import Context, Parameter from .common import join_param_labels if TYPE_CHECKING: from ._core import Constraint def default_constraint_error(params: Iterable[Parameter], desc: str) -> str: return ( 'the following constraint on parameters [%s] was not satisfied: %s' % (join_param_labels(params), desc) ) class ConstraintViolated(click.UsageError): def __init__( self, message: str, ctx: Context, constraint: 'Constraint', params: Sequence[click.Parameter] ): super().__init__(message, ctx=ctx) self.ctx = ctx self.constraint = constraint self.params = params @classmethod def default( cls, desc: str, ctx: Context, constraint: 'Constraint', params: Sequence[Parameter], ) -> 'ConstraintViolated': return ConstraintViolated( default_constraint_error(params, desc), ctx=ctx, constraint=constraint, params=params, ) class UnsatisfiableConstraint(Exception): """Raised if a constraint cannot be satisfied by a group of parameters independently from their values at runtime; e.g. ``mutually_exclusive`` cannot be satisfied if multiple of the parameters are required.""" def __init__( self, constraint: 'Constraint', params: Iterable[Parameter], reason: str ): self.constraint = constraint self.params = params self.reason = reason param_names = join_param_labels(params) message = (f"\nthe constraint {constraint}\n" f"defined on parameters [{param_names}]\n" f"cannot be satisfied because {reason}") super().__init__(message) cloup-3.0.5/cloup/formatting/000077500000000000000000000000001457133754100161475ustar00rootroot00000000000000cloup-3.0.5/cloup/formatting/__init__.py000066400000000000000000000003641457133754100202630ustar00rootroot00000000000000from ._formatter import ( HelpFormatter, HelpSection, ) from ._util import ( ensure_is_cloup_formatter, unstyled_len, ) __all__ = [ "HelpFormatter", "HelpSection", "ensure_is_cloup_formatter", "unstyled_len", ] cloup-3.0.5/cloup/formatting/_formatter.py000066400000000000000000000417741457133754100207000ustar00rootroot00000000000000import dataclasses as dc import inspect import shutil import textwrap from itertools import chain from typing import ( Any, Callable, Dict, Iterable, Iterator, Optional, Sequence, TYPE_CHECKING, Tuple, Union, ) from cloup._util import click_version_ge_8_1 from cloup.formatting._util import unstyled_len if TYPE_CHECKING: from .sep import RowSepPolicy, SepGenerator import click from click.formatting import wrap_text from cloup._util import ( check_positive_int, identity, indent_lines, make_repr, pick_non_missing, ) from ..typing import MISSING, Possibly from cloup.styling import HelpTheme, IStyle Definition = Tuple[str, Union[str, Callable[[int], str]]] @dc.dataclass() class HelpSection: """A container for a help section data.""" heading: str """Help section title.""" definitions: Sequence[Definition] """Rows with 2 columns each. The 2nd element of each row can also be a function taking an integer (the available width for the 2nd column) and returning a string.""" help: Optional[str] = None """(Optional) long description of the section.""" constraint: Optional[str] = None """(Optional) option group constraint description.""" # noinspection PyMethodMayBeStatic class HelpFormatter(click.HelpFormatter): """ A custom help formatter. Features include: - more attributes for controlling the output of the formatter - a ``col1_width`` parameter in :meth:`write_dl` that allows Cloup to align multiple definition lists without resorting to hacks - a "linear layout" for definition lists that kicks in when the available terminal width is not enough for the standard 2-column layout (see argument ``col2_min_width``) - the first column width, when not explicitly given in ``write_dl`` is computed excluding the rows that exceed ``col1_max_width`` (called ``col_max`` in ``write_dl`` for compatibility with Click). .. versionchanged:: 0.9.0 the ``row_sep`` parameter now: - is set to ``None`` by default and ``row_sep=""`` corresponds to an empty line between rows - must not ends with ``\\n``; the formatter writes a newline just after it (when it's not ``None``), so a newline at the end is always enforced - accepts instances of :class:`~cloup.formatting.sep.SepGenerator` and :class:`~cloup.formatting.sep.RowSepPolicy`. .. versionadded:: 0.8.0 :param indent_increment: width of each indentation increment. :param width: content line width, excluding the newline character; by default it's initialized to ``min(terminal_width - 1, max_width)`` where ``max_width`` is another argument. :param max_width: maximum content line width (equivalent to ``Context.max_content_width``). Used to compute ``width`` when it is not provided, ignored otherwise. :param col1_max_width: the maximum width of the first column of a definition list; as in Click, if the text of a row exceeds this threshold, the 2nd column is printed on a new line. :param col2_min_width: the minimum width for the second column of a definition list; if the available space is less than this value, the formatter switches from the standard 2-column layout to the "linear layout" (that this decision is taken for each definition list). If you want to always use the linear layout, you can set this argument to a very high number (or ``math.inf``). If you never want it (not recommended), you can set this argument to zero. :param col_spacing: the number of spaces between the column boundaries of a definition list. :param row_sep: an "extra" separator to insert between the rows of a definition list (in addition to the normal newline between definitions). If you want an empty line between rows, pass ``row_sep=""``. Read :ref:`Row separators ` for more. :param theme: an :class:`~cloup.HelpTheme` instance specifying how to style the various elements of the help page. """ def __init__( self, indent_increment: int = 2, width: Optional[int] = None, max_width: Optional[int] = None, col1_max_width: int = 30, col2_min_width: int = 35, col_spacing: int = 2, row_sep: Union[None, str, 'SepGenerator', 'RowSepPolicy'] = None, theme: HelpTheme = HelpTheme(), ): check_positive_int(col1_max_width, 'col1_max_width') check_positive_int(col_spacing, 'col_spacing') if isinstance(row_sep, str) and row_sep.endswith('\n'): raise ValueError( "since v0.9, row_sep must not end with '\\n'. The formatter writes " "a '\\n' after it; no other newline is allowed.\n" "If you want an empty line between rows, set row_sep=''.") max_width = max_width or 80 # We subtract 1 to the terminal width to leave space for the new line character. # Otherwise, when we write a line that is long exactly terminal_size (without \n) # the \n is printed on a new terminal line, leading to a useless empty line. width = ( width or click.formatting.FORCED_WIDTH or min(max_width, shutil.get_terminal_size((80, 100)).columns - 1) ) super().__init__( width=width, max_width=max_width, indent_increment=indent_increment ) self.width: int = width self.col1_max_width = col1_max_width self.col2_min_width = col2_min_width self.col_spacing = col_spacing self.theme = theme self.row_sep = row_sep @staticmethod def settings( *, width: Possibly[Optional[int]] = MISSING, max_width: Possibly[Optional[int]] = MISSING, indent_increment: Possibly[int] = MISSING, col1_max_width: Possibly[int] = MISSING, col2_min_width: Possibly[int] = MISSING, col_spacing: Possibly[int] = MISSING, row_sep: Possibly[Union[None, str, 'SepGenerator', 'RowSepPolicy']] = MISSING, theme: Possibly[HelpTheme] = MISSING, ) -> Dict[str, Any]: """A utility method for creating a ``formatter_settings`` dictionary to pass as context settings or command attribute. This method exists for one only reason: it enables auto-complete for formatter options, thus improving the developer experience. Parameters are described in :class:`HelpFormatter`. """ return pick_non_missing(locals()) @property def available_width(self) -> int: return self.width - self.current_indent def write(self, *strings: str) -> None: self.buffer += strings def write_usage( self, prog: str, args: str = "", prefix: Optional[str] = None ) -> None: prefix = "Usage:" if prefix is None else prefix prefix = self.theme.heading(prefix) + " " prog = self.theme.invoked_command(prog) super().write_usage(prog, args, prefix) def write_aliases(self, aliases: Sequence[str]) -> None: self.write_heading("Aliases", newline=False) alias_list = ", ".join(self.theme.col1(alias) for alias in aliases) self.write(f" {alias_list}\n") def write_command_help_text(self, cmd: click.Command) -> None: help_text = cmd.help or "" if help_text and click_version_ge_8_1: help_text = inspect.cleandoc(help_text).partition("\f")[0] if cmd.deprecated: # Use the same label as Click: # https://github.com/pallets/click/blob/b0538df/src/click/core.py#L1331 help_text = "(Deprecated) " + help_text if help_text: self.write_paragraph() with self.indentation(): self.write_text(help_text, style=self.theme.command_help) def write_heading(self, heading: str, newline: bool = True) -> None: if self.current_indent: self.write(" " * self.current_indent) self.write(self.theme.heading(heading + ":")) if newline: self.write('\n') def write_many_sections( self, sections: Sequence[HelpSection], aligned: bool = True, ) -> None: if aligned: return self.write_aligned_sections(sections) for s in sections: self.write_section(s) def write_aligned_sections(self, sections: Sequence[HelpSection]) -> None: """Write multiple aligned definition lists.""" all_rows = chain.from_iterable(dl.definitions for dl in sections) col1_width = self.compute_col1_width(all_rows, self.col1_max_width) for s in sections: self.write_section(s, col1_width=col1_width) def write_section(self, s: HelpSection, col1_width: Optional[int] = None) -> None: theme = self.theme self.write("\n") self.write_heading(s.heading, newline=not s.constraint) if s.constraint: constraint_text = f'[{s.constraint}]' available_width = self.available_width - len(s.heading) - len(': ') if len(constraint_text) <= available_width: self.write(" ", theme.constraint(constraint_text), "\n") else: self.write("\n") with self.indentation(): self.write_text(constraint_text, theme.constraint) with self.indentation(): if s.help: self.write_text(s.help, theme.section_help) self.write_dl(s.definitions, col1_width=col1_width) def write_text(self, text: str, style: IStyle = identity) -> None: wrapped = wrap_text( text, self.width - self.current_indent, preserve_paragraphs=True) if style is identity: wrapped_text = textwrap.indent(wrapped, prefix=' ' * self.current_indent) else: styled_lines = map(style, wrapped.splitlines()) lines = indent_lines(styled_lines, width=self.current_indent) wrapped_text = "\n".join(lines) self.write(wrapped_text, "\n") def compute_col1_width(self, rows: Iterable[Definition], max_width: int) -> int: col1_lengths = (unstyled_len(r[0]) for r in rows) lengths_under_limit = (length for length in col1_lengths if length <= max_width) return max(lengths_under_limit, default=0) def write_dl( self, rows: Sequence[Definition], col_max: Optional[int] = None, # default changed to None wrt parent class col_spacing: Optional[int] = None, # default changed to None wrt parent class col1_width: Optional[int] = None, ) -> None: """Write a definition list into the buffer. This is how options and commands are usually formatted. If there's enough space, definition lists are rendered as a 2-column pseudo-table: if the first column text of a row doesn't fit in the provided/computed ``col1_width``, the 2nd column is printed on the following line. If the available space for the 2nd column is below ``self.col2_min_width``, the 2nd "column" is always printed below the 1st, indented with a minimum of 3 spaces (or one ``indent_increment`` if that's greater than 3). :param rows: a list of two item tuples for the terms and values. :param col_max: the maximum width for the 1st column of a definition list; this argument is here to not break compatibility with Click; if provided, it overrides the attribute ``self.col1_max_width``. :param col_spacing: number of spaces between the first and second column; this argument is here to not break compatibility with Click; if provided, it overrides ``self.col_spacing``. :param col1_width: the width to use for the first column; if not provided, it's computed as the length of the longest string under ``self.col1_max_width``; useful when you need to align multiple definition lists. """ # |<----------------------- width ------------------------>| # | |<---------- available_width ---------->| # | current_indent | col1_width | col_spacing | col2_width | col1_max_width = min( col_max or self.col1_max_width, self.available_width, ) col1_width = min( col1_width or self.compute_col1_width(rows, col1_max_width), col1_max_width, ) col_spacing = col_spacing or self.col_spacing col2_width = self.available_width - col1_width - col_spacing if col2_width < self.col2_min_width: self.write_linear_dl(rows) else: self.write_tabular_dl(rows, col1_width, col_spacing, col2_width) def _get_row_sep_for( self, text_rows: Sequence[Sequence[str]], col_widths: Sequence[int], col_spacing: int, ) -> Optional[str]: if self.row_sep is None or isinstance(self.row_sep, str): return self.row_sep from .sep import RowSepPolicy if isinstance(self.row_sep, RowSepPolicy): return self.row_sep(text_rows, col_widths, col_spacing) elif callable(self.row_sep): # RowSepPolicy is callable; keep this for last return self.row_sep(self.available_width) else: raise TypeError('row_sep') def write_tabular_dl( self, rows: Sequence[Definition], col1_width: int, col_spacing: int, col2_width: int, ) -> None: """Format a definition list as a 2-column "pseudo-table". If the first column of a row exceeds ``col1_width``, the 2nd column is written on the subsequent line. This is the standard way of formatting definition lists and it's the default if there's enough space.""" col1_plus_spacing = col1_width + col_spacing col2_indentation = " " * ( self.current_indent + max(self.indent_increment, col1_plus_spacing) ) indentation = " " * self.current_indent # Note: iter_defs() resolves eventual callables in row[1] text_rows = list(iter_defs(rows, col2_width)) row_sep = self._get_row_sep_for(text_rows, (col1_width, col2_width), col_spacing) col1_styler, col2_styler = self.theme.col1, self.theme.col2 def write_row(row: Tuple[str, str]) -> None: first, second = row self.write(indentation, col1_styler(first)) if not second: self.write("\n") else: first_display_length = unstyled_len(first) if first_display_length <= col1_width: spaces_to_col2 = col1_plus_spacing - first_display_length self.write(" " * spaces_to_col2) else: self.write("\n", col2_indentation) if len(second) <= col2_width: self.write(col2_styler(second), "\n") else: wrapped_text = wrap_text(second, col2_width, preserve_paragraphs=True) lines = [col2_styler(line) for line in wrapped_text.splitlines()] self.write(lines[0], "\n") for line in lines[1:]: self.write(col2_indentation, line, "\n") write_row(text_rows[0]) for row in text_rows[1:]: if row_sep is not None: self.write(indentation, row_sep, "\n") write_row(row) def write_linear_dl(self, dl: Sequence[Definition]) -> None: """Format a definition list as a "linear list". This is the default when the available width for the definitions (2nd column) is below ``self.col2_min_width``.""" help_extra_indent = max(3, self.indent_increment) help_total_indent = self.current_indent + help_extra_indent help_max_width = self.width - help_total_indent current_indentation = " " * self.current_indent col1_styler = self.theme.col1 col2_styler = self.theme.col2 for names, help in iter_defs(dl, help_max_width): self.write(current_indentation + col1_styler(names) + '\n') if help: self.current_indent += help_extra_indent self.write_text(help, col2_styler) self.current_indent -= help_extra_indent self.write("\n") self.buffer.pop() # pop last newline def write_epilog(self, epilog: str) -> None: self.write_text(epilog, self.theme.epilog) def __repr__(self) -> str: return make_repr( self, width=self.width, indent_increment=self.indent_increment, col1_max_width=self.col1_max_width, col_spacing=self.col_spacing ) def iter_defs(rows: Iterable[Definition], col2_width: int) -> Iterator[Tuple[str, str]]: for row in rows: if len(row) == 1: yield row[0], '' elif len(row) == 2: second = row[1](col2_width) if callable(row[1]) else row[1] yield row[0], second else: raise ValueError(f'invalid row length: {len(row)}') cloup-3.0.5/cloup/formatting/_util.py000066400000000000000000000014411457133754100176350ustar00rootroot00000000000000from typing import TYPE_CHECKING import click if TYPE_CHECKING: import cloup FORMATTER_TYPE_ERROR = """ since Cloup v0.8, this class relies on `cloup.HelpFormatter` to align help sections. So, you need to make sure your command class uses `cloup.HelpFormatter` as formatter class. If you have your own custom `HelpFormatter`, know that `cloup.HelpFormatter` is more easily customizable then Click's one, so consider extending it instead of extending `click.HelpFormatter`. """ def ensure_is_cloup_formatter(formatter: click.HelpFormatter) -> 'cloup.HelpFormatter': from cloup import HelpFormatter if isinstance(formatter, HelpFormatter): return formatter raise TypeError(FORMATTER_TYPE_ERROR) def unstyled_len(string: str) -> int: return len(click.unstyle(string)) cloup-3.0.5/cloup/formatting/sep.py000066400000000000000000000201701457133754100173100ustar00rootroot00000000000000""" This module contains anything related to separators. Currently, it contains an implementation of "row sep policies", e.g. components that decide if and how to add an extra separator/spacing between the rows of a definition list (only for tabular layout). In the future, it may be expanded with something analogous for help sections. """ import abc import sys from itertools import zip_longest from typing import Optional, Sequence, Union if sys.version_info[:2] >= (3, 8): from typing import Protocol else: # pragma: no cover from typing_extensions import Protocol SepType = Union[str, 'SepGenerator'] class SepGenerator(Protocol): """Generate a separator given a width. When used as ``row_sep``, this ``width`` corresponds to ``HelpFormatter.available_width``, i.e. the line width excluding the current indentation width. Note: the length of the returned separator may differ from ``width``. """ def __call__(self, width: int) -> str: ... class RowSepPolicy(metaclass=abc.ABCMeta): """A callable that can be passed as ``row_sep`` to :class:`HelpFormatter` in order to decide *if* a definition list should get a row separator (in addition to ``\\n``) and *which* separator. In practice, the row separator should be the same for all definition lists that satisfy a given condition. That's why :class:`RowSepIf` exists, you probably want to use that. Nonetheless, this protocol exists mainly for one reason: it leaves open the door to policies that can decide a row separator for each individual row (feature I'm personally against to for now), without breaking changes. This would make possible to implement the old Click 7.2 behavior, which inserted an empty line only after options with a long help. Adding this feature would be possible without breaking changes, by extending the return type to ``Union[None, str, Sequence[str]]``. """ @abc.abstractmethod def __call__( # noqa E704 self, rows: Sequence[Sequence[str]], col_widths: Sequence[int], col_spacing: int, ) -> Optional[str]: """Decide which row separator to use (eventually none) in the given definition list.""" class RowSepCondition(Protocol): """Determines when a definition list should use a row separator.""" # Ignore error due to flake8 issue: "multiple statements on one line (def)" def __call__( # noqa E704 self, rows: Sequence[Sequence[str]], col_widths: Sequence[int], col_spacing: int, ) -> bool: """Return ``True`` if the input definition list should use a row separator (in addition to the usual ``\\n``).""" class RowSepIf(RowSepPolicy): """ Inserts a row separator between the rows of a definition list only if a condition is satisfied. This class implements the ``RowSepPolicy`` protocol and does two things: - enforces the use of a single row separator for all rows of a definition lists and for all definition lists; note that ``RowSepPolicy`` doesn't for implementation reasons but it's probably what you want; - allows you to implement different conditions (see type :data:`RowSepCondition`) without worrying about the generation part, which is always the same. :param condition: a :class:`RowSepCondition` that determines when to add the (extra) row separator. :param sep: either a string or a ``SepGenerator``, i.e. a function ``(width: int) -> str`` (e.g. :class:`Hline`). The empty string corresponds to an empty line separator. """ def __init__(self, condition: RowSepCondition, sep: Union[str, SepGenerator] = ''): if isinstance(sep, str) and sep.endswith('\n'): raise ValueError( "sep must not end with '\\n'. The formatter writes a '\\n' after it; " "no other newline is allowed.") self.condition = condition self.sep = sep def __call__( self, rows: Sequence[Sequence[str]], col_widths: Sequence[int], col_spacing: int ) -> Optional[str]: if self.condition(rows, col_widths, col_spacing): if callable(self.sep): total_width = get_total_width(col_widths, col_spacing) return self.sep(total_width) return self.sep return None # ========================================== # Conditions & related utils def get_total_width(col_widths: Sequence[int], col_spacing: int) -> int: """Return the total width of a definition list (or, more generally, a table). Useful when implementing a RowSepStrategy.""" return sum(col_widths) + col_spacing * (len(col_widths) - 1) def count_multiline_rows(rows: Sequence[Sequence[str]], col_widths: Sequence[int]) -> int: # Note: I'm using zip_longest on purpose so that a TypeError will be raised # if len(row) != len(col_widths). An explicit check is not worth it since # this should never happen. return sum( any(len(col_text) > col_width for col_text, col_width in zip_longest(row, col_widths)) for row in rows ) def multiline_rows_are_at_least( count_or_percentage: Union[int, float] ) -> RowSepCondition: """ Return a ``RowSepStrategy`` that returns a row separator between all rows of a definition list, only if the number of rows taking multiple lines is greater than or equal to a certain threshold. :param count_or_percentage: a threshold for multiline rows above which the returned strategy will insert a row separator. It can be either an absolute count (`int`) or a percentage relative to the total number of rows expressed as a `float` between 0 and 1 (0 and 1 excluded). """ if count_or_percentage <= 0: raise ValueError('count_or_percentage should be > 0') if isinstance(count_or_percentage, int): count_threshold = count_or_percentage def condition( rows: Sequence[Sequence[str]], col_widths: Sequence[int], col_spacing: int, ) -> bool: num_multiline = count_multiline_rows(rows, col_widths) return num_multiline >= count_threshold elif isinstance(count_or_percentage, float): percent_threshold = count_or_percentage if percent_threshold > 1.0: raise ValueError( "count_or_percentage must be either an integer or a float in the " f"interval ]0, 1[. You passed a float >= 1.0 ({percent_threshold}).") def condition( rows: Sequence[Sequence[str]], col_widths: Sequence[int], col_spacing: int, ) -> bool: num_multiline = count_multiline_rows(rows, col_widths) percent_multiline = num_multiline / len(rows) return percent_multiline >= percent_threshold else: raise TypeError('count_or_percentage must be an int or a float') return condition class Hline(SepGenerator): """Returns a function that generates an horizontal line of a given length. This class has different static members for different line styles like ``Hline.solid``, ``Hline.dashed``, ``Hline.densely_dashed`` and ``Hline.dotted``. :param pattern: a string (usually a single character) that is repeated to generate the line. """ # Workaround: PyCharm auto-completion doesn't work without these declarations solid: 'Hline' dashed: 'Hline' densely_dashed: 'Hline' dotted: 'Hline' def __init__(self, pattern: str): self.pattern = pattern def __call__(self, width: int) -> str: pattern = self.pattern if len(pattern) == 1: return pattern * width reps, rest = width // len(pattern), width % len(pattern) return pattern * reps + pattern[:rest] Hline.solid = Hline("─") """Return a line like ``────────``.""" Hline.dashed = Hline('-') """Return a line like ``--------``.""" Hline.densely_dashed = Hline('╌') """Return a line like ``╌╌╌╌╌╌╌╌``.""" Hline.dotted = Hline("┄") """Return a line like ``┄┄┄┄┄┄┄┄``.""" cloup-3.0.5/cloup/py.typed000066400000000000000000000000001457133754100154620ustar00rootroot00000000000000cloup-3.0.5/cloup/styling.py000066400000000000000000000160341457133754100160440ustar00rootroot00000000000000""" This module contains components that specifically address the styling and theming of the ``--help`` output. """ import dataclasses import dataclasses as dc from dataclasses import dataclass from typing import Any, Callable, Dict, Optional import click from cloup._util import FrozenSpace, click_version_tuple, delete_keys, identity from cloup.typing import MISSING, Possibly IStyle = Callable[[str], str] """A callable that takes a string and returns a styled version of it.""" @dataclass(frozen=True) class HelpTheme: """A collection of styles for several elements of the help page. A "style" is just a function or a callable that takes a string and returns a styled version of it. This means you can use your favorite styling/color library (like rich, colorful etc). Nonetheless, given that Click has some basic styling functionality built-in, Cloup provides the :class:`Style` class, which is a wrapper of the ``click.style`` function. :param invoked_command: Style of the invoked command name (in Usage). :param command_help: Style of the invoked command description (below Usage). :param heading: Style of help section headings. :param constraint: Style of an option group constraint description. :param section_help: Style of the help text of a section (the optional paragraph below the heading). :param col1: Style of the first column of a definition list (options and command names). :param col2: Style of the second column of a definition list (help text). :param epilog: Style of the epilog. :param alias: Style of subcommand aliases in a definition lists. :param alias_secondary: Style of separator and eventual parenthesis/brackets in subcommand alias lists. If not provided, the ``alias`` style will be used. """ invoked_command: IStyle = identity """Style of the invoked command name (in Usage).""" command_help: IStyle = identity """Style of the invoked command description (below Usage).""" heading: IStyle = identity """Style of help section headings.""" constraint: IStyle = identity """Style of an option group constraint description.""" section_help: IStyle = identity """Style of the help text of a section (the optional paragraph below the heading).""" col1: IStyle = identity """Style of the first column of a definition list (options and command names).""" col2: IStyle = identity """Style of the second column of a definition list (help text).""" alias: IStyle = identity """Style of subcommand aliases in a definition lists.""" alias_secondary: Optional[IStyle] = None """Style of separator and eventual parenthesis/brackets in subcommand alias lists. If not provided, the ``alias`` style will be used.""" epilog: IStyle = identity """Style of the epilog.""" def with_( self, invoked_command: Optional[IStyle] = None, command_help: Optional[IStyle] = None, heading: Optional[IStyle] = None, constraint: Optional[IStyle] = None, section_help: Optional[IStyle] = None, col1: Optional[IStyle] = None, col2: Optional[IStyle] = None, alias: Optional[IStyle] = None, alias_secondary: Possibly[Optional[IStyle]] = MISSING, epilog: Optional[IStyle] = None, ) -> 'HelpTheme': kwargs = {key: val for key, val in locals().items() if val is not None} if alias_secondary is MISSING: del kwargs["alias_secondary"] kwargs.pop('self') if kwargs: return dataclasses.replace(self, **kwargs) return self @staticmethod def dark() -> "HelpTheme": """A theme assuming a dark terminal background color.""" return HelpTheme( invoked_command=Style(fg='bright_yellow'), heading=Style(fg='bright_white', bold=True), constraint=Style(fg='magenta'), col1=Style(fg='bright_yellow'), alias=Style(fg='yellow'), alias_secondary=Style(fg='white'), ) @staticmethod def light() -> "HelpTheme": """A theme assuming a light terminal background color.""" return HelpTheme( invoked_command=Style(fg='yellow'), heading=Style(fg='bright_blue'), constraint=Style(fg='red'), col1=Style(fg='yellow'), ) @dc.dataclass(frozen=True) class Style: """Wraps :func:`click.style` for a better integration with :class:`HelpTheme`. Available colors are defined as static constants in :class:`Color`. Arguments are set to ``None`` by default. Passing ``False`` to boolean args or ``Color.reset`` as color causes a reset code to be inserted. With respect to :func:`click.style`, this class: - has an argument less, ``reset``, which is always ``True`` - add the ``text_transform``. .. warning:: The arguments ``overline``, ``italic`` and ``strikethrough`` are only supported in Click 8 and will be ignored if you are using Click 7. :param fg: foreground color :param bg: background color :param bold: :param dim: :param underline: :param overline: :param italic: :param blink: :param reverse: :param strikethrough: :param text_transform: a generic string transformation; useful to apply functions like ``str.upper`` .. versionadded:: 0.8.0 """ fg: Optional[str] = None bg: Optional[str] = None bold: Optional[bool] = None dim: Optional[bool] = None underline: Optional[bool] = None overline: Optional[bool] = None italic: Optional[bool] = None blink: Optional[bool] = None reverse: Optional[bool] = None strikethrough: Optional[bool] = None text_transform: Optional[IStyle] = None _style_kwargs: Optional[Dict[str, Any]] = dc.field(init=False, default=None) def __call__(self, text: str) -> str: if self._style_kwargs is None: kwargs = dc.asdict(self) delete_keys(kwargs, ['text_transform', '_style_kwargs']) if int(click_version_tuple[0]) < 8: # These arguments are not supported in Click < 8. Ignore them. delete_keys(kwargs, ['overline', 'italic', 'strikethrough']) object.__setattr__(self, '_style_kwargs', kwargs) else: kwargs = self._style_kwargs if self.text_transform: text = self.text_transform(text) return click.style(text, **kwargs) class Color(FrozenSpace): """Colors accepted by :class:`Style` and :func:`click.style`.""" black = "black" red = "red" green = "green" yellow = "yellow" blue = "blue" magenta = "magenta" cyan = "cyan" white = "white" reset = "reset" bright_black = "bright_black" bright_red = "bright_red" bright_green = "bright_green" bright_yellow = "bright_yellow" bright_blue = "bright_blue" bright_magenta = "bright_magenta" bright_cyan = "bright_cyan" bright_white = "bright_white" DEFAULT_THEME = HelpTheme() cloup-3.0.5/cloup/types.py000066400000000000000000000023001457133754100155060ustar00rootroot00000000000000""" Parameter types and "shortcuts" for creating commonly used types. """ import pathlib import click def path( *, path_type: type = pathlib.Path, exists: bool = False, file_okay: bool = True, dir_okay: bool = True, writable: bool = False, readable: bool = True, resolve_path: bool = False, allow_dash: bool = False, ) -> click.Path: """Shortcut for :class:`click.Path` with ``path_type=pathlib.Path``.""" return click.Path(**locals()) def dir_path( *, path_type: type = pathlib.Path, exists: bool = False, writable: bool = False, readable: bool = True, resolve_path: bool = False, allow_dash: bool = False, ) -> click.Path: """Shortcut for :class:`click.Path` with ``file_okay=False, path_type=pathlib.Path``.""" return click.Path(**locals(), file_okay=False) def file_path( *, path_type: type = pathlib.Path, exists: bool = False, writable: bool = False, readable: bool = True, resolve_path: bool = False, allow_dash: bool = False, ) -> click.Path: """Shortcut for :class:`click.Path` with ``dir_okay=False, path_type=pathlib.Path``.""" return click.Path(**locals(), dir_okay=False) cloup-3.0.5/cloup/typing.py000066400000000000000000000014411457133754100156610ustar00rootroot00000000000000__all__ = ['AnyCallable', 'MISSING', 'Possibly', 'Decorator', 'F'] from enum import Enum from typing import Any, Callable, TypeVar, Union # PEP-blessed solution for defining a Singleton type: # https://peps.python.org/pep-0614/#motivation class _Missing(Enum): flag = 'Missing' MISSING = _Missing.flag """Singleton that works as a sentinel for a missing value. Useful when None can't be used to play the role because it represents a valid non-null value.""" _T = TypeVar('_T') Possibly = Union[_Missing, _T] """Possibly[T] is like Optional[T] but uses MISSING for missing values.""" AnyCallable = Callable[..., Any] F = TypeVar('F', bound=AnyCallable) """Type variable for a Callable.""" Decorator = Callable[[AnyCallable], AnyCallable] """Type alias for a simple function decorator.""" cloup-3.0.5/cloup/warnings.py000066400000000000000000000007521457133754100162030ustar00rootroot00000000000000""" This module contains a boolean variable for each warning that may be raised by Cloup. To suppress a warning, set the corresponding variable to ``False``. This is an alternative to using ``warnings.filterwarnings`` that: - IMHO, offers a better developer experience since setting a boolean is easier and cleaner than writing a regex that matches a warning message - can be used by Cloup itself to skip the checks that (may) generate a warning. """ formatter_settings_conflict = True cloup-3.0.5/codecov.yml000066400000000000000000000004531457133754100150220ustar00rootroot00000000000000coverage: status: project: default: target: 95% # the required coverage value threshold: 1% # the leniency in hitting the target patch: default: target: 95% threshold: 1% comment: require_changes: true # comment only if coverage changed cloup-3.0.5/docs/000077500000000000000000000000001457133754100136035ustar00rootroot00000000000000cloup-3.0.5/docs/Makefile000066400000000000000000000011361457133754100152440ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx SPHINXPROJ = cloup 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) cloup-3.0.5/docs/_autoapi_templates/000077500000000000000000000000001457133754100174625ustar00rootroot00000000000000cloup-3.0.5/docs/_autoapi_templates/python/000077500000000000000000000000001457133754100210035ustar00rootroot00000000000000cloup-3.0.5/docs/_autoapi_templates/python/module.rst000066400000000000000000000054611457133754100230300ustar00rootroot00000000000000{% if not obj.display %} :orphan: {% endif %} :mod:`{{ obj.name }}` ======={{ "=" * obj.name|length }} .. py:module:: {{ obj.name }} {% if obj.docstring %} .. autoapi-nested-parse:: {{ obj.docstring|prepare_docstring|indent(3) }} {% endif %} {% block subpackages %} {% set visible_subpackages = obj.subpackages|selectattr("display")|list %} {% if visible_subpackages %} Subpackages ----------- .. toctree:: :titlesonly: :maxdepth: 2 {% for subpackage in visible_subpackages %} {{ subpackage.short_name }} <{{ subpackage.short_name }}/index.rst> {% endfor %} {% endif %} {% endblock %} {% block submodules %} {% set visible_submodules = obj.submodules|selectattr("display")|list %} {% if visible_submodules %} Submodules ---------- .. toctree:: :titlesonly: :maxdepth: 1 {% for submodule in visible_submodules %} {{ submodule.short_name }} <{{ submodule.short_name }}/index.rst> {% endfor %} {% endif %} {% endblock %} {% if obj.all is not none %} {% set visible_children = obj.children|selectattr("short_name", "in", obj.all)|list %} {% elif obj.type is equalto("package") %} {% set visible_children = obj.children|selectattr("display")|list %} {% else %} {% set visible_children = obj.children|selectattr("display")|rejectattr("imported")|list %} {% endif %} {% if visible_children %} {# if visible_children #} {% set visible_classes = visible_children|selectattr("type", "equalto", "class")|list %} {% set visible_functions = visible_children|selectattr("type", "equalto", "function")|list %} {% set visible_attributes = visible_children|selectattr("type", "equalto", "data")|list %} {% if "show-module-summary" in autoapi_options and (visible_classes or visible_functions or visible_attributes) %} {# if show-summaries #} {# === CLASSES SUMMARY === #} {% block classes scoped %} {% if visible_classes %} Classes ------- .. autosummary:: {% for klass in visible_classes %} ~{{ klass.id }} {% endfor %} {% endif %} {% endblock %} {# === FUNCTIONS SUMMARY === #} {% block functions scoped %} {% if visible_functions %} Functions --------- .. autosummary:: {% for function in visible_functions %} ~{{ function.id }} {% endfor %} {% endif %} {% endblock %} {# === ATTRIBUTES SUMMARY === #} {% block attributes scoped %} {% if visible_attributes %} Attributes ---------- .. autoapisummary:: {% for data in visible_attributes %} {{ data.id }} {% endfor %} {% endif %} {% endblock %} {% endif %} {# endif show-summaries #} {# === CONTENTS === #} {% block contents scoped %} Contents -------- {% for obj_item in visible_children %} {{ obj_item.rendered|indent(0) }} {% endfor %} {% endblock %} {% endif %} {# endif visible-children #} cloup-3.0.5/docs/_static/000077500000000000000000000000001457133754100152315ustar00rootroot00000000000000cloup-3.0.5/docs/_static/basic-example.png000066400000000000000000001602251457133754100204570ustar00rootroot00000000000000PNG  IHDR5aksRGBgAMA a pHYsttfx*IDATx^{Pgb.o\re׵[Zv}H@%!r$$.HBA KB` Y`$̈f$$9ogdsgsgl޵o 3=CsǧlMۗ߾7zIw|}y'{MdKfDDD40#){??O~ Zv~[w"Tpv${g73U;>KfNDDDsLT6y1sr0I*@]Hu[밁:3J~c "z, tnݶS6Ro}Yw8M3ݼ!Y0mҲ?}ð;'xhTnOC4$)|CrcjAەO+2z14γ١;mAUFn; 7wxfEv*w/)1ŊN0.dIUNDDDsC<] ~𛏟3_0SRmù>50lwv,'zC=ǧpkG4eRY`}Xakﻍ\v256s dVL \M63P!\Z5;nS1nzlòpC8a 4+)Zs%KR<6 (BccE{Sә ](.įچ&zbR~#*1xrnUbuZ751.p.qy 9{yK$);pg\ԗuOa'""!f\ ᇇ磙n hCGSP9!s4Y{ O=Jndn\;w2S7Vϻ} Zqj'/$i)Jfp͂]HK(B|!,cԸ8xa+z;pՐJqwɂQyToJ/Z܋k1X6?}-/{_1E蟿kD{ۻPo%~y>yK/g?_z>b/ŵSBx=S<(YS~Kן,=_ŠPm+qGl0rEcy{iqBؓ {h9/my~8>bݾ | uwm&D qY>ƹؿ4pӃ1rߦ T]x(rCup[=OeGvcC&4 yYP6p^ FEE !沘pIJ@q 5܎k}t >kgC{ʅv\ۉ5h3)%?k\\>u.u/9|Y)J*b|+ >AP]:\ >R G hc ơBx–~z^^ZMg~/?|?3-W!>R'"r]_ōsRfet1~TH:i]zSOyO~+] ۝/ˊz}±rB\"Ws2ߞokM fo7BxB1qWP3nVu_mo&Z7OZG.L7& #1l箞$˩sO`Ϡby:H,["_Nᱻx.bldeH V 8w:Csn)mSB$} ?a$8>h)|E R+~.ĵ x`N[ܙrYopb؞ǕazM b^{}?ShH~_Ϳ!tŀ;b~UzpI6~;y<:MSbXK]~,7x1eT?>,螺/6O˱Ybu.0/(ayM3|yK$$nZ<ѳ(G[N1Z_ױ{:LYEmo_7#m5!<ͅѣ M!(Z>;]9=܁꭫u C8ek1 _IFɱ|.n%hXy4514{z a3lR,ϻCW 6ں?^OW>.eY<>omz'rD7ba";{[e_G"$= 59L}.u]k1z "t~#B~ \7E] f|fjY$雰T]Fee]:u"1{oXu R/}ߕSZ4 O$3_CK7PQ]࿽Isly81G:f&;[[.E.?jnBQeXK;>V䀧[Ba'"",!< 7񻳻2\Rn4]sQX:oeK@Ej|QzBkrRNwΑCp}#װNҞC x 絧O/.B_V| qEge~b_?7u!dD?\E(w5[OW'K7|Ǐ?c@]zôʯSaZqJG 㽓!@4b2exC2=s:OP.#eT+y)TW'f zG^Mr{ u = DDD4;i(T Amt\s(Kᙔ [⻝m;yagS.Y}v*VN/r~rc`Hff[!\t8dP%] l?xw|AJ|,wLS-w!o>jsk|{w~?°/L;1اJsr/#u9!\{{ \E|~I@2&nX0+{A^ Z-h?_\!\JGuoet%%Će؁AϹn0(NDDDsYC2m{h)_ߪC#C#¥}<+{1'i TvP55bn~KCxt8~++>%pw5B-my\(3]׊_ &pHC8e ყ΅Β9ZxoTĹvXՎׄWu*oߴ9)wC%WaʼBxѽ&. }..,}>wu__ ES^F0G_{i|,38Z&/Bg~zs/#uiveD|}U/!-w9!QʱƸ||R&.$_ƸX}NDDDsY2\򅌸`?gXk;}!FJEvx/Фz{*QCg9C׀a0ߎyVЎټa\>\fFV9 bnqNxmMgP*,#Z2YFK8Ƒ.0# M|b:eD.PYgeTWSw|΅p?NUlqD>O+(O.sz?;G˘2޸3؅pi,j7O-&wti]—V 䋸u8''ONM+MuaGO=vQ?%-dž7LMgp#ŗ3:'ܘە'BxFuĽx-EXUyhh?SNC8e!C(\/t mJ&cG6jK+:XFe8'_) JYi)֡vc6 =Y5{>msy OAK\]]7-)mFCkKM|8> SR..zx W+y x<'5Eſ/GRSb?m•a) Ћv:ׇC9-?7)#/u?0'yQ<|{ҩH.%Z_}^~o_BMn,B`v$ |smV =ظF^Fv2ۘrv;q88-HRP1/;[N׈ˁQy^uYu?҂AP!iCGϷbn_},P%)E #J8Í˧QrIwx- c1 72#ŗ( )傄(;ӊ[Cs8^g3g eقQN ~&G\ C2;0-|QP?co>^}YRCp!㬧?ϥyI)/3_/'?}/ϯ跂>O_,W3X:yb|/׿| !]$uivWx O>o>cieQz n]'l~Gk6Bxaq-6Bd…tmIT盞؝>P7sA\`'"",>_eNzwn$"g$NDDDsق |REYxtObo !||kě檼=$! M>VSKx!FDpqKf DDD4-r1ׅċ0""""""} p""""""!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I=7U^XADqf|XF"x#"""=s"KR**xv1_ҔDž1{S~$ţkrc tǛMb3v5T -bhoQuWjd"3%񎈈Ȩ9S}}ևp6>~}Q.4}αhؙ;l(\]lr0Seֱ$cpL&"""2b>.7bL\uU 5NF1/Lфp"""!b!8lZ06s^:p"""@ ,bkU3awc.V!%N|aCrmj~qXSp: 7\QXvjc{id33wJَl7AJBɉwp_.㜀 Q 1*m;!y&%H 6setkOCN>>иs=*1&ϧtVĥm Gq4 R*<%[pwP Xg[-)kζY`;0>jEՓ(\ Femߌ^e-ՙHT[s>|]P[NSt*@7J9&FwB[mȨj;awO>d/JN}n'^n;mѼk._:tB_%l8^z9aKA>2 f,x@ 6ǰ]}y5 n7))uqu<lV RZA\6 )Y3/eyv>1y+U(7&亹v0# yuۻAGrnMˏG 'Hb\Mc: ЙV4-. rYx:ul6Xp2ڶoFd?w E1LMzp{V3r,nﳚ=d'w4M[&""" VXPar,ť`suF4\wP>EjK >coa]@Ś%S-+O\ gZNcG ux y+<'!k__CɊy9P.7*KMwruJ9+Iq˱ Cb؟⻌jvaarRԢԃ$$-1sԯ2$-Ȩ22UE+¼G3vKKFIju5viѶed3ڶoX%og=hSCPc~'B?x׼{6ev2Y>_Fc[DDDDZ@!܁=JR*ջC>G-n;dxMQ:;i,G$i=N rTgCˢ0L+7΢.^TF+YNc!<&ⲸKln3{>.`XyvO WbzPaӕ==@}uN޲.קfRNӽbO#e±ΪZߍo"\ R'j{UˎI78/mfm C:>躌D-ҹm˽5YSB-V"""H-;Uô½[Wυn77TuJ #5Ɲ wXNc!<&%2`|wtjs|19/E + Qhv8K]֣uT}9nA$ 3:hVѶ%k?#+!\{Znpչ(R _#m{5޹mt5ڃ%1+>if׮` FP`fP~:ٙW0b{07h `~‰h0{DpVd`c~!J+jeS,Y1?;&DxQ)Ah(03m:RqzWV$hV8Ѷ%0gpc'""a4z]ыhQ4%O{k: ʼG(]t;*=B{> ] m[i k>NDDDbOOCna! wo*-_=fQ @E,-6qdBd]{VuW7Gchۓ|}{k/(wt3B7v%BE76㜧sp"Ѷed3ڶ h-1;z/-st]rL`{cd-\ ap) =*6`{ɚB\w){~&V¿(V";(\x)V/ŷWmA2M9DepyV?콀L"^|9C-3: ~;xrrlxmv|'\1#'ޡDMުR^>PjXaNķOw``XvlBym[F7mKn/|'1_hl:8-{dCUޏ0qbԪSwRw_s|h}yxu&b (^R\:*n1dVw(.lŠ!r^@eb…rܰOɨ.F.lq9v Femߌ-#!dj{_c2dŐ-h,EtLxgdy<@DDDʣ7U*ҩţpԋss\HA۹rlJ#FQ)krjŧ桪=Ż| s'/vCa~;3!Ur]}Cpڇu'e Ҳ(=}8c̆^ynO K豎1!]t\Ķɠ햔 x`Cku(Lil^Femߌ-C!λ;q8%޸~r{ QoQ&Gwщ(s6 7QT(r DDkDDDD9p""NDDD{ DD!(‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!( Rq\.67ph;>-\;L"""C#@JƞĞdq(4iyܮv1PƋntuK-h-mAIj8%oטϱ@99lp""" CX!1; tǛ Uhp""" Ӵ!!w2й'`{ ΅ǣnw/3#[ 8rX^(Fl^9>[E #/Ck]̺J',1/#Ew!"2Kqp21n;p\=]7NVen&eI5N3!el1QBi=.0D O g\|1ӌ`ź-p"""TP|Y ~B[Y>$UTw`dD6܄ 񫷢 c̆8^*F fHQoĘL$?;0dw`|>n6E^joĽhy^{6k+I/;R31YNϺ FS%dY){O=p^7Dnjn9/)..Y0tn@$b!ww⭌_܇p);I}m<:y6vT{ |?9:w+5Vա]eu,y9qbPݾ-, ?2(9:]!YgrY]/ʼCp)GSxUgZ#EMC8E*!\}|*P/S Ln( }?eLp 6[Zr r.tW*&4yܰz{֩{$l:7?Bx rQ~<j=7htzwD]mKy mȍѣ蒔 e{Zp@*Xg.4mt9Ҁ >ӦlCˁ7?/I=mv 6 Ӛ&qz YyL!<^V<7(nZ DDDp#O({}4ZNkCeձ߻);3Bx,~aoeq3}7 g~{ YjH\#oq܍mM!|:rwR|NU}eE}Fְ $%lՙfe e"l>7t,(伪muc۪üf>!nwk~n?N'.į9r_G;iU |&NDDDyUFqaʬu>-5!2<]1rS|uû7[OCY}r ;FK8W[i;3CnxN-ND!»)!\F]ky92Q uo~:R|1EW?T}^H<P~8`69{W}j^1!\XvV}O[^s\ks"'" .'tx{Ŗn7/p"""Cx#]!\[ڶ~=kwoա 5u) r=<1<Bܪ]л'NiE5a)T9 ¥$Pnu})AkpGoUy-[BoEj2}!.#VnV_Kt>(2Qn7/p"""Cx#]!kl-<7O\s;,ek- [}bGQ*Q0+;Jrummњ G/btMt0Ws0_BV’ dpc-N) BHB z{#h:u*NDDD }^,31h =';|'\Y |\︄aI|^6P{%Qbpct?ݔ3Bzc_*>ވW]FNgjHQuX>qC14>>?;#W YM2*Rj#B!\J,AKȓòʶNDDD µ=XO͝%U_ŇOqdkBFJQ F<z6XN+[G<c" &,hxqQ}nSdﳆcnUҢS{*Uw<&. R\:J,q8a7sCb sBx:)ώTﺈ@>Z;JKʝԻkc3IZo XYWpd),^Fh*=¥lO>9u(vo ګ]rPP,1.4_ojo6Tnp{G.)n5r2 -s n(8K(,wƱ5IQ4.An‰(RC % Ps:00 I ܪ^[Mksh|GMl+6oՆSʖUN-. 6MNx(;`p vE2 n)g wkV]cE,-Xpq8SQ"8&k4]wt%mPƽqd|'HlMعB =R|*6u [t*-?/5F敐٤(O[5^oװ׎*JKZyB}WKض4?()#`{pu˫EC6oDnH%))kBr'Yz~߰Oy UNbyR{^ɿ :=MZ5WNxui;OIQo| I?%.+Dm)4 _Wz~ NVe}vNDDD …Ĭ#><δD]=~A"17F‘oҽCb$DInya\ݻHYFmPE}gEqO/i.4bqZtW;fI4\M>=яm"=9ouy¥8L)/JV<][5>kٕq{FqCo`^sSeT \ˑ (ݮ)¥(ާNXfG#ekVcEJ؂ʃj߭ǭ#^aWݚ\GϝhLZZs"@V?Ky:aAtDv[n%|_+؎m4Һ4NVd^7N,6m?2o5?H),qSy݁#߃ooiiMGJpStG0Q Bª-(ð;'0>}7[ptyT8!-m} hAmIv D弤t> ߀.Dڠ*B2\ǰ].;: #WRV ζY>^c…h-w¥)E 옐u9(}my 6[0*Iom أeVW'db{%47詴JqWLp-8 cJ! F%%fcw~+.q9 JNqKAQu\=xr 9=GSFOoT!}@VOSѐs;h)^nWJ32|x9҉XoYi7t(M:O>jEmAEVQ.#m'Դ2+lUv9:wॕjoe &`(ݯNCyCXnU/e9GcH.yC8! Dߤ.ޫv~v!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""07U^XADqc -"ZH=/i)Oo`8$閙$e~_<2u$|Wo? $I2FbOudz~ג6}[g_Dse\$)]nrИ|\s˿u߉@+ۏ$n P;DsҔ2J[}ߞ;c}De\$)קa}|rt_{mz26{i%͸os.v3 ,G rRfM+ Oǒy#طC3zBcaX咯/4޾FJ`%2CL=?"]>RٳX ANxS!1<8~Hv[?˯cfq[?N/A g5R~#ąJWRnGb!<:,;1?|vy\W7*CD3!\&} G a&C |_bJջbz'@ 7/!| CxtQcF06s^T2G C쒒J.ьYȆ]p.w~~Eÿx^|Zrњ.lnRy~M_syz>aS(U1X8%0h6m ]e9%8z pn'v =E|[#%e;/ׇs6XFE*댟wuqxN0IJKC^?=b2RѴAJSѭΣ2? ~$I;jGξΪǝa^FBxR>mfw`|Ԋ'Q6[b+6b+m;!y[OiB_W jK'ʪURMh5ay| iw͌Kd'#qCYqm+RCIuh^7Hme|uKt*́сn4WcߺESFSBtvˊZ&QOw#Ke%l?ү}x1~)Cw¥0<}>b| ?, fp)(/S(/Ÿ5;?@N gۉN[4Jp&aq: 0}O.兊xG@ 6ǰ]}?y5[))uq\š͊!!_82R"6)<4:ܗmߣ]N#4DJފw a\nL^ra 2)w]8p5؀cm FeM F7)Y3?e{[a@o}>o2;"mw&׃+س2p^F%Sm:NVG& e$ٷLX7HmfjmȮ<1 >M]=OEnnn{]u5o^Ke5|]=+kG_w4~FŊ!\Xw)|*O}s޴E­v|rMK*';mIR-b:]8ǣY4ֲk4ZmiVdaé~eG;BJ$%!k_0} %+#-CÀK^Q\޳B-.oޞsFEF ~hm'.7 (sX+],+0$۱?e!\dQ26bwWw{O輌I[n+}M(JR7*^)ζ7|\/܃>55懼xXi7[q('&UَaMr߿aq7Ⱦ׼ \ShFe4z mEnѝOHG7RFQ. ?Au1{JaJd!D^AQΆ-M* C26-c\6Nnl^Fۤ qz<5,y;s}yCkj0Anyd߰TT]?1ljXtQBxyfdVym\^~RPޡ~wi|vݢ;up#cO3!\Z 7cRN+<~_а Cmu!^7<[PsNz=(c!fҺٷ鶛\ovӚEw> gCrZpui ѣhrZaqo;/n\D?.HLCAa( '^K1`]F{s#NU`=Rc).qU[PW[^R5,K^Ͼӹa႑7N&}E: mں4=OlyE& mi3%e55Ȋio%np3aݦk#AvӚ>C4Rj9e e1?&" h=j8!\'ڎz|&OӧД޴.gM^wn&;r|If;\1µ˥'E=ǔ~!HG "cŰPڤ=rcBDz /6] ZCb'e 8ц>4N}+ WCxµVd`c~!J+jeS,Y1ބm))rq<-Qu)t2;ká.zuD_CN6'f%flEqq1rWC4[<uSBfdnrkxz½ԥ'z0>'Bl_a~XP~$gps{>|k;3!\OCna! wo*;ï(0,uqHQ>M^FMM oX3L '{";U7Q}x %:ura ^n{V{B7=C\p)̎]{_RҪ|GN q$i3yz,<'b;ymƷw=@V6}p˽ȎAF7Ⱦ۸ n8~ۊv½տx%, 9&0ׂB. ?O˨\w+xƧO\? kbn-akfhNp) r~bi5ACw Qpy蓱X~k~Wϫ֠K/"7;p=߂E|Cэ u>]M _.Ɗ34hq~ﵧ;L}uo{Uw= %ꅖ76=܄:wHNbP)mD6?Tr;(}wch4vZn (ni)24 ?;a ݾ9g|%$ůC,0הzBn߻?Z.F?Ki6S{evxhbخrF~XcRQcw6N+ S}'[֡wc@d\f9n8*ovcN`t^Fڤ`tlg1{ o~FBcM֟¸gmKn7 HҩuH7о=nqX'vnƂn]Fsnµ ե2Z!<17_g/Ə,ڏ|+gV!{8uyJ)ox:(58v>N C8Mk*DNP%"0Ѵ[LDDa'"""i1C8M!(6‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰ɛpv/,|}8DD᱄f -$l` IwH^Fz1.Izo I6F+#󻖴>"-B&Irߟ=|o]˜[ IQ|59 &)c7kjPSKu\1W3d&IrGsoQUZwx,9(c\o"ٷۺqr>2I!|WPDt1~'qX]wmŇq ?%u|Wb i{1eM$z]ƅLR}}ևp=>~}Q؜XJq>]4Lg6I`uY9n!<͚6?dW.p%.Go3f89Ss$BY|, '>$yz-OsyױN3~'OϠvUi)cBAۣqbe|k'3/p Ꮾr<_H&p97] Ⴔya+U.ߴPCx ) yCaӂM1?R77GC›ּ>XO>[1qdWӷ~8>"|b|gpKX䄒 %%5Z.EG?GciCIFgr7<9>|CxCx*V5{`v8obeR6;vP1tKRd-ܳc| cֻhځ_DȷN3,xpNKۨ[x⮼.n]I&I}iHk'G,3RF?v"H9w2uyT!o$i'b^(7YS0HOڇmZs$ &`kC샱yE&2Qob~ŵ͸7$o 8CjAmIdZY5[3ͷ0,}> ->`X2&;q8e6rc~n[MHDoGiEo+[.Snt:-4z[ȷ\<%[pwP>&8ǒ8+G C8)Rtol/`%.yq~?)c{e:>'_=cJpDR wQ^ P_?k|Yw| " u'`';ۉ'm-IpNj> k ۃ}HBE#cخ|߼­:˸Z.fŐ|{ οMu}|; M3<1s;p0mh#M;]B-ekm3|l2t]2:\p 6y{ByiMJOVw E&6-~w==+eX2&dum`]F"};̈́utVhf݆ӹߐnԥTvҊqپN;ʱDنqn `豄Qo{wؤWSHϟD@/z?Vkaǟ?F.'Ѿ) _{sb'_OG^E?3BSTg?| ݏ OcY݆ v]wPN%)1.Qجvekً5K]W>Q{S.Z=  ex y+ջڒ}MW54 .yFqy Ly{B́!/g1KoX7ait@RrlhÐ(gorfpǓݗF=؈e_ܵ=m2&ooys5$+IRdިz&߻Q AüMzyrYu]v5MGh;pzP~o`w:'&4?oBÐ y~}&6i|{ie~Chܦa;ޱ<3{Zzn;m;nGk2a8ҩޡ~Hc԰oH7Ⱦ۸CczvӚEw>o)[GxMOwз-XB͙. ~~~p뛱)7Ÿu瞴?uMߩ߾aSrD3xk74/c4w<liQ#@ހ6ô"}GRdUDq,:ln)G]c Ly[\u;nD/3!Xm'J]orޯsOԥ!<W,Z_ꣽ1 eMl<pJ߽C y> Cmu!Sp tzI[Y>Ge,LZ7#0vnZ3£^Χ,c05(3\6oUk}~űh;!2aFM4Y`#+!\M[h2B̈́uMkC [pH蒗QL/ؔbs.Xz}ۤuT}ܿNBO/c B#1'diifpi}›Cx#hi۫mdP^/Ɍ{^FBvH2ݏڜq7#CA C=rNY!\[zY=fpuu(œC oR*^dP-: =#{'W.0'GCxW?yӾCC!<\ Z +21h)w‚Y{foB׀A9FFP]o8=µ­hCA$ !G&4L,. e ftm$x(3ݴgtm:bӻ"XNiD: 5N'~WIB}5x>j!\tJwԡnܕ^:+ tn~z C컪=EJDU^^7L!}MףS;鎯ev UCw+ż.zuD_՞vCN6'f%flEqq1rW C4[<uSBfdnxxCx붐Cxd׽ C8ƒ;MSüAZs5eP~;ϟ1ωK<^C !<\ R|r Q{V~EaјG¸0܏wj`> pWyA畸Nk(ѩcHo82/ÒQ!.}8ǔvfGӮ=/)iUA|HOK6㜧sp"6i|{{4lnnnﭹ[_/шdt3o݌0ﺍ`ᷭhm!71A5i*r╰70X_ D C:j]}?;KO|݁a;۱ eMv;n7gy psC0n_O>r;шd|3k݌Sf/|oZ|'n8~ۊvrlK}I}j~5y&HHy oϪcWDk=t[3p|_ħr?g~ n)%8_}_oKhA{~ 4?gQ;mq7[Ul2o!-P˻Ƈ1izm2.7e7pe;Sͱ{'\0:/#mR0I[q׳ ۘSsX7?#F%6Y e-7#INe4"ٷL\7C]cr@}w 4\p2Tt붐Cxd8/Vja+mᐏ? W3ct\t2\_/pwu:dI+^B}y\98-MCoLŧn}r4qR~?8|b4!|IXi{Op?ye8ރCš9._\96MsҨ8r ‰h+ǧmCŹ69ǘ w;Q[ '1JsZQw&8[ޝ[!܄&Pb[h Xt2ʬKhtR)2RtǙk‰h! ¥,&C^;.ۗ 3hy}Y`YyC,Bɓ8qkԳ)Rn$%#; oEs.lz!NDD Y40޸3]^+:eVh O=o 9K瓘:GˉuwnV,rY!2chk'}g%I-7[! nǁ%PSu iIK.ƙ#{wp:+ɧ0IJE6t񨰫9No^bokE¥-8z G:b yݔk|։WªxWu{x~,ڋX~4-x!,z{߸ϱv[^&yj9z몚@FT}f|Kt ԇ:)o6wp""ZbsQԄ&VpCNߋ R@lV+h>׀=vy3o-Gvva*.6ܥ{ˍO`9;pY^n2юODu ;p\=e\Fxυ,SA׎&y޾| Cw }?e! - hld9+תG&$l:&<k. zR{NpʶQ.įե ߓQ w!1GGw)v{ mmVźU~3Z'mjqb&.YWrj+*Wcu)oU ik>R[J6ںqSG'vH RR&p8J9YƉޓxMs1% XO"GrZh.;L Fͧ4crr;Q.%wC[xv߆yR%dlyA{s2P,G'2@? '%G0"Tn/T=~0sPQ.ByaNgXx-[=kair62Ss zr{MS v*D9>-eLqd]B|qpܽxAWP>02gXĻ.[/Ž:N( Nߺ-eF7v2v{>O\(*M Xp) 7=;kqyw6X9oygԃ qFk} [u+}s2Nr=m: 9( -9&o|n:ΊiUar8lۭ>=WBxnra'"fNp%I.bydXc>i `ء>!|y~˿rEܹsG\|Bc(F?&.cv6/>3릿HR:sO6:^¹ 䥇o6D;98U|﷪Wdž s6^ wW涼dIYsoP ;`!,[qYر-o)F +melשM+n}(\'z‰hYP!<3MPf bV棶vk ZJ? PٺΤpÜx:dP.Ku"W_&Xqǯ|oEj2Cbp=]'z‰hY0!\\ɗ!<C½x$52\,)3 sqϠSwbYoa-,W,DHXBnʼn6%}ӂC!>n|= !ܬ:NDDM.m^ćp&p;b{wp;᫱,Ξgp]fn$qvQ 9ﻤ7~(g>p{Uyw:]E\] s.n GQQ&|lwꌆpDC87!Cdּ7"0ǽv\TUum`4h{G@f2p^w~6/}6hh$Bue`9neydeX0nvw2ݠInź*<EUR2kPuSf2^r,ťɂx3׷X 7yn'Q*? -)11-mKanHQoC@U|[ؽw٧BlܠK\Ga^fץ0Bʹ;z:Fq*ӿw VDF^텙 ։p""oB,ؽv\AZ7MCN; +*|oz~[;ym[pz[ٲ/^7M;lw›mrwE&J|[p׽VaۧXu wH,8׃N(EqNawz;UHƥmPƽqdN|'?NV]zo+'"e[hV~ʡ&ŷ~։$ejVאLBԖMÜ88ev] s/n7Gr=7MUؼ݂?zfm;sĥTlT(Rф-~wl: t= DD4߄ BbNnN]kǕ e<bq p!!0 =Zn} B!\H:AQBΒe0ZQm7i%JZt CadrmOG7ɢ^Fn+'Q-'+n1'Bꅸw*qQeˑ (ݮ!\JǾ}cU! ktDC^RO]ޣCxϯcSw-<.q:2vl߭yODS'+2/EK BgZ07u)If4G-Gh~'`a(tnB܊r4ɁT-YU5ީD/pJqh6~*׋ݪAL ,R>m>n*;uDo4RByT;DDpM…|T4gp 'cCw%H愗Umu3δVN82v6xqfp!aU_a؝b1pν 5,3Zl7)!nԃWB2jۉspv"Ha(BͺE4HrԢCvӡ|(}myG2Plsvk'6,pJ ؞w ͇pMZūRtmiI mmFCU옍B8\ }a6:"r Bp !pWB-a2}<>{VM%Նw/R}g'"y!E[UixGCxvG‰h`y!p""G C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&aHT{a~uǙmaivLqn`=kNpIJEE14K0Byx_qMcBxi^,cnTԠt#JE,BI]z͇6.)]cPw|37(UZwBgf2Ēl=7v6 ~vaob쎿f}q!"?HO>iX%_,LJѯ6jrN. si%͸os.v32J`۬) Q.C;Rf~?dW.gYh>+{.+m%O4U__#v%;n)o98߈1q2@j ;\L-D3?BcGۅq.]ݨX;N0 3^L !|[z7!|ǎTvQf9`vq!"?XO֪ft p \CJ~frQvP1,'[ounDS:2 f,gD"QnV\9C,o"oc|!%`ߩOCμ|-\@gߐnt:-4F-<%[pwv'!lAmqBl73CxR>mfw`|Ԋ'Q6[BHd4Wqm3n*jNQ]r=dǬ^e-ՙHT[6p>|]P4evmFELԇh s|8w 3}/2,;ccz'3ʺ^?ؽٱd'>QeS+M *v}&4NFn'^n;mѼ'hbP†u' 0}.eXN.Vl6a!xj* zvPgWs'CrS=kDBJފw cL.km3vjmȮ^oYhhv"nqiŸlԁӎ! lcNu=3!Bx\F:CNF.pM]/iFQ)Y3/J=v>tq)gdDnOSu\"m_f} ot{]> tQ1&:̇s(eLw˭#GxX}JDs VXPar+ť`suF;L}@ T[tB]\ƣY ֲkL4c/.> It-1=EEE(ܰ2`r!/`:D{s]FaGr;N^frTkwjlߙ`:2pla&Xoet?bg9vvaWMFCx0Ǝ% D:ppO@Z +21h)w‚͟YifwۛP0<5[RB ql8+ +}t鼸E wnCY8k6ۿ0@72j1s)Ǝ% D:p#'r-!ƾca˨hQ4%wo/xcu.e^ANB5 lyߋjت&lf?ǦMGn[Q\\UbqqN/>MZ 91㫱}g7rp"" %=qDgϫol#D4w0{uqBހU:Mz̢vvsX$9K9n>u?j%Һ׫QSS 3·S^^Hᶵ`ԥVm$uly/lt;VKjtII[;E!>=ꆺp (MG4lnn^7 3|.!܄sxs\oa;'*sXa'NBcI:{Dۿ2pLv4^ Rcc}-؛'@(2 ]\fVwG^,Ӝ̗)ą~r<Đ|ҳy7::!\aK|R5fmù>wkq"hbخAaγ&:NCV yh,e4m$up_[job=NX/^ v&#[֡wc@^zfT 7yj'\0&RVl7y;Xas*s`N2tqi}qM9ned?25pna6suQ¾ x`waBoLDEXpP$G8&oq0ӊT28zzB{tCBi+>Bye8ރCE9.zvbx2*eM\N+4݋ߩulz":p267no7))뮢: DŽwq&/|mo!N^Ck}t4ݺs&_[աHp 1Wm2}T̫D DriFeܗP7оl@-"O Lùk6sqQƟ}G&52 FSii&7K9oL+zΡ,7EիXѝqtY3gC8|5u2 ;3O<:^Ԣ5n/Mg.c!Z8‰bl$;3/>u(p>MFpIelDp߉>[1P|:=ul#D C8Q$I2"cVRiN;|6Bp0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0G!eW AoC4$o۽tM)l[ۀ(v $ T_o!:1Izo Ik̷Q6X|ߵM_V.ќ-EfTTtOCcNqa-օTc.I(⚜؅f55)݈:}v2rtۿPwH7(X;<̜\ɱdImf89S Ql1G!/7?G_g!A'>߉@bXOVVv1׋/,jEՓ(\ FiTD6fߐ'%ٓ}mhe*oDʦ 4j>2cTt"n(9]mEj6i.#;6ɾn)vNe90:Ѝ|[hyJn"ns60:hQt{6p9|C'?]Ro=|O31n!'=ۃ}HBE#cخ|߼­:˸Z.nb{ οMu}|; M3<1s;p0mh#M;]"-ekm3|l2t]2:\p 6y{ByiMJOVݯ('6)^~?UhtG/G6/$\.|Ϣ5#`ϻOgܯBCzHfpa݅X>~Ga{'f݆ =m$;0*ʻrm-ILwf,[^Y25ҌlL#7Χ\"{4N+8Vw%) YЯk(Y8ii\:oqx7*2B^cFh;un@Ҁ]'iXц!Qގ)S'/zuk{FeMv;}_)kBIV|Q JMw!|tt7OcU:{ԧK4mf+${ڤܶ*1Tw4=fֺٷruНwQwݖaÑkj9yFᷭh-\& x9cۛh1coV%}i y!y;îPHxa~2Y!܁=JR*;X>q>Vvq pl #qhEv2ZP;$-AQ]6{|y%o?г@2oꐯ3տv"I(gC&nu?NLSui~߄!u1.'uM76/m^иM sӹ>ü!S~wIE5 7DێGnѝOXl7!ټ3fۜ Қo} rI\׹۽O[; Z.yBȉMl7!mM48:CxLµ LJ_wDy>(.gM^wn&;r|If;@1µ˥' Y2ݏڜq7# Cx.& mi3X\µ'ucf_XH7ݴ&0C8CxL³T.8mZ/_ " Z +21h)w‚Y{B7Mkx\##x(O.7Zֿvb!<ĝ(ڤf9ŪY!<ں!܌uM!|nC8Q0OupyNx!\5t] կno@cd4kP]U"% */0z'%nę~CvGmvL_.GNg^Gti~^]:֊d90ZFۉo=p:+Ienއ3pLigv4 ރV;wO%Iqcy}@98эh4o{G77^='dw4";[7#:̻nv3;dm+u[!v" x9c[Kwkth&0A?op3s d^M˨\N\+r>s;bn-akfbMj!^,%k qA<|?-im,.A'Gq֮)VOϫۻ(_..-ǩ8E.=$@@`.!-1Fex a!zBH 4 iywڽoo&Y޽7YZIsc{;O335>tOW}N%/r׆3ek1=(V#Ix}Xia;0ZFQS㯮{7nD;sgP^H)TuIݧxA玅Q n6(cNEQӘzlovuĄpc2OooݪvsB %֌~<8p;-|ccHY.^ GX=K7%aKYۗM ~pyrt; Vh( 뒹4k̆<V;&|O%W)i /zc`i+[:zj,^!X$p1`-5B{B#LJRhzX#ťt3^lV gu:KGEHE}Ow6.+2&-PL1ɤ2\r8*ovǚN`t]FI&o‰>_ǵq[,h}FB>Y{ m[~3PW4:1Z؎x3lɲ]Ýpl-1ں<_[ic2Z!!! siTrf5ucwA߾pDRZ<ڂ;.|285;^%= NQZlíNev H+팕RZ=}CK q|6ҒiBRn8ǬmG 2ZI:_n>}RH[u}鐷O 5(IMhl~F>9LǛX_a@W Z{!k(- '3Qp]betMxE{l}XXubF6rbmH}h2󔲼!N&anSnft]fn#Q$SA2Ju*Nfuqߢp9 HFe6E2df]-ag'"""N403MAinb'}!l DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""07+}EY~gI ;vnhK\fl?Oifwy:sOpVkH o?9#߹ru_4H/wAT(v 0.NB] _=̵dv;Cܔppto']nz_ڿCs`7-njR!(!\Csٲ?}o>b~XpI_uݏ߹}nc.i-){8qA||<|Y +jϟfp{'y=_anx_BM}wnĦCMᜀ}N,@FEMN優P~4G[qjDŽ skh<֤_1#߶GwrQYtr9f D)U&빆#y)$) +{=2R']ZУ˷N`aR$I[jcq}UCK݅mNLZ{(LcuTxQՄr[r U%ku}[*rM݃eto0d%,#-';?c968KvN`F\MȌOXsl}pٴb9ʖ!)t*`* 2lԥ_boHw9 !ۛvcCP}]O?Z?>3Ÿ'G|󑐎+E_{<,'ʻԲN=6#?Jfp)}F-wDZWhx\w~;5Tuu1l/l^k7^poL/_l+'/nRAK=YvX10$>w;0R'R&^{L.籣m_v@0!<)reqgOڔ>)=ޤt6[׉N>'Cx1aDwi9l_.#%SEq4X؎x3l~2Дmue.-[6i`3<hoۍ!\qhrb@QH Vby]7/~=>[$BԩGQƟߋrCx> ~wfӆu|qXsikIFE|t(jR;g5S.މ;6$,Mºc6v]mIJCF (aJtEiI=rFѲ}YR~p-ĥ37\_3iËkaeX_ކa33;?l;3loc*l5k6.ۭ%ij{KKR6!\~+S-َ~57FDO^ny}R޷î쓽8dp?aq؏7fVmWD3-źv{5;6~ߊlOgDL`hoۍ!ǟ8~,݅5N!QJo{˦߻kCx!;RE{w0?q0ܙ!܉JRwL ōȘ-eE^^3Wl'|hAEN༒ywLK$lb]zȡgveݮ+_Qf>$=! 6q碴Sq7wOե!|#m+Ƥ8گ-1!غ;ѰY zPiA-`l; C%Ke@zæw|e,zW6#Ƕ:_ y;M@YzgvӚo;Fh76kBx`,7E`n~'_:'=[l?3#>+욦/\Gp\1I=wf h^yuM4X0ѳl@I 9-nWF_Tr hY 76{Bxx[EY.cЃ[ w]}4d0Cx#{1/y+[Pfpxk#QCxXBx5 b3ڋ%9 Y]z"^$Ji2*/wF?P Cx.EOji3D\µ'213%X"6>͗vc'J,p2-~ ?:xyxR8o0PpX_X=Uh%mAEmBv4bM+Ðx<##!7Z w'{ 'fx2Vfp36>͗vc'J, J;S{FZwkݹ%xթw<~"{йyaa4kPvW'"%{-x)Iq|@>m8t%ہ}Ykµ"տP`];ӾujBqL!|NnliMرc7 <i zGu&2pfkwךwnݢb>hoۍ!w5^(#>z+IS[c3u;\dpfi{GW_fG豇-]G>þx<}?oGCCx/nEaΝ[N<2-uq@P:tS/U- NO}ʸȎag]@NkŲFI`=p=:+IkP+Ùmg㰲9b*cĺ+'IϢcymHyx=[}x{&6{G/[s{,_ǰF S㯮{7D;sr8GJĦK>mo :w,gbHw Eӵw*mcl'&[}x{Vm -Afa 771|>;87fVٌSeՊK5MxF'߷-۝h~>x7Z}L`}Nw>Dbt-ǟTY/pvӏ0ACN{﨏? xYOwy8肔Zɷ/_/})]ri"wu~|5sl| )„Q GX=K7/G&օk;pHywv.FPx%siX}'+ט 7yvL>ssK[R_V80lV:tX'FmCIcZj6 $%? s7,G1,Ѹ-.ViRZj^ c6+3:棢[ >';^Ecv{&zdRN.9m7c;cM{'\0.#`x7Dq ۸K0^˂g$-Ǻ0a%7uHөuX7f؞,E= NvlqxSNq[ qb=ho0>4~;-O_O^\SBҊGq~?p>NwbÑ+L^3;K!%x{>dYW$y; _aBx,ᖌ0#'/¤4B [7!晏[?n./(wO8 ~, JQދš5!_  Qə8ԍ]Ikh zp X.!/9]p bOkt*ӽSx>@Zng|>r]]p'gHw9fEo[=J7dD{$冋賎)Q'QއyjPԄvgt]쓓qތay}.v t讵ۆBߒ}2%/^FTQWvǛe؞*['gd`+9 /݆Dm(g.9O).۝kX~=|7pzG'0!)q7"""" 4.T'v#} QFDDDDS[Lۍhag'"""v#}‰077݈fm'""""""2C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0.)}-WAl_;&m:hs܆kM*C$.#IIQJ I zq-XkC 6)m!x(DDDDDDiCxj^9:Gpu h>T0-6Zˈ , EmLDDDDDD¥\Ty&Cz;N⍖t(_Z,CmO'"""""ċ_Ǡ/,M q;Z0> iAwJo?p"""""%bOpBkӤb B¥ԝhx&kC`zv81aƃx.#)`2c_Yt06nE_G#?Aͷ]LǾ;tj]yj.[dG6jRINFnr( m I(_\= )[-IXu4_۸nX|>lL]N` F].8ؘ;LMU$}!ZurJTo8{`@!4*,@H J] :G\_mhիx.MS!]Qe۱0^^fYW%"""""FbCx767;֩; ?PfZ^BS}N_ ڎlڣp{yEpGSQπnĠˎ'QW$oS\lz#%=ͻhn kޭo*  ,N.47ԡ<92@ҰqQ=s<|Q6P^ uXp>l/e:nw7!U'\Ko0m8;3xa?&auE8|_ ~o= {hۛ$t) inH\) 7|T!/ej;ֻ ,[@4ey;B4 .y6G )[ _5Oڂϕi)kQr7$[Y~I$Gw=1Љ<2;N8m>\Zf#Z8p9)Go~OkÜ6ΡX霰zP&\0-Vzo#[:?%r#O/pQX[,e4WO/ 7l3+Cxp$9(T<Ʈq[?awoc@i@AyW^U4 |$:VF1rDu3S-IY(tLvft?rIhgzDDDDDDtGHb k·Ump^5RJDDDDDd;&w^ xE6+GQ5-%X:mJDDDDDdD8M wŴ- X.3C8 \6!<^g )CdOnt^!^8PQf?L;Dg9h1%箨uߊL #գK,ZцD-^Yt ͊CK[CW}˹[𒦧y֠т 눈fR#}Sc@_oZuj/xR\9ԉfե5¥l.tqԞ:ˈb֒41'=4ciOYxӌn)7rqƒERnA{L+~r ([,흺<7;q|vqzktT02ߥ'L1 y4:Z^'O; -}Ma=#p^LHُSAPutQH23 FQ['*.?o{m 7Σ*/`-?dmx=^j7-1^sp"""""2ʹ!\H*DyCzFpKͨ*YiLZn NYpN Br(=ފa;]p9ml‘ʛ…XQ['".Pӝ;eG* 2<6' -! ͗,v(}x\NeK-2$iO)){,uv:L* CՁ>E[LDDDDDDsC,NDDDDDtga‰, DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDdp"""""""0!$ DD:7+}EY~W9Nc-!"") 3W޻

QW4- (֚ƒ1ENDDCǾw~11&.|kO y \ֽ#ez,!h>̷>7C&IZq= {Y{^S ǰMW;+z]ݤ '^yqf9m."urh2pgwv=~Ϥհ!"grXIR`IawŻp_cqklgvCxʺ#F|auCrl݅a)ZfFlp㵢jGB;K+@e׾Nڬa^elF#)kZlnu\ [4}"LjngZI٥\YNn1yuz֢6|7Z'F)[|4=\$HO`WŅuS'dIҵ?=94I>6>̧FX>mmLzE-#_݋ hS)[\ ;PiմSVX{Pno幊kGܩ\_3\$;-uxq: 0,scwr4\?}|%-t eN-i/r]h* cV'$)=C衹ILּOͻ${|߱At,`f%xe7+qT )O[^M=i_gx +Զ4jĀx.dy`9h}HbyQe9kk)ɘƜ]^sΨ9- ߗWaKM߈i!halFHuҁkĽO=JK P77ϒr+n3'.g+ ^N+ԏly'=&I/We,ϓ3t~n%`z,N-x^N];Ѷ78dd`@M{CksS2P).xsw/boe(jAuQy>THBxlu,>:prK|iYkl_bo~t2\W][]$ؠ." 7;in9f>~agvc?dyէ6ڷ,hڳW^㰡 h_NZB:1xٌW["IbBx|IımGb ++ǺJET[1\4kBxP^,7pAgP\gR#?| ~(*B ]wï)5U7>r*x=;w. .C+ \J*BCm6T)Y?7ezq&x)wA`k|nK XNE“GR3xawl.݇rN<_bcGv(-M+WmnCS}"RR1΅ٗmQ d&-;:IH%i=xR݇#<!³r2Y&~Ԁ5!<COw߻hwi-t v}4wI+n[)Nw`ȡ޹rX^өE~O>Rv༸C΅hn:TZU^i{e\h.N8t*v`:y{|wA 5c`\/)Šz<A{SUdK>2)`83仴SjhnoHٌևxzq mǺ/ qԉ6Cw"(KD=J8gGg7Dn6C8E|HC8%Zqz@}DҁՓ¥5)/'IUtbĝshdڑb1Cx8 b|3ڋ%9 i;.=zAHٌ-X>Kt`6Bz"p) [ƕTEC8E FBg@7C8EG|MwG "o{u/z['"h< ]6v-MLlb w4b.H٢]&iq`T婁FGOPDpP 0$gdcw9<‰h.b% Ӄ\bF ;wݨA|ݻ* *H7N$aD^~?lc|mw5(_l%\ ZC];|ӭ/w-2w6vB1lPꄬ6<cp}; 7VRz=q[>]qv}_‰h.b%O~7z_H(&BmQkW?=2S8Wtҳ'ԡk^S='0v%:esDK<2_pn7cł!<>5N(Uö쩗Sn\O<~ O;N÷@V8g9NFJYJ0vz^<^tx}>;Q4c႔] :}x7>d,yE.I_q>~ú]m}g2~&>Sr$hnIuN &9rpxW2)-݌~b\unGо_3']5钴?ʟ`]I)i /zcCͧl:e^%=;#:~4vVk̆<V;&|O%׉LK/@ECuUc.㎥fpbFfd))Váv+lcp˸pV/ ikw7.8k?9rZ}=8 F$VF8֔w#(By ˑ\P=mAcq*[ƒ ""Cx!\2`|7~h{DŽ#IZ(NǻǗ0z~W?/:{qsyl/v!ZM_>{aYcO9t߰5nCxK$5\\wbruq7f_o߇`ə8ԍŽb8QEZwVFҶ'1d*(E}{/n qMȡmeؘGW y=r{p ߀En[ö)m-vלGuNsq 6gL>n_G[/ ႑:1H=01fB sR1_,(~/rʻ`-6 [@;c;ӧsE[58 >NDDqچ@›L ׁ{{W'n7YlDD^rKhVR9>1EgIQCV:G_AvЫDDDD C,ND=m ^WqO%~"""x0!$ DDDDDDD&a'""""""2 C8I‰LNDDDDDDP@&^cIENDB`cloup-3.0.5/docs/_static/logo-dark-mode.svg000066400000000000000000000113231457133754100205530ustar00rootroot00000000000000 cloup-3.0.5/docs/_static/logo-on-white.svg000066400000000000000000000115401457133754100204430ustar00rootroot00000000000000 cloup-3.0.5/docs/_static/logo.svg000066400000000000000000000113251457133754100167140ustar00rootroot00000000000000 cloup-3.0.5/docs/_static/styles/000077500000000000000000000000001457133754100165545ustar00rootroot00000000000000cloup-3.0.5/docs/_static/styles/extensions-overrides.css000066400000000000000000000012151457133754100234640ustar00rootroot00000000000000/* copybutton */ button.copybtn { cursor: pointer; top: .4em; right: .25em; width: 1.5em; height: 1.5em; } .o-tooltip--left::after { font-size: .9em; padding: .4em .8em; border-radius: 4px; } /* sphinx-panels tabs */ .tabbed-content { padding-top: 0 !important; padding-bottom: 0 !important; box-shadow:0 -2px var(--tabs-color-overline),0 1px var(--tabs-color-underline); } .tabbed-set > label { margin-bottom: 0 !important; padding: .4em 1.25em !important; } .tabbed-label:hover { background-color: rgba(200, 200, 200, 0.1); border-bottom: 2px solid var(--tabs-color-overline); } cloup-3.0.5/docs/_static/styles/theme-overrides.css000066400000000000000000000126171457133754100223770ustar00rootroot00000000000000/* Furo theme CSS variables: https://github.com/pradyunsg/furo/blob/main/src/furo/assets/styles/variables/_index.scss */ :root { --color-primary: #0094ff; /* custom variable */ --color-primary--dark: #0080dd; /* custom variable */ --color-primary--light: #6cc1ff; /* custom variable */ --color-brand-primary: var(--color-primary--dark); --color-brand-content: var(--color-primary--dark); --admonition-font-size: var(--font-size--normal); --sidebar-item-font-size: 95%; --toc-font-size: .87rem; --toc-font-size--level1: .95rem; /* custom variable */ --toc-title-font-size: var(--sidebar-caption-font-size); --color-toc-item-text: var(--color-sidebar-caption-text); --color-toc-title-text: var(--color-sidebar-caption-text); --toc-item-spacing-horizontal: 1em; --toc-item-spacing-vertical: 0.3em; --toc-item-spacing-vertical--level1: 0.7em; --color-toc-item-text: #696969; --color-toc-item-text--level1: var(--color-brand-content); /* custom variable */ --code-font-size: 90%; --inline-code-font-size: 93%; --color-inline-code-foreground: #005fa1; --admonition-font-size: 95%; --admonition-title-font-size: 100%; } @media (prefers-color-scheme: dark) { :root { --color-brand-primary: var(--color-primary--light); --color-brand-content: var(--color-primary--light); --color-inline-code-foreground: #b2deff; --color-toc-item-text--level1: rgba(255, 255, 255, 95%); /* custom variable */ --color-toc-item-text: #bbb; --color-sidebar-caption-text: var(--color-brand-content); --color-sidebar-link-text--top-level: rgba(255, 255, 255, 95%); --color-sidebar-link-text: #ccc; } } /* Layout - give more space to right ToC drawer - increase .content padding to keep the right drawer on the right side */ .content { padding: 0 4rem; width: 46rem; } @media only screen and (max-width: 700px) { .content { padding: 0 1rem; } } .toc-drawer { flex: 1; max-width: 40ch; } .toc-sticky { padding-right: 1em; } /* Typography - increase font size and limits line width - increase paragraph and list items spacing - use a dark blue for code.literal */ h1, h2, h3 { color: var(--color-brand-primary); } h2, h3 { margin: 1.25em 0 .5em; } h1 + .section h2:first-of-type, h2 + .section h3:first-of-type { margin-top: .5em; } .content { /* blindly taken from Google web.dev website. May be non-sense. */ font-size: clamp(1em, 1.7578125vw, 1.125em); line-height: 1.6; } article { max-width: 72ch; line-height: 1.6; } .section > p { margin: 1.2em 0; } code.literal { font-size: var(--inline-code-font-size); padding: 0.15em 0.25em; color: var(--color-inline-code-foreground); background: var(--color-inline-code-background); } a code.literal { color: var(--color-brand-content); } .section > p + ul, .section > p + ol { margin-top: -0.25em; } ul > li, ol > li { margin: 0.75em 0; } ul > li li, ol > li li { margin: 0.25em 0; } .table-wrapper { padding: .2rem .2rem 0; } /* Admonitions - use a bigger font-size, not much different from main content - fix icon vertical alignment */ .admonition { margin: 1.5em auto; } .admonition p.admonition-title::before { /* adjust icon vertical centering */ top: 0.65em; } /* Version changed/added */ .versionchanged, .versionadded { margin: .5em 0; padding: .5em .8em; font-size: 90%; background: var(--color-admonition-title-background--note); border-radius: 5px; } .versionadded { background: var(--color-admonition-title-background--tip); } .versionmodified { font-weight: 600; } /* Right table of contents - replace title "Contents" with "On this page" and use the same style as sidebar labels - increase font-size and spacing, emphasize 1st level items */ .toc-drawer { padding-right: 0.2rem; } .toc-tree { line-height: 1.2; } .toc-tree > ul > li:first-child { margin-top: 0; } .toc-tree > ul > li > ul > li { /* 1st-level ToC items */ padding-top: var(--toc-item-spacing-vertical--level1); font-size: var(--toc-font-size--level1); } .toc-tree > ul > li > ul > li .reference { /* 1st-level anchors */ color: var(--color-toc-item-text--level1); } .toc-tree > ul > li > ul > li > ul li { /* Other levels ToC items */ padding-top: var(--toc-item-spacing-vertical); font-size: var(--toc-font-size); } .toc-tree > ul > li > ul > li > ul li .reference { /* Other levels ToC items */ color: var(--color-toc-item-text); } .toc-tree .reference:hover { text-decoration: underline; } .toc-tree .scroll-current .reference:hover { text-decoration: none; } table.docutils td { padding: .2em .3em; } .toc-title { display: none; } .toc-title-container::before { content: "On this page"; font-weight: bold; font-size: var(--toc-title-font-size); padding-left: var(--toc-spacing-horizontal); text-transform: uppercase; color: var(--color-toc-title-text); } /* Miscellaneous */ #user-guide .caption { display: none; } .sidebar-logo { margin: .5em auto 0; } .field-list.simple { margin-top: 1em; } #user-guide.section li.toctree-l1 { margin-top: .25em; } #user-guide.section li.toctree-l2 { font-size: 95%; margin-top: 0; } .highlight .hll { display: block; background-color: hsla(60, 100%, 50%, .3); } cloup-3.0.5/docs/_static/theme-elems.png000066400000000000000000001726071457133754100201610ustar00rootroot00000000000000PNG  IHDRv| pHYs  sRGBgAMA aIDATxx{1`<,7Ξd6IiӤ3Iӑ,ڦdA$){%{o^ge lI2,{t?C """"""""9$v;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(v;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(v;DDDDDDDD""""""""b`A9=4k?iЧƇp!WT(ûuA]Qug1 pč"Y5'3UuBDDDDDbQ-uT~~ """"""a|#Z|;7*5miEE&RЖQ<,Zv/q2um:۶^{""""""+u16},_n~y]]FS"ZP6]~Ц) u^ˋA/m}k(mZ!Z1YʷXG[ԲWCq,ڲvıˉAst8AjZc+k7mEZvc+i}wc%-'=$5-CDDDDDd2QgeQLo~Ԧe.4xlԴMJm~>[JCVD-D(l"N%F(Q#WȗȧJvu~.rk*m*AMQHv{:xiy [ےRxV^bD^{"Oj]ieE:"_zDDDDDDd#CC7˖`Y"n`VA$P(~h4#4jPB,gJC:mL _ Q%uDPJ i=q,r*c瘮#%"hBbg0 $:j_j"Բ+Ҏ }N=2$"""""j!# 6n*m+4Hmh[iigŎڪFe) ]_L.W"X 0 ]imb/̕(gu@\ʤI-_S(L/EU[歡 XJC/m@L!"""""3ءMP[Oj mB5FZCrDe:[9j+sx[CKZZjdD@DBL;5cEҀǗҚ6yuoZQ[ii[YNe-Q[ֈh]tSW [AJ:G[ms9ƈ;.>M %ho*ݢ,݃D Gk5ֱWXwv,fҶfıS: S`DDDDD08uڱ]DK85 mF,kzRlDH; vsΪd6NFl+ӲԠ‚6ovT*`E5ꬡ-7u`e=DDDDDDu1%fvN-h#4,K"b:JԼ׵|ك6 &"Z|DD IoXBgLmI <8'?I^ +u]#^2v%'khcZF5fbyjE:HXFbD9}(/ڴom!"""""jIV-;@ =icA֥fURo|aM#ˮ4/Vk'!"""""""rPc;ch~du) ug"ɫ5 9v3ۭep""""""""Ή;DDDDDDDD[# @nߜ~•DD}xDDw^\8x2Yuu5}39 ""`W,""""""""ZĿ#?Oi "I9rDDDDDDbA1CDDDDDDD!"""""""rP 9(v;DDDDDDDDMwNDDMčpij9.y$P3poq}|#ҽUȐẍ.Y~H x7Ew7 x]"(8n,E!$ĭkIpȨvDPUJPشxy]^{;_$E[/_Eۍ,˘( cx6% G%U3sm_,>yugNu8@98nkQxm\Oe7*Pjːy 6†<0½u5*ykU=Q?W'Ѧe"Lr:YXsX#ٸoq?Fl؊7Y3rW?'gF s?⚜i9 i LHꏈ@o ٩8}VHCϙC=G?jFL<,ȼ.{71s>$ffd,D5@D]9qQ.)C"bF`v ukSVa=eŨK.^ ǀ9oեQQ\m<A~jˑ_\ jV7cUnnǗ| ՕQ*o~ΜѪrty? P]Zr7e{'`RXFˎ?"l`Awz` Cu GmOXW8[YBiw9ul+1]; M%M ] YtEÜ[g+2$?kӎߗ X:4 x4~yM]/vppf7_Z -l7.H>_=݄_у]z`m7,5sDdܺt!J~e 8n 6*Ah Nl]I}1|jщlطT> 1)r8Ôس CPǐTƗ?+DDDDDpj#=fGx:C@IQ.؅ L_\u10c FF!N(-[vA֙AvEy7y0{B_{Wކxb9{<m'㯚 ]M9 sOqwd)cCTKUٯ W+>;m!or^ؕ9^xiZO>\P`=.zbPowjƽY|qw%rƔ=ZnӷQxr|4e߷7>&oN8U㿛P׆b<*kF.;+LJz7N)GtX1>` f+ÔwFABJyJL-GG:gd¦eAx+WY[m(;k(p qvEO.Lvj5ʏǃ!T*,O@Zx9텍K}-N:IΟݦA8_):~cJqBH*k>ǀIG/72MҒ{|_ }vlvVpG3a<05.ڛ;W/G$b&oc?;O=|47l#1a ~m%uJ|~0jz~Af 'Cz\= aщc,fTA1?)=~j8l{e}sݐOX503O;Н% ]Q2E↧< )|pA W!;2 Uj~ol@o4 7Cy3kod>~1#}l6bBxy? TOJz%.R(ȈR kr"n(Ը`<ִтfRxҎx`DԜ Śs1#,b~4ׯ/r/cٹGt8nGjDRLCd8<۲%11~k7W:z|(9?(y\ѹW=Gط5Ȇ<֥/7G.gz%:ʲQ9Aq<dۿb~n3E Q$xcl[O.:cÁcmjGDDDDZ]zNІ0{cɚ-ع{v؄~6`ؚVf~c˫; ^~?d;[J"C;Ft7l>k=xM|B[ۡw2F2ʣK‹_,ڈlH#`6٨GuYlXulWDN+ _"h+c7l"B7?djJLuտ3&_] @ p{@ǽS[q.nG<*canfn<_ACzGMJճWM3zD!߽h ֩sƺ`KMKͣ+> _k(%' ߉ri-׳,ϦZnYң8Q+)/l&$dm K/Ew6u ym(LjK5蔠Μ;FW }؝Ǥs2@Ro04岫\VVBN͆ޫ=AU/oxi4x5l'7M=2D֬^4M`CE0!^h=w "\IPl\δ$`ϛjphO@$9#XT~jb:}Pt]>/CRrW7։"XKq"#܀jGb^*\WfSSֺI\8~ZIO LE5q:xt6pRF-rp ^8qi"pfL2^:_pno֖f_yK4N:3DpuחrS7uX"0?bfs?_(85̈]{W@|R"*uXbΞ㔺>ɢ+y9 _}قl% tŇq jvsT/PZ\呝qEN8vFAܜ@W I͛LivhJCɐ f} jjx9U5 /7u|Si>+ô,38"po7Eg|R!45yҘ_۫q㛩Z ,V"I'aHOJەUxm) iGhy4 pGXdVq2(T|jfC6eǐz$R[U :^9Oskhd޻ u@$>!P/r}-Jzj6ވpd1hCe겱bJ?D' Ebb"&D#26t@$bρL̸z <^ߺ`TW I7q;^ۘ"""""[tNud6rQ|Q~ѽ3<!a=ogxo:gG9Abf_Cר7QRkې`fM .z1|7VW+M>#&V oT(øT;v/zy|t1F=xWG`e% ģ#±v(1Tz?F_?"is5.=`!p: =['.6>٠lNMM Vzzz5 X50w+W[bê"BIL޿ _Գkv5pua7JP֐ǐ$ɍی>֫B}À^ >S2LpazƏ^8iFJVo|i<]ejj., 9^ͯ+inTE{kC<<=ܝ2eگ\Sy#ڴ^[ʲY`oP}u󠎠M t~n /k` +՘]D=^ws2+0~ ٢vٷN}vncrj<}.Bm0}݆́7XqXGMwq"@>n`80St≇覛I>'qߢAiߜ'gy?8~}`%&CB0'f&J=yr, #I.8;Be#d]Q:W@$=qPME<%SX;4e?Ln;3xdFl-PVrr֐MG7kPd8gTc#>N&2{d R"z3T[O ?@3U+2c\\B٦vF\ Nݯlp]dpuƻ~__;nR8jnJk` 23o{O#0r$̹^Laܴ} eTy =17o=MGQkSjiT5( 1c0|`wK {2p~7~?~H=z$#qߚC ̘;`:^тiУcb0r}d6nXY}j07*Xbeឡ?վشuM]JFٞ7urnU?ar ̟4)DTl{ yxfyt'/bOi5Jz^'q[Y1!;Tᦿ0WOG7k"C5!a~xYB05Zg߾9szby}v/P|3x|eYW᮫c:>EDDDD6cH|5Pou9Qb~5&*/j[45\*iG}>7x# w@ZowbfA{iv@.^>eyΨ1܅ckXvGps}pG.FO|>n7V4I9*g?nJOls[} JKk5||FLCLn\ď7eҮVnEt?{dv=IZrԺzYټ%GۊѩIDDDD݂CLwn/uikK0uD A>*Q#6bݶfYvaA N}jʋ~ {6ԊfxWϝ1sSnUߏΘ7r%w|Lk 0qd P?8+QywǺfe78ĜP8ǕQb{zFV"[aMS+~}1`pJu_KT?GQk'":ծHU?Q߆J~T1F<+#"OSWJ=m8`>nZ R%=o4qnT *d /bJ{ d̺ εe>}6KC7!!&x#(U=|k${ 㩵Tyr^y,N!1=oV ?٤|ޜE̠N4yc'jބIǦ D[k:S[*#G'ǃKN/;ϯm4K'xKqǏ\ɮ;"px k{0L6uD3';4CD@xcacW:בh'2v3`k~rq-$W+{ Mv8pDDDD^qjfXv.2CD#,ZbPt'Gf`KM""""2.£[z_쉈>sJQv-v7QCDDDDDDD-v;DDDDDDDD[tl**JQ]] < """""";DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(v;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(v;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(v3RtDDD: ƃ[9(vbYˉZbe""";DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(v;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(gQpH!z3NȨrCxs^,AlXU׏gcI_QvC`LC6vkxYڮiK y7?;DDD&!""".e9/ {bdry Q␿!B3CDDd;ݐGL,Z8E#lJCWy$׻ǽ^yqȥU7%SZSr$o\ީ6.<]nuUkt֮Q#%jZeњ:ADDر3YU=QR+®wgG0->26\~-C䙈%̅cX&"]C1, GSQz5caFG-_L+kI[  nO.¤B_/5X~0}Ѩ|Cdwɮf?9# ScuqDx1Kv^{ƌ{_nlX lJn,C:b׿8[?CbëMxǐF/:ט~f PVefiE͂Hb $"']lܷ J~O}R}RVڲyxװ?iH5tڠ]AԲVsJe!""QQ\OK.sT[>@v%Iz9|'a4t5G$`pM-&DtkC\DۂAA2B8uA:^]c DsDc8?nW=migaAu7,;[Ҙ77E(A.nm\~ jĺme ؝i2̕=|kE>LRD eԧ:AQ~fi:Hu' ؙ$bvkr-xdemס/oݑ<]#䑈>^q ~.4f๢Ez._3%Jp@D67GE^7>uX4F!gx,z7D=oR"[1&7|Je}[;3Z[E֘7^c0oD$ G}9ߡz=X5u͚kpdyoJu"&.%>[;iN{ 0{@f8{V>BY̶k;rmТTv]Z[ K+[~WЮ#<FܴD 1`o@Qgb` |fMPwwp_ }z/}qMRUIrsx]:GME2>!JZWG#xۑokqQUVb5{rT_"-&܋ ,-E7"$cN 5w[.d;wd%ҒQSVroD qz8 [f݅gD#xS2xx[Wk6 q09z:cmcGSUM7vppŋmD͐:m9neD7n+άÇƉzuwDw0q G򡷱 6iFCZuåؕQ =1:uK2܄=N뛯7FMfmOXW8[o{ {FxKMSuEHzaYvy A#[pX#ʾELw>9qsFҬ@KmN|2e D5a^.֦emݲ|ni ^kH|M#\KbXrSB;pϼ571[ ]l9߄vtxiO"g!7%<MUI[TK՞ӟ7aӞQ7>s`s 6~MR\)6Wt='l獠z5Nnyjrv/W֋]E%B[[o$%)-˾7x TaaE)i%*ibןb*cd)Q!ap_ ]Hvpiȣ\3`CEٵ[Qt8ch!Pr,ﴕ"Kq9_DɶuweuXb)|Q1kXR-M[[ 0ɞ_-8rikZ-k7kV]t1cH+{7>[qe 8X^nآs `Y A|3ώa:յ:Z%UM\20v[MXҘekobL[b ItDD0%fHR>2zzyv/Tnx%7yk #$:J>67] Ő92iI"I8uB@`l`2 i&iqfçXd ݟg^ N|zzʾo>RGTSE㧶#qf&lڴKVPb,OKeǰd;yeqkӲnYqY[4IEIM!>~'U2|"=X}il<:泣aTX(v0:i9k[ث-Ō`[(nu}.k/bJ{Ն%""?v꒪Qe{XM]ïw9$OQ0x ?3 M`.ds(=Fm|0@h#fѧ0kn8's c1 4[8SvamI#L{= ]DeQW%᯸`x.B `=ӱj=8)pDpEal%w={gTm gݻZU4ČgF)QV.Q;`` V}Kn)\={€H hxO_q{W|vduV&k8J>%q^v7bw>[bǬŚU7I>]ߟWuf[P2"X-` <&\Y&ig`R);j(-1H\T{"|hkr-5-Egd'{ssi̗l)u*yw3=<f;_l^4DDD ;W0M"*<ջ"zD_p 7މ}O\C0(a]ut5ϱH&BuO W毳ԭ~Qo{b-""ԗQ؏6a7E !>TAW(l}-Ř1N.p?6m&ܶ%%W&߫b1,Ss빹q\6  ?%pQ{|s v|"P&bV#77;4XV7kViȧI.׷xpurG<3GUZc;fL-cCQ([ōxhZ" l|C527Яvh8`A/ԱvL5v.Jw>l(Qf)Mo~ 5<i\*jgfٙ.1Aru钳q9:vP.1CzW.¹gQ(7 A:+7` &O}=dc"h7+"3RiM- M{0< Gb{j l/DH?}x&-MCᏯvЫIz>W[λawvR-:><eooL M @=;Veeݲ|nu dZ"u5s7YvG{å(vv~,q#ޖ6%FWlEW-cG;QgWzϾwyn OKKPLfF|p1ua1;Q0]q1df`ڴ$Uvi8xPI#ނ i"|Mfa#R[Rhzl˒߆JԞAJtYbs@0O^+}ę22"ac.iYY:߬[=ފIQd D[ uY_WkB'o>#1cJ8=d}2NئǢ|Q;݀k:s%88GjZ]">_8ð;Gi(.__/$nP[G(wM>ڊ,ٛ_ۛ`+1˰e{XT>^JԵ[ĺtؼk"ӫbָ$ܠU 7'eeXuYz˰#1>Uz>V֓8񓝋-j8#& >ЕΘ3~v$VA7b4帕CG^l9IInkӲnYsY[eIGRԸ)VWbK66\uMIݙ %0j"|+DDD51x#(8-nO+M4+30cZ2G?R}-Jz|7o3NHr.6.1amq "U_G,3ǵr0Uv!U#}tMFx7Pkf{:*ͤ%UgYWaT|)yt-Cطi%֧ud> kzs>[YmUQ:}JބQ&">*J6||*ȑHܺ7L<ŨGRt񹭃QǑ8,z> wKÊ^B^YIDDD-%%W2CDDW Qwb`Aqd""$(<+,1""""-v;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(vsku!lCDDDDDDD!"""""""rPEͥ:[9(vbA³ """""""*,ؙ #\).Ճe:u-vD@'My Wj:lYQ;Ik<:(Hy>JiG:j,%ڏι_Jj>S*i\!""""""""dW,W@DDDDDDDD]hQsGGGcJm Q8+l~8NauӸ;E5iձvD0U>g`}Ib)[ZOE0y**JQ]́}$)))YcGtڴ3zG ,f3d="GoԙN """"""""jG[촆oG v>8@-vLWzg@z^"""""""" :Cd1+ܴ""""""""@;b:sur""""""""z,v8 3b\(5>_ ?cg""""""""˻wdP+2 Li|"DDDDDDDDYlHR&"""""""XEu5Ύ%ZZ6_""""""""@;nJ?""""""""B.;x2uM[kQf1DDDDDDDDԅ9zFV ]3"߁-lcgs&#ト:-v3gQ]Zł!:2!.7 >=}.^/[ly9 n {ә (6 n7񷆿{Ӷrp}u+tf}| aQK~zM 4e+SC{b|.q}q} bSͶ!ƭiQxkr3-KyY&\ xi@m)""r;bv+ɰ+HsDPǑ;R||O󦖮(<:~ As-㶘(AExOgͱJvp_cn\ה65n1XWLzn,4Tb_L[*1?,c)~Ѱ%&ʼ1dK9ѕ1nށA'㯚 ]M9 sOqw&A;P֚h\3"[] .؉UD {e3GţOD]!U@Ik6S~+ћ]21|`BݡB p AhGo@mIR8f>fjtg8%I}qc`nT<\_ Te߆`_Pp!);cΌf-P[O#rܾY7kebKz|K SY6/yNw~3>8_(cu/HWd/j,ƵB4V{{dSX9'ûsqOcI9#s7{I/܊zTK$!)7}JʼnؚNn)?'EmQjz/NoA|۞_٩ ¢\KPX|)gVcg^&yp򛇟@\Ď/bSI}Ӷu>]ڎ2}pЈ~u^dzlH1<1~X :eU9܈-gLJzAom`?cma>{4m~d}pۦ߀(_7)6$ڠNFYC !'-/ZL48ȂΏ/HO t*ARhxocÛvbB"8O[*yQҪ :|nP!=+Zl>pǔ_C*5h_ Fim?ްh"&;e\Ąahͺ[i(6SCkyD 'm{3EAF/|cr "+O; xࡅ?½!,:Cşlq`3aݰ] 6E?/-*ғe'L o>ȭE?T<";4_/~[/6HX[&N棿EgoE1$ajvޗfix9Oٯ$Wn\?j57-}|~5v)6oc~R8b oo RK:wx@P^Sfgsu5&:u{=0s=L#<~JsؘUif#|\ WU !ɘ8GcYzlJhV'֨P}7}٘ ~H1qcHCz@Fr !}|rn?DX9p.5A: J*= ~qU5(P Nps r:YYGQ _%#.l%$GpZLyۚ֙4*.TVC5]DPG}H>C+H/%slD,x`sX25E.D"!BdUQc`G\؇XEKMjD`v3eURUh%;G<3"ZCe͛׵iʩvZDh<*TUV>Dvu5>A))ѣ.#+!0eƿ5xh)5T rfɔe=ʳ+>=1q[q`}[x%#>w^@{OGYSX̛#^Zw6&:run܎S+7ccҠ@8yI>/֕,cƢyA38z*Unw&nunҬũQް/ZT"1dWkJ}?`sjÿR(&BeRfԩ=#P E!n'N+f~zpd ;;Qo&)d,N،W7jV`Eؔ^|ʁ3WVl¥=?)Csކy?A IR)y rmx|pûfj\e )_ڂ 5iC{\kDHk1*yl-C܅3 ö5`oNf([DB X5Rey#OHB되/Ҍf-IuH;%᪞2ep?!2@`^o%=ԩ/ߏ"DM9!}&fk bF}:?h,HCf!9p'679<>n' &=q^H8'c P?\(CQ#tǹQ=nEѶ1mŴ X{ԀX4#fVMW0D DyZ +/ih$h̫tE7vہ{1 uJ!0s܎z=qp7<Fu&Ϛnx^TPؾx$%o]{ع;b$͜EM;cZ`W+' S;`  Xw2F2W]J_O7LEkxȡp߷ݮ:Ͻm$ߋݒOI^^cHk!ҽ˱d͉Ʒݧ3sBĮW@|i^lmD&cDqx\]zV֋4J`']y]ҟ|" +>Īl7T?,iu*ȑnF}FH7-O$}?mU| 7 !SMrRnb~խ1-Ӷ-Y۱ `$$br{i#)n^;|$",r}N#S GWOoؠ|pd]ئuË X o%S"I8|#,b8ODZ7|c<&VDlxaWMe~ o4ؐ1#Fy=AXmHo%@%R>)GI ^~W AhSs:Bm|S&]$VIũR Apl.t Š_rũliԖǭ3ha-m|~iy[WGFUFZ-)ؕK mVDa_Ftdեvn/"0F釻m @HxO53]ιM"&Z ѕlW"/f@hD0GLIAੑuTNa RmjBF3Hl\ΞhWJ !=~.,1hS8[n%WΕMjn"-_'sS4um5Bl}AA[N!]sb!#HܝvbPṈ&9؆IPzŏŹ;PM`i@DLO]R-9psM,:/#Tgĩx NBJlnG(A6˳"ݰ  X|{Te߈-ol/.X,Y.Jqh!R YPX,Ag?|:S:HJ5t wMet+i9PCBs*((:͊Ӷ֙Ftq)6юӑ;`hWW<ؼfkxZ+ڈ/7"1#k9S]tD/k2aM ?&~?L׾_uAW;UUQ GNQaH}98"7Wydg"A<~EkmUW57I*5_p X}4q-Dm֍uFeDEMh{Y3šREl(l>n4(<0E'>bYbvuIѣ+|bb L}_ӫAG'qJ[NeFqG=2 \,FNY楡Lo:R_db:1:b-[x:S8AۈG-Ao k`^1&xfj]$kE7 rJvi:bZ^k}0vh\ߚusTj&5:Xc./Égm}%{J1}hDB& )?ʌ,d^5"/{2*b,,̧cq@b Fju aA ʫ$#i[d80|wm73'm21QW& o={nȵDקԁdg^ֳdЧ_tn>_tRZ8źbGdDz_nqvX*/?[" rCgXxe[ jI&fO"PbZ"_ ؘ'Q֢D &{y.Wb|˿eY 숮U"#xc(5P[̵ֱ{u!f9c`%l:n(}(VFRNogxo:g*Lg`eBu˱f`gbϰ-7 d]`Oh$$@]Pa}Hs5u ɡnJ.A?{ol1c#Jy8Dwp#1NKm(I8s Ms.z$h@V~'LѰu$I_qk4桾$&4OB7%JtQ+AђFmŢNn }ѵ)dIqKq8Qb oŒ 6 <JIS>ޔhb F5dYy&(kc;I% v*r<<"}ߜYZ.F_B n2D-mGͭ+iosDDi" -k^ ʦ'럯6 (/ebLױA%tāt;"P#`=2:5UeWh'xy zgOW:Ԫ-Nʣ#w ".S8b}9wfFsB?,T jE>AqۅX+FX}K8 ڨsD HLcJEdU(EKi7lfb_2`4&6#]dWL2'af&‘9v~-^dXطJFK50 mn(\3iHHUF …*; 4܀ɸ*BS43~#q]Z\Gy^SoK9Z]']QH|_a/gϡ3 <"I8s3:W8Y ǭ3 ̚6@a:So?-MDY,,YL-/}]#j*NIf6Ъe)p)kxDm/L1ǚt,mKA˵RAKc\*Ë tjkF^%|[K4RVDZ"x%Z4<0f$O 9D*5c_v|-wID`L XZG]e;*C fR%Q_-b~uC%u9QbpXy4 @r Cyt1c{=d FG5^v|Eiۗ+H n7綼y͚[[&e=~6_*S580.$HpQ~ȮǛ q[)D&Z4Q]qHC/2ڱQte{"&ZkhA&ai]m GxDwPABWk xRDU hЩA)Kx2-1|SA岁B@+ @ 0qd P?8+QywǺ-גbxo1gôa1:WG-sc`O՗X|NVR̊< FBdp ]!סYǰglOmİLo/ebU é %qrX( .1ݭTqsϛ>̀qzr{K_7^)3<D+t(:o;QEQH-瀭mw#1'!Neiqvmiaȏ1k}).áS+,bw78{UIr|f)>Vn]j p!sڈ1 Gw}| f3l캞PWWmƥRdv&RIr_K_{ýp:nէM'=lv(f'֞NbbP5H5Vo{YchX_xxC_ScFŖm-Gkdi֧3^6+^&_%KavVw) lNj7Hr9w~w~{,DL O`Ra>1\NX2Ҟ襻^R}QLyx<^nI{_hMyLښ^prIr}<.=o>wDRV,Y)7?PԱl90V)}Il=<:mMO(3˟|?˒ұ' ZZ3g^օ(hu><̽iڢEFWBc÷y*H}/Z~ߘJT[њ:)f*ߌʣxcrXKmMׄ7"&n,VB~[| koεӓ& ntnSb@ QWBv)|[bܟΠ-;v A?%9eLCOr*~, `2J'DzDWX+]1+NDd1Ôx8jh"Z-|Z01DH̾ 6 `+-Du 1iD;]žuV`Gh[]ȱX |Kfa_@bf,15uf,"+gV^Fp,wrTq W x6?=.Xb+P!6MVj0""?Vgc<9٣ rĠ*1+YWmJ"Ӹ|=RZ}LNbb[b]'uut*7 jECW 7Db0`G-oQ^UUDG=r1-G51mM=z@ۅ3s0{΀%]@!ܻ?͸\#1QW&]\w@], G?GF^k ɉOͼ:FDvb{znIy(k;PvDEniEkuEA ,Z8P-tYbU_=a+ZWW!.4M3(D.Xj+SbKaLzv-R.{ԵX DPђG}Dp:kRc$v<] ""<Fl1Җosqÿzw-L:iAEWp!0ԚuT"(d9=n1U`hb:-(Dh[h<E=Xvd$ ϕ}?6>WEr~alzfo-3yN#?Oi|ޚįgKk" ѕA $%h6fХ) qCצ‚V%\VHQa4۲UV峵| 2LYU]n`sp|1ݿRRRp%3bGFt>>MzPCUV>DDDDDDW:Kq}`C˘0k!i+Wkk-kՉf:PDZ؉R9i &ݬ8:_m5>p"""""µ&(^D#ڴhY#SustSyj1cJlDD&ADDDdCޥZءNd1t2hRDo6,xDG`N2నDԽX 술YFDDDDDD@ +m`N'frB̜Fݓ3˦3u"#MU9D]6#fꈮW$Ys+<8^Z9DH.q+Tb5#;` """""2u{e!"Ge1#ZܽlDDDDDD]Z0CD݁Yvh|.?R8-zΕv)ωf9Dݘ @_oz%Z6=?Wu : Qwf1@DDDDDH:¬k3CDZfݱx : Qwf6#X`YDw,Svpe12u 13hPˏ)]#:'^'#SUY˿7v#-vi )7_R:+RlZZ\idW= FIX yâpt|Trmܦg"N/3'*_jRЕ8DÒ0{d/8ڭgP#Y>8nY[ʲq#%NcG~!Q^H/xe}4YGG"{Fanp񷰣>ߎ n ë>1yU.Y oЊg Qc1 D(2As><޴hT2PG#B0ypd5+!Hk(˃zI}Hxge嵤[v"^H>\ }MJQn*eOxyCv"fx;L6Ng%Z,k:M$?k^n%-k3%~yٝ!ԹKJw⃿5:H^X+X7Y[Kxˑ̹TG!"rt[;ߚ<6[ 瀭Zm=OGn|#.Iךo1v:3> ig7-cSD~Alz9 Oo7~h޴d[Dލ!ΧĶ}ӆ^PZ^Ӽ}C偑zd{1!qFĀd̉_n˅l \/T.)C"~Inܷ>2^D??7:b뾵IAɸᛐZYJzAQ1%jbWWjA R&uJJ?~-gM=nHK9n5J9x#$jC\XTX*G1㾉觫@iE=<|4Dg/Ŷ_'UQmɣ-@[뿵ceIRj`pQ_j2ctg#|vwg7BG;-2|-8aVlI[%[~x`u9wn?ϖAŀYw=6úky,aObF'7|1,>9^_ja7 w3H[y [tn kZF~Km[5 7gÏVDq;"ވn#Y;E6',y+̭Snνo{ {Fx͞<ߢ\#-b5dDLw>X&M4kF6k'>yo2"0/zm֞vraهK+z!buK)ǰ17}U<+Kp  B0v<~z͇5˨c꿵yh5㑯u?GP7 tpFhsheіeb__27\ Vzٓrv.rï\+?Ŧw]O y#(@J^ 59;+bE:Shq덤 e߲ehA3>C<`($%fዚ!-g-Tٵ-册-ʾSg7|gʾXt[%aŊX&CPǐrܲvu)u2?xd/ 7< p8VkbMUJT+ޯ?Uj |Dy}aG.|xbH/{7>[qe 8X^rl9:<:}vX/b-^˗⧼om8O1XfHR>2Η{zㆱ ^c!7e*?)Gr| m!<|eBF*?;؝isxI5#昬a2 TJP]S}փtb}k&K>7G#Κ]]K9r[E_z1ُ ܜоDZ _MY46[ۄ(=9,#tF̀2̀d7X$''cD0JBVq<:vLgG'7E(5 ^Q&e]$zs`Ǫ2S]峥:ƮbuXGDo&O@~r BC14/7bк޾LO}=dc"h7+/=#w1w\ Q(_{;|9U֣л"XN}kg'XɆ<>/]kkj IIn_(f:i9xo-[1gH :_4:#Q#:3>;x48 3\x#i }YU&+Cd0D0▟[l!Q믯쎁/p7F3] `bZsd$Q`=?q}uZ4<5_]b0ÇwnN6:UgSb1#Y1 4ހ˲37iIp0uHTJ^~F+X{)+-_coŤ(OC0B<=VzN/t6P-9@qqދQ~e\&'I$WJʼn3n`GVq;,[0"ܽ!-W ׌5L{P*:_WBǞ7rܬ៉@ppZVp3=3ywe"Is7az-wŠ7cbO?Qg!M>#1cJ8=dDDWL]k:s~sq/ ㏈մ~=E|F >ۇaw C/ӊQ\V^pI݆;tbMg}؛6 s Ƣk+j6:ޔ[-ή^-ńޓpǢ 5NrUu%֥]i^ ;bcqQUo7'S9Nkdpf?,f>b=pJlɆ<,#[Zqӻ=GL(/EE}ܡ+ًG1g%~-O݂Ύ~IX+vRYYK XG_nCG^l9IInfk{5ئ5.|n{" 7(iUzꖲrw.êSveߖSr-Cطi%֧]b wbGEa&NZoF%겴ضom#鳰7P>jO/ }F_co1Mx|[/{"XggMLZ-n\g+P1L5usT<-FoxW =);bK֭6~uH1yXGF|vi*c;xk{Cެ). +x3ga+Ֆ‰صt%__Qk7 #Zt6ѕJeoO<ŨG"Rr=aS`s ӕ:WGp =eG ux$5K;c/%ezq:2?A$c5sCԼ|U{ z 26=ﲬC䙈%cX s 9, GSQz5R;GjQGڛ;=3Q&eV{C<~X+Q^H/xe}[GմZuTkoGܷdu> c|lOIROuDD큁nF55 RYo|p s,n]:,{!pM75e(+|-B ""j' t7N2NtƿIrGT<9ȇ`q9c[/,?/Ă?հ}ݠ"<0U)ײ;^=OJa59<_2ܲ6cP,oNu t'>[S ~ᵄ b? >+_x& Q{ݍZW7+Kax`%Zg/-> ·bBD-\9>Qr󆧋>s8cn`kGi&/#c0cGƍɥS32`Շ&Ɩ)]}c/Lx~^mGNbT\ kؙbΨ8'vQ*[[p~7? DOwԗfUX e:u˖}{Ycvܺwۇ85% Z,[#"s=Wxθx͇Ki>Ǝ[?M }iQ]~#|4c7VV(N7b~R桷ÎwAn¬)Q@YjaC/*Cnzq.sqpg e(-C|BFL/#/2JP\f>^PZ^Wq#bB+d}-*KKQɘ3}| ݖ LέIJxp|(&,m@~{.11o73Xl[my-ǠuDōދqa.q%evADoby17m!MXhw`U%OĢai"mmzmZPz!-Ք!QC0w/q;~ V7QI3pOt|;]lH` M Y?C:iE9Z[[Cz6nܷ>2^D??7ںe˾uu%Jw{UFI~G-ر᳃1 lk466[sY'Čp)YIOo,>9^_j5l4=xfar|uƶbܡ6vw_bgatX .Sn1T?n)q[h-ێA(~o4|1 K s2Biw90`u}Ml/EyҬ KmN|2y"0/Ѿ֦gMX7Ҫ݇e.ŮJ%ↈ[6&\|u|#Q<Kp  B0v<~z Y[%M40{ؒVIo~'_1aR'/VֲnQgckʾ}B;pϼ575u˶}kgiWwp>z?>?K)GG " cUůWcpFNp{@ǽS^gÌbժ-cl98p,P_9"uʗ0_ɿx!glJ-7+ɥ8Sl:{]O y#(@M7BM|eʻz/[3 [}o9$ ?o1$ZR |^ sbqbJO37blRn=[A[H v\rYRDb:O 0q/ AC8rGGzIkVB^)~)v2oY{7{J~Hx§ˍACzٻي(Sޱqf[y OczeCT%ؘ*+užYqnwoonآ9u*pv'xVo8nZf˾ug᳃N;KyxJEƗ=l^*Z'8vͤ$917 :whrb o\TmwV*w^صJhm|d/"\ ۔"Rz Ө:3Χ D>Y&CHR-gC+YޯHŝI#1,dW}6.[.8 98osH^ r0:xnjy<2k;emzVI+V? IKEǙ bihĬIMSz}+.vJpyKp.S;Rl߬=;^[Ϸ7|nX']~-[GqT9}|Fe:9^Yrt"NN{pGXd%"/Xql\\t'Ϳ?W};}S{4|r-ƽ?9жFUEWk~)l|I#?F1Dz&$ 2Rq9u׹pk~G1yp ggJR#߆ S-˰\]Rʶc:"ѰԈSd50̕c頳GQV"aYQ'>֗9nu'2]I*ܸV _O;:;@ - h+k# }")s}qC.n] _wKأFb@{ ػ#|#K (6+Ij$$*y8l :1Z'ұzܺeQd^&Qc`Td1~}c% ?`c5%^gz!GV==#cpNdU;E\C>x.x7Qq 'c0ӗ}81Z'yͳvy߈dn+zAP]VGשl7jdm9b`JwAa* -YG|rv}sΉL%Sf삐_+_*qۣR=Ⱥ :C"@j^b@Ĵ޾;}q/@AW*n#_sbl_W=F Psz5V@o(_~yR}! E9" z7^Ȳz5\S MVE^Ⱦ&!abI*13Kb71nXށ+ӳNZ}[8"7Mz+ D D>^^e_D4SYiQŭ<7o!H9nfʊ`9Nڶord 6_5j07* ,c}YgO/6miOp˯/cKBPR W(W^.i}>:-Mࡘ:,e-7'vD _C ru7+"[@ ,;#p| Lfk9LJAWw܊B^՞V(y7 mʣ:`Jaj Cb ,y!zmcn>$b ͛;Ͻ /d'_ Zp3&+uSɅO|/U8q!7bG] Y!5/kӳNZ}vX7_%`ZJ2|Mfa#Rj{­uUJ+A_c&[wnEo֝F9VL4^en5#㥖MFM-G?9c eV_Mõr(sFO tޮ±LΑl_sa_R1Խ!o655TUAx}S#+"|-"t<|(4+^~i!}vlRca#˴b@v񀯯\usaSE>M}cǵ5Pgo~oojY&tv2lp/&8eq򄏗-C/.).3np'=7~Mz/ʒTnyv|tzDy(V;ǖyh7|~wr~+8ُ>eqm:nWbK6, hmݲmHem9c` &{O_ډk+R`GAgԛbnz;ޖ k0->ãn0ؑ7ߢ8?W <~U- Wf`ƴd/ZTd!nfn$9{NȘ0©!e-Ty_.~3g`\B_{ãϜþblT5o0fL|, _:Jw⫵y}1k8R)ٜGku1*W@Ƭ0*”rt-Cطi%֧䙝IK7^AY0$zn%p8v~8]rɠ$w߂~<J]u,r&j,7QpUn;l7Wb36IZ<-FoxW =);btT3k*wûxl!fpO=* 2qrjX}Eyhq~wY{~[;q3==V]k×[m*GFZVZduHo˙0y**JQ]&)4>OHQWז:+7= ""d9ҰW75;cHIIcQ! Q1CDDDDDDD!"""""""rP<:$Œ'%@DDD;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP 9(v;DDDDDDDD""""""""b`A1CDDDDDDD!"""""""rP "jg13hPˏ)DDmk -""rd t\vb7^Hd~$figaW{>9_']f"1g>:zL{zbΝ~ȖSo€zosAo|!Yǎ$˾g07JjŲU=ۧ^Ʀ]uH<qDp̜|aNAW"%a^p*<[ϠFzPWͣ=H{}g#ʤj|?k7? i/@{GX#odw[uk``0 9׼VuU"o_r e=PXmaJ^MIa!33#F♟`ќ_bp~i(g=swZ o oуbL\<w]Zl~="mLKp?ccJkGp Ǭ w'+QZ{mk;Xkuܡk{q=~ցVZcY?w5e(ȸaυ"MW,SO=~_LE%@ */|}ЂhAT7g&E·]ɽ۱'.Ã/=6o`Un+mċ…=*]9 sרԣS;PFϚq19)8{hvN7]TO73ݿhܶh9ۚR$ί#y&{- Z{zJ|qa-C.k.NԘn2[q~7]d\yc :u.ۆ-ۓP,X99񼽇7렗AӖbnmi6.݆;Σ NA6c{Tz|-ىG2,z-!"UX~Z R= )CO(Y2H!HcD@q-JkX1VqP=R:#mlPP13rj-9غ,b $(@y@Um9̙Fo#w(F{7UUZK*w8c:M*Ups0(s!v˯R\ 誠.ncyAD!01j508 E|eMۊb_L\2^ۍl5WuAnqGsu{,Cgkh)-0oP :Co6C𛂟b1W p{n]:Xk1)3Ek%[u  ?Ojm,{x**AꉐYXk_tZ~& E|"6J=h/&!IEX9wϷ.[:p~{Oy>oz\<0|Cr}/K2[ќϩ歽f=f+qs~ &xcw M QQ!W0L [.>v;%DD;8`]XsFRf_"cuоcD.ǁnjĚ7tV*' ~?܆1 oBm;g!R} >*<n,G34 6wa[X8Q)#`mxxsO>-b6l4}b;Q;0'EgIn{-AZ"JbPDW/?:\:wэ9h?ߺ*ou3İ!װ5)orޜ6>\8`Ҿ$ȵL9pYj=;$߾ _持3yRa>)oy k-.1u'mƇ#Mm<&F-Ńҵ$`ʭrn"ތc*5!U~qJ};^1zl}AA<;ԥ|CCQQcZoge *.ϯxslݴ\*mKiB DYk}>CX %I G`ojո3nE ;!ұLYua|{8ݗ`xI1 bj hUTBE|JBb+s.sؓc_ב:h1hCcndX+;`w^>B!T}wuLYҶKK#{X3QhKsӾA|\kkNoc0_ Y8c=# 8Jq:U)XSzTКk1ٓZ7uX lO{g/i7<1R z;RyB^h/sәClObE(5""+N[AJ'}&|oBF=L;V%^^ m F.-CcSNB&7.LچJg?C@ Â0r(|ۮujQs=y<"c^=8za)8 eTX{\ׂCR% 1=x՞1*rwVs漑uQ")ÎLi5aOt OUD80qdQm?7B߼igD>/@o 𚓇D,)Nய+/ay e|ѭ5};6aXl4܅ $/e`:wLFOԩu 2c 3jk0Ʀ=[hr0vUj}C>3[K1Yu}z@|| 5p0KOͥo%A AFFAvtbajQKnCI6nL Xsg)8u:Kpw``Q銡$MyN]Զ|qD#FeWjq91Kcpsg1iv]KvV7WiȺ#ґiΤ B%$JNJ#ĐRyٴRwn[D]7o [!(8"l CQKԭo+[oV+0!pcLB *O2W""""""bJ1CDDDDDDDdlW(V@DDDDDD[Y)vgŢN^iSJ-WddݼtUe%pDDDD:vD ģ7[mΨ[Pmx{WCPz~0eΌf˯4r@DL4ql!-Q_u,;lƽۜV_21jDE=bc'@DD"$=Q<6:c&OĘI MYۉ}wc`:1 Ĵ%#tV>"""v%N[;s,zSfDUeϮ\DDDԻ1Cj mo.6{2& nWeXDD== InIRy[DD!'3 4}>1u< QoN&_yA]\/\r3MG5]_n̳m:vؑp?_/p;DDi2{acnOrPe/ٍ֗,KZl*t_vPDDDD;}ii`cY*CF+;i:uF(w[.Gtx ߺKKg2g!CAl~lـAa埞Ƈo(Ӡ΍f AK7 ȭd|fx,wɒ5:_ʨԭ_VwOw3q۽;4sA"!6~dXn #jL\y_}Pn}y9Kַ[ҿ;%#4mIDDDD1u/z]ٳ?\ٰ&ׯM"4#c&L}e.xs9{1prvZrײLJ"""NS"Wi17 Lgm Xֳ)j/uAMN bG5Rpx08": &w39 v 1MIn[vς.k8\u ""W7]L\/.ji+u-t, 9#CDDD0ǘNtָ:7ljshh ]9iPGޮԱDDDD=;}i+6w2Z3f5U׵Km ""~9YY=[)`~^7~R-uA!"">&3ۮ~*9'7Mc >KLg"""'GZ-v̞y4]"b0OZtTKݯ!""b1rPu3csW!{s<^2Wò=\qƱRa<{u-v2͈%iEkCLZۮ{M6?""1NL`\HL2i:3Qo"6 =)P.Fݜl5|.[qOO[o#ng`X*j*p3zFӱf3R:""nPT87g~܅=2D1 ˟{ W_޹KaRd;(/HCW-RxF*ҿXEwa瑌h2PwsXH*RhKuQ*;qLD3@]1g y^uG筍-*0 v6(^$QyWJ-(`}+0ʲطؖ\"""""";z;uݰ,ܱ4j%HWia, [[rtQX48:b>~TՍ xd vQ6,s .t qDcAsLX`7zG:t M7U0Yh2VQX ZێLD՗\ߗRUpޜզE%-zL9W|mk!8tC%u ૻPfDDDDDD}F˒vS(}Pw5H%ϝGdݪ46vCn`m%CY!@3 &ÿw֯+~q1#zg_ūQAmo %|xnd&CQ~B+i ̏5ta!!"""""cD ދIMLD9 FR *qCk3':ӸP,vq]ƱCƠaUq$'yJWCZsq~t"qQsx}Pi:VgQ\jhe$1hœ.#8 oBFsqqDDDDDDWYEkVYYxAy%;Q)0IvPn 84*׺`Vhʡ֡Q/¬TdfVXE *>VU M2J[{%g:a4 =7|Kr2z4N_ mDDDDDDԪ6vUVut ]z3782JzA(}b|po702ŒX$2CDDDDDDYE:>xr7EN$>*{FCzOןFM7:ިu˱t~e[#n+ykXk Y>wCד_V슂ܻb|`Sk+U """""v,*NNtʀXtR UGDtJ$8`99y\S7,I?]A)_ xpK͇C#ऐQs?vKPǸq"g f4)GG#ʱN8T{zq3?*tz\p孤kkGcȀ/|aɒ/]ދ]I%[{k>l!1.pүk 8gLg\O̹ty릛qY пwss""""">v ,; ^ö϶"u;Hb Æ-_wp&=Xahw"AŮ5_a/#Y"kG- NKmAwkb slV?S;&r}!|?#FmHL2PFao|%5gbK  k&,k+o~@~Kۓ)<'"""""vHSw{X6}x9 SgMƈAr)H< ;c4gNA_8)ƶ$]x,L5#"릂EWpV#>uGQfS-ŭ'`H;uȺp[7ĥuEt4o!ϊu? E'ܜ 质.8;Jm%t~7>ѾP][Q#X9͆QoGz1.Db۝&˫w`įA=h016ΰGsRth'*#q}:+[9Vwb1oT(aǁ+ZgGD2=1BaLQoyNsxuWi{%DDDDN k Nԃh?!ШPn hL@DX*⍈O4Lv^C==8u*g1K$UKJwb@'U A hwKlPz쎘 "2(GDDDD]-vz&cKGU#mŋq@ؼ |j9Pjlq߼+P>ޮFMm#|T![:3e^,i(gA~|>ᆈٷbAuCMI&.َͻ.@-\o*ĸP)CK8w;$_<`l3^2ia=s+f>,4I{mqu/o:mj{="m%81Fcp Ǭ w'+QZ{mk-s/#~5G?xŌ=9)+Ēa\;MmBbixѡ?̞1a댋8{\(37o))f`lT0=\`J\NC;HFqdbI G駱('<>ɛ7W7O3ʤ#ړcE;MD+GAZvn=e Y _!{N5?8T^| JMҶ<ܵ| 2ƪK!ۡJ{2EbC/*~wğ>KÝa7qQ|[TIxb#o W0ge mjd)E(.qB W M.+nS!Rl 5z8{bļ.^|UXˇ0VASZ Gau Y~.{///xJi zV~ /q^NKUe()}t]Սk`2*-@~~%W p [WM h*Hˈa9^{ E˝7c!3'ގy t"8 +nMsϟ0?&AנRF3c=߬͛x|ĸ)!j5RQ!^!05d("oh[o ~sVGq#Jl=z[&2'ou{n7/p+YKkoYjӶb<:Lơv2Ap3Dl]8#kyjk QG0ӫ`(yo><^Ǹ_d埖"iꔞo뉃9t="*݁X ƌ*yO>:%B+گJ4I BNN2-;c7Ąb?c_JTo<<8+ cbؙ߰ܢb冠NMXFJ7^N8><80/ c!9Ƥ;:^1Mz:x 8W*ݔI\CZN>:B8@F߃v;k{nZ.iOy|017$3lC:Go }DGxR]`s}9؎Z= x!k&xj> AN"u6wᦻN|v\,I4n9Xŷ#8R񼙟q ד6u6gxxLS<uil;g!Q} qDq7\o敉[7*0Ʋ_+)K7H0&t{ǤX:i;6^4hyjk Qp^))+^)ROK/)1jiy fM=TasƠL8+}9w,̩/A0vE%NO/GxYmJ1g_^#_% G`ojymqig{M6E ;6Y?. 67udX+;`wTv]?y2'*'I /C7V>^}Xr~-s8N* .k`ȪsկWÁ<]k@nC+ )ȑU^KAN67Y H \બK/i7nT!Zl?[-u8G,7s9Hى-[u{ >9v&jz'՛IPD R?70 iE80LeN8!fqoʼ/⫍'P*}E_!"<ߊ 2=9oJDDD]bޥx zX73`vjyAڻ1E [jp9M(p!E@`}-GS1邴8 H%hӱ(3D?`0f,Dظv;.vSdy4ӱy qr|Xٰ f4ZJW !ҒliN }<+I󃷷.h|Keb5 {2ɨ4Ut3V+O]UCr]rݤ5 ZN!iͼtqw^6Z$QT*ܡr*͛[Tȸ8fJG3LcVv9ebeE&lJ`f6rFN=xnDDDԫ0:U"&xIP0xUumkEUa+Oj< O4}'Xvvn#ƝQrF$hspwql¢0GO~a)-:AUE_&#Pަ=aT/AJ #]rR,~/<u߳뵳L l1  LgI7H i(vsAu-gU?R{ 8+y3'<{ĊX7)5eeN;.DN :FJ+JEDDD;}]'~9EsW4DFA{_-]&rH6~>p+8Ṟ0B|U x:ŹӤrW¤mx_tCHp? [0,8#ߏZv5'S!ucnIERCQ>[I(6v_ S7"wEW`'~2ɯ(ebfe-KUb̋G/_2_cY䈈Ot@ܝVCD80~8RB8zmu|- \C/>77tΰ&6i5 9ȯN7|3~~𔎟܎IbteH9w w}^y k (n#׵a.[w`x,st~ape'FYPC&S!8ʤʛ]-m v{日hF]@b_vspo& Uz[dYl*%y*! 䓅X,UU be7ٞQ1W و37a&N-SPNbI abEU[;rO DI~DN> >)uT:ñ^PxM[q]]mE(Z 5Hon `\hqW͛lu&6 I=0 2L*}0jr yjeZ|21yy3eޞǗD؂%:y;>[XCeI-̼ӷYADDDmNe{VLo &?ecbԴ⊽ajis = F\6 ӧ[iܼ.Z645yQV a[QA@Fm<>< Vce-37ւ]1\eyei<'AX\[TXl4N{-qYSV ;5DDDDm} }=F ֍MÝQyxc[J6?e]?s>tkN7qx~ʳ0'^g6bux E4MGms|I2zlGK7k!)7v<r9F*r=Jm;^8pJ7+*ut.pWBԗ_Ƴ,K)@2Ω}1v,EK Y:,$N\޿VT]Yξގ (-vJ'q-=i)[_SƛcA}ߞĈF |xnz Jʪ!ڪ;]AjR]HC<?3N:Pt!`s`v Jt5gv`WF$Gr5*tpvqq[9{74XpBrP063GXLen!jv4\KDG*N`?&R)KQYwLR=7GohA#H-.Dމ! qk\>NhK]KNjQDDDD;}Vow£X>M0(նH=>ܰ _B|,WA0m=>j(QyMĹT^ڂW_I1|P \a[S«iH:;܂ 71g&FG 3ljʐ{bWbAَ ۴@(_ZaGu>F 򃇫kBev$^pov|&KCKN}Օ5=^pvu5*Fc>$WwXaȜ= C+յyWp:U-͜t>;zbB8'`׶=PmTeoj|؍f9:7s4 vIg镃شy *>ɰ{ִ;1-P  .`xja(.ʼn oD} ;=`w= %Q&N u 44k' p4Ҵ`|Y/90jKgouDeЄS@LB "/DDDDD݅]n..v^XNnnpI0CDDt Gw?]¹s?뭇+"uCDDDDDDDVcUb`J1CDDDDDDDd!"""""""R Y^1+Q_;DDDDDDDDV""""""""+b`J1CDDDDDDDd!"""""""R Y)v;DDDDDDDDV""""""""+b`J1CDDDDDDDd!"""""""R Y)v;DDDDDDDDV""""""""+b`J1CDDDDDDDd!"""""""R Y)v;DDDDDDDDV""""""""+b`J1CDDDDDDDd!"""""""RQCIENDB`cloup-3.0.5/docs/conf.py000066400000000000000000000102021457133754100150750ustar00rootroot00000000000000#!/usr/bin/env python import os import cloup PROJ_DIR = os.path.abspath(os.path.join(__file__, '..', '..')) extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.autodoc.typehints', 'autoapi.extension', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode', 'sphinx_panels', 'sphinx_copybutton', # adds a copy button to code blocks 'versionwarning.extension', 'sphinx_issues', # link to GitHub issues and PRs ] # General information about the project. project = 'cloup' copyright = "2020, Gianluca Gippetto" author = "Gianluca Gippetto" issues_github_path = "janLuke/cloup" extlinks = { "issue": ("https://github.com/janLuke/cloup/issues/%s", "issue "), "pr": ("https://github.com/janLuke/cloup/pull/%s", "pull request "), } # The version info for the project you're documenting, acts as replacement # for |version| and |release|. _version = tuple(map(str, cloup.__version_tuple__)) version = '.'.join(_version[:2]) release = '.'.join(_version[:3]) language = "en" # Autodoc autoclass_content = 'both' autodoc_typehints = 'description' set_type_checking_flag = True typehints_fully_qualified = False # Autoapi autoapi_type = 'python' autoapi_dirs = [os.path.join(PROJ_DIR, 'cloup')] autoapi_template_dir = '_autoapi_templates' templates_path = [autoapi_template_dir] autoapi_keep_files = True autoapi_add_toctree_entry = False autoapi_python_class_content = 'both' autoapi_options = [ 'members', 'undoc-members', 'show-inheritance', 'show-module-summary', 'special-members', 'imported-members', ] intersphinx_mapping = { 'python': ('https://docs.python.org/', None), 'Click': ('https://click.palletsprojects.com', None) } # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: source_suffix = '.rst' master_doc = 'index' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output --------------------------------------------------- html_title = f"cloup v{version}" html_theme = "furo" html_theme_options = { "light_logo": "logo.svg", "dark_logo": "logo-dark-mode.svg", "sidebar_hide_name": True, } html_css_files = [ 'styles/extensions-overrides.css', 'styles/theme-overrides.css', ] pygments_style = 'default' # name of the Pygments (syntax highlighting) style # 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'] primary_color = "#0094ff" darker_primary_color = "#007bd3" def primary_color_alpha(alpha): return "#2a5adf" + "{:02x}".format(int(alpha * 255)) panels_css_variables = { "tabs-color-label-active": darker_primary_color, "tabs-color-label-inactive": "var(--color-foreground-muted)", "tabs-color-overline": "var(--tabs--border)", "tabs-color-underline": "var(--tabs--border)", } panels_add_bootstrap_css = False # -- Version warning ----------------------------------------------------------- versionwarning_messages = { "latest": ( 'This is the documentation for the main development branch of Cloup. ' 'The documentation for the latest stable release is ' 'here.' ), } # versionwarning_project_version = "latest" # For debugging locally versionwarning_body_selector = 'article[role="main"]' # Whether to render to-do notes. todo_include_todos = False # -- Options for HTMLHelp output --------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'cloupdoc' # -- Options for LaTeX output ------------------------------------------ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass # [howto, manual, or own class]). latex_documents = [ (master_doc, 'cloup.tex', 'cloup Documentation', 'Gianluca Gippetto', 'manual'), ] cloup-3.0.5/docs/index.rst000066400000000000000000000025561457133754100154540ustar00rootroot00000000000000:tocdepth: 2 .. meta:: :description lang=en: Cloup is a library that extends the Click Python library with several features: option groups, constraints for group of parameters, the possibility to group the subcommands of a Group in multiple help sections and a custom help formatter with support for styling / theming. :keywords: python, cloup, click, option groups, help sections, constraints, mutually exclusive, help colors, help styling, help theming :google-site-verification: r5SEmg2wwCURBwKSrL_zQJEJbCVsScFhryur7zdFM3s .. include:: ../README.rst :start-after: docs-index-start :end-before: docs-index-end User guide ========== Please, note that Cloup documentation doesn't replace `Click documentation `_. .. toctree:: :caption: User guide :maxdepth: 2 pages/installation pages/arguments pages/option-groups pages/constraints pages/aliases pages/sections pages/formatting pages/misc .. toctree:: :caption: API reference :hidden: autoapi/cloup/index .. toctree:: :caption: Project :maxdepth: 2 :hidden: GitHub Repository Donate pages/contributing pages/credits pages/changelog cloup-3.0.5/docs/make.bat000066400000000000000000000013771457133754100152200ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=python -msphinx ) set SOURCEDIR=. set BUILDDIR=_build set SPHINXPROJ=cloup if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The Sphinx module was not found. Make sure you have Sphinx installed, echo.then set the SPHINXBUILD environment variable to point to the full echo.path of the 'sphinx-build' executable. Alternatively you may add the echo.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% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd cloup-3.0.5/docs/pages/000077500000000000000000000000001457133754100147025ustar00rootroot00000000000000cloup-3.0.5/docs/pages/aliases.rst000066400000000000000000000053761457133754100170700ustar00rootroot00000000000000Subcommand aliases ================== Aliases are alternative names for subcommands. They are often used to define "shortcuts", e.g. ``i`` for ``install``. Usage ----- The usage of this feature is pretty straightforward: just use the ``aliases`` parameter exposed by Cloup command decorators. .. code-block:: python :emphasize-lines: 6,13 @cloup.group(show_subcommand_aliases=True) def cli(): """A package installer.""" pass @cli.command(aliases=['i', 'add']) @cloup.argument('pkg') def install(pkg: str): """Install a package.""" print('install', pkg) # Aliases works even if cls is not a Cloup command class @cli.command(aliases=['uni', 'rm'], cls=click.Command) @cloup.argument('pkg') def uninstall(pkg: str): """Uninstall a package.""" print('uninstall', pkg) .. note:: It's worth noting that the ``aliases`` argument is exposed by *all* command decorators, not just ``Group.command`` and ``Group.group`` (used in the example above). This is possible because aliases are stored in the subcommand, so a ``Group`` can get them from the added command itself. .. _show-subcommand-aliases: Help output of the group ------------------------ By default, aliases are **not** shown in the "Commands" section(s) of the ``Group``. If you want to show them, you can set ``show_subcommand_aliases=True`` as in the example above. This argument is also available as a context setting. With ``show_subcommand_aliases=True`` the ``--help`` output is: .. code-block:: none :emphasize-lines: 9-10 Usage: cli [OPTIONS] COMMAND [ARGS]... A package installer. Options: --help Show this message and exit. Commands: install (i, add) Install a package. uninstall (uni, rm) Uninstall a package. .. admonition:: Customizing the format of the first column :class: note If you ever feel the need, you can easily customize the format of the first column overriding the method :meth:`Group.format_subcommand_name`. My suggestion is to copy the default implementation and modify it. Help output of the subcommand ----------------------------- .. attention:: Aliases are shown **only** in the ``--help`` output of subcommands that extends ``cloup.Command``. So, normal ``click.Command`` won't do it. .. code-block:: text :emphasize-lines: 2 Usage: cli install [OPTIONS] PKG Aliases: i, add Install a package. Options: --help Show this message and exit. This is possible because aliases are stored in the subcommand itself, precisely in the ``aliases`` attribute. Cloup commands declare this attribute and accept it as a parameter. For all other type of commands, Cloup uses monkey-patching to add this attribute. cloup-3.0.5/docs/pages/arguments.rst000066400000000000000000000033731457133754100174470ustar00rootroot00000000000000Arguments with ``help`` ======================= If you've used Click before, you probably know that: ``click.argument()`` does not take a help parameter. This is to follow the general convention of Unix tools of using arguments for only the most necessary things, and to document them in the command help text by referring to them by name. Cloup doesn't force the Unix convention on you. ``cloup.argument`` takes an optional ``help`` parameter. If you pass a non-empty string to at least one of the arguments of a command, Cloup will print a "Positional arguments" section just below the command description. .. tabbed:: Code :new-group: .. code-block:: python from pprint import pprint import cloup from cloup import option, option_group @cloup.command() @cloup.argument('input_path', help="Input path") @cloup.argument('out_path', help="Output path") @option_group( 'An option group', option('-o', '--one', help='a 1st cool option'), option('-t', '--two', help='a 2nd cool option'), option('--three', help='a 3rd cool option'), ) def main(**kwargs): """A test program for cloup.""" pprint(kwargs, indent=3) main() .. tabbed:: Generated help .. code-block:: none Usage: example [OPTIONS] INPUT_PATH OUT_PATH A test program for cloup. Positional arguments: INPUT_PATH Input path OUT_PATH Output path An option group: -o, --one TEXT a 1st cool option -t, --two TEXT a 2nd cool option --three TEXT a 3rd cool option Other options: --help Show this message and exit. cloup-3.0.5/docs/pages/changelog.rst000066400000000000000000000000411457133754100173560ustar00rootroot00000000000000.. include:: ../../CHANGELOG.rst cloup-3.0.5/docs/pages/constraints.rst000066400000000000000000000470721457133754100200150ustar00rootroot00000000000000 .. currentmodule:: cloup.constraints .. highlight:: none Constraints =========== Overview -------- A :class:`Constraint` is essentially a validator for groups of parameters. When unsatisfied, a constraint raises a :exc:`click.UsageError` with an appropriate error message, which is handled and displayed by Click. Each constraint also has an associated description (:meth:`Constraint.help`) that can optionally be shown in the ``--help`` of a command. You can easily override both the help description and the error message if you want (see `Rephrasing constraints`_). Constraints can be combined with logical operators (see `Defining new constraints`_) and can also be applied conditionally (see `Conditional constraints`_). Implemented constraints ----------------------- Parametric constraints ~~~~~~~~~~~~~~~~~~~~~~ Parametric constraints are *subclasses* of ``Constraint`` and so they are camel-cased; .. autosummary:: RequireExactly RequireAtLeast AcceptAtMost AcceptBetween Non-parametric constraints ~~~~~~~~~~~~~~~~~~~~~~~~~~ Non-parametric constraints are *instances* of ``Constraint`` and so they are snake-cased (``like_this``). Most of these are instances of parametric constraints or (rephrased) combinations of them. =========================== ============================================================ :data:`accept_none` Requires all parameters to be unset. --------------------------- ------------------------------------------------------------ :data:`all_or_none` Satisfied if either all or none of the parameters are set. --------------------------- ------------------------------------------------------------ :data:`mutually_exclusive` A rephrased version of ``AcceptAtMost(1)``. --------------------------- ------------------------------------------------------------ :data:`require_all` Requires all parameters to be set. --------------------------- ------------------------------------------------------------ :data:`require_any` Alias for ``RequireAtLeast(1)``. --------------------------- ------------------------------------------------------------ :data:`require_one` Alias for ``RequireExactly(1)``. =========================== ============================================================ When is a parameter considered "set"? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Basically, Cloup considers a parameter to be "set" when its value differs from the one assigned by Click when the parameter is not provided neither by the CLI user nor by the developer. .. list-table:: :header-rows: 1 :widths: 10 7 10 10 :align: center * - Param type - Click default - It's set if - Note * - string - ``None`` - ``value is not None`` - even if empty * - number - ``None`` - ``value is not None`` - even if zero * - boolean non-flag - ``None`` - ``value is not None`` - even if ``False`` * - boolean flag - ``False`` - ``value is True`` - * - tuple - ``()`` - ``len(value) > 0`` - In the future, this policy may become configurable at the context and parameter level. Conditional constraints ~~~~~~~~~~~~~~~~~~~~~~~ :class:`If` allows you to define conditional constraints:: If(condition, then, [else_]) - **condition** -- can be: - a concrete instance of :class:`~conditions.Predicate` - a parameter name; this is a shortcut for ``IsSet(param_name)`` - a list/tuple of parameter names; this is a shortcut for ``AllSet(*param_names)``. - **then** -- the constraint checked when the condition is true. - **else_** -- an optional constraint checked when the condition is false. Available predicates can be imported from ``cloup.constraints`` and are: .. autosummary:: IsSet AllSet AnySet Equal For example: .. code-block:: python from cloup.constraints import ( If, RequireAtLeast, require_all, accept_none, IsSet, Equal ) # If parameter with name "param" is set, # then require all parameters, else forbid them all If('param', then=require_all, else_=accept_none) # Equivalent to: If(IsSet('param'), then=require_all, else_=accept_none) # If "arg" and "opt" are both set, then require exactly 1 param If(['arg', 'opt'], then=RequireExactly(1)) # Another example... of course the else branch is optional If(Equal('param', 'value'), then=RequireAtLeast(1)) Predicates have an associated ``description`` and can be composed with the logical operators ``&`` (and), ``|`` (or) and ``~`` (not). For example: .. code-block:: python predicate = ~IsSet('foo') & Equal('bar', 'value') # --foo is not set and --bar="value" Applying constraints -------------------- Constraints are well-integrated with option groups but decoupled from them: you can apply them to any group of parameters, eventually including positional arguments. There are three ways to apply a constraint: 1. setting the parameter ``constraint`` of ``@option_group`` (or ``OptionGroup``) 2. using the ``@constraint`` decorator and specifying parameters by name 3. using the constraint as a decorator that takes parameter decorators as arguments (similarly to ``@option_groups``, but supporting ``argument`` too); this is just convenient *syntax sugar* on top of ``@constraint`` that can be used in some circumstances. As you'll see, Cloup handles slightly differently the constraints applied to option groups, but only in relation to the ``--help`` output. Usage with @option_group ~~~~~~~~~~~~~~~~~~~~~~~~ As you have probably seen in the :doc:`option-groups` chapter, you can easily apply a constraint to an option group by setting the ``constraint`` argument of ``@option_group`` (or ``OptionGroup``): .. code-block:: python :emphasize-lines: 6 @option_group( 'Option group title', option('-o', '--one', help='an option'), option('-t', '--two', help='a second option'), option('--three', help='a third option'), constraint=RequireAtLeast(1), ) This code produces the following help section with the constraint description between square brackets on the right of the option group title:: Option group title: [at least 1 required] -o, --one TEXT an option -t, --two TEXT a second option --three TEXT a third option If the constraint description doesn't fit into the section heading line, it is printed on the next line:: Option group title: [this is a long description that doesn't fit into the title line] -o, --one TEXT an option -t, --two TEXT a second option --three TEXT a third option If the constraint is violated, the following error is shown:: Error: at least 1 of the following parameters must be set: --one (-o) --two (-t) --three You can customize both the help description and the error message of a constraint using the method :meth:`Constraint.rephrased` (see `Rephrasing constraints`_). If you simply want to hide the constraint description in the help, you can use the method :meth:`Constraint.hidden`: .. code-block:: python @option_group( ... constraint=RequireAtLeast(1).hidden(), ) The ``@constraint`` decorator ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Using the :func:`cloup.constraint` decorator, you can apply a constraint to any group of parameters (arguments and options) providing their **destination names**, i.e. the names of the function arguments they are mapped to (by Click). For example: =============================================== =================== Declaration Name =============================================== =================== ``@option('-o')`` ``o`` ``@option('-o', '--out-path')`` ``out_path`` ``@option('-o', '--out-path', 'output_path')`` ``output_path`` =============================================== =================== Here's a meaningless example just to show how to use the API: .. code-block:: python from cloup import argument, command, constraint, option from cloup.constraints import If, RequireExactly, mutually_exclusive @command('cmd', show_constraints=True) @argument('arg', required=False) @option('--one') @option('--two') @option('--three') @option('--four') @constraint( mutually_exclusive, ['arg', 'one', 'two'] ) @constraint( If('one', then=RequireExactly(1)), ['three', 'four'] ) def cmd(arg, one, two, three, four): print('ciao') .. _show-constraints: If you set the ``command`` parameter ``show_constraints`` to ``True``, the following section is shown at the bottom of the command help:: Constraints: {ARG, --one, --two} mutually exclusive {--three, --four} exactly 1 required if --one is set Even in this case, you can still hide a specific constraint by using the method :meth:`~Constraint.hidden`. Note that ``show_constraint`` can also be set in the ``context_settings`` of your root command. Of course, the context setting can be overridden by each individual command. .. _constraints-as-decorators: Constraints as decorators ~~~~~~~~~~~~~~~~~~~~~~~~~ ``@constraint`` is powerful but has some drawbacks: - it requires to replicate (once again) the name of the constrained parameters; - it doesn't visually group the involved parameters with nesting (as ``@option_group`` does with options). As an answer to these issues, Cloup introduced the possibility to use constraints themselves as decorators, with an usage similar to that of ``@option_group``. However, note that there are cases when ``@constraint`` is your only option. This feature is just a layer of syntax sugar on top of ``@constraint``. The following: .. code-block:: python @mutually_exclusive( option('--one'), option('--two'), option('--three'), ) is equivalent to: .. code-block:: python @option('--one') @option('--two') @option('--three') @constraint(mutually_exclusive, ['one', 'two', 'three']) .. admonition:: Syntax limitation in Python < 3.9 :name: attention-python-decorators :class: attention In Python < 3.9, the expression on the right of the operator ``@`` is required to be a "dotted name, optionally followed by a single call" (see `PEP 614 `_). This means that you can't instantiate a parametric constraint on the right of ``@``, because the resultant expressions would make two calls, e.g.: .. code-block:: python # This is a syntax error in Python < 3.9 @RequireExactly(2)( # 1st call to instantiate the constraint ... # 2nd call to apply the constraint ) To work around this syntax limitation you can assign your constraint to a variable before using it as a decorator: .. code-block:: python require_two = RequireExactly(2) # somewhere in the code @require_two( option('--one'), option('--two'), option('--three'), ) or, in alternative, you can use the ``@constrained_params`` decorator described below. The ``@constrained_params`` decorator may turn useful to work around the just described syntax limitation in Python < 3.9 or simply when your constraint is long/complex enough that it'd be weird to use it as a decorator: .. code-block:: python @constrained_params( RequireAtLeast(1), option('--one'), option('--two'), option('--three'), ) .. _constraint-inside-option-group: You can use constraints as decorators even inside ``@option_group`` to constrain one or multiple subgroups: .. code-block:: python :emphasize-lines: 3-6 @option_group( "Number options", RequireAtLeast(1)( option('--one'), option('--two') ), option('--three') ) # equivalent to: @option_group( "Number options", option('--one'), option('--two') option('--three') ) @constraint(RequireAtLeast(1), ['one', 'two']) Note that the syntax limitation affecting Python < 3.9 described in the :ref:`attention box ` above does not apply in this case since we are not using ``@`` here. .. _rephrasing-constraints: Rephrasing constraints ---------------------- You can override the help description and/or the error message of a constraint using the :meth:`~Constraint.rephrased` method. It takes two arguments: - **help** -- if provided, overrides the help description. It can be: - a string - a function ``(ctx: Context, constr: Constraint) -> str`` If you want to hide this constraint from the help, pass ``help=""`` or use the method :meth:`~Constraint.hidden`. - **error** -- if provided, overrides the error message. It can be: - a string, eventually a ``format`` string whose fields are stored and documented as attributes in :class:`ErrorFmt`. - a function ``(err: ConstraintViolated) -> str`` where :exc:`ConstraintViolated` is an exception object that fully describes the violation of a constraint, including fields like ``ctx``, ``constraint`` and ``params``. An example from Cloup ~~~~~~~~~~~~~~~~~~~~~ Cloup itself makes use of rephrasing a lot for defining non-parametric constraints, for example: .. code-block:: python mutually_exclusive = AcceptAtMost(1).rephrased( help='mutually exclusive', error=f'the following parameters are mutually exclusive:\n' f'{ErrorFmt.param_list}' ) Example: adding extra info to the original error ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Sometimes you just want to add extra info before or after the original error message. In that case, you can either pass a function or using ``ErrorFmt.error``: .. code-block:: python # Using function (err: ConstraintViolated) -> str mutually_exclusive.rephrased( error=lambda err: f'{err}\n' f'Use --renderer, the other options are deprecated. ) # Using ErrorFmt.error from cloup.constraint import ErrorFmt mutually_exclusive.rephrased( error=f'{ErrorFmt.error}\n' f'Use --renderer, the other options are deprecated. ) Defining new constraints ------------------------ The available constraints should cover 99% of use cases but if you need it, it's very easy to define new ones. Here are your options: - you can use the **logical operators** ``&`` and ``|`` to combine existing constraints and then eventually: - use the ``rephrased`` method described in the previous section - or subclass :class:`~WrapperConstraint` if you want to define a new parametric ``Constraint`` class wrapping the result - just subclass ``Constraint``; look at existing implementations for guidance. Example 1: logical operator + rephrasing ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This is how Cloup defines ``all_or_none`` (this example may be out-of-date): .. code-block:: python all_or_none = (require_all | accept_none).rephrased( help='provide all or none', error=f'the following parameters must be provided all together ' f'(or none should be provided):\n' f'{ErrorFmt.param_list}', ) Example 2: defining a new parametric constraint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ *Option 1 -- Just use a function.* .. code-block:: python def accept_between(min, max): return (RequireAtLeast(min) & AcceptAtMost(max)).rephrased( help=f'at least {min} required, at most {max} accepted' ) >>> accept_between(1, 3) Rephraser(help='at least 1 required, at most 3 accepted') *Option 2 -- WrapperConstraint.* This is useful when you want to define a new constraint type. ``WrapperConstraint`` delegates all methods to the wrapped constraint so you can override only the methods you need to override. .. code-block:: python class AcceptBetween(WrapperConstraint): def __init__(self, min: int, max: int): # [...] self._min = min self._max = max # whatever you pass as **kwargs is used in the __repr__ super().__init__( RequireAtLeast(min) & AcceptAtMost(max), min=min, max=max, # <= included in the __repr__ ) def help(self, ctx: Context) -> str: return f'at least {self._min} required, ' \ f'at most {self._max} accepted' >>> AcceptBetween(1, 3) AcceptBetween(1, 3) \*Validation protocol --------------------- A constraint performs two types of checks and there's a method for each type: - :meth:`~Constraint.check_consistency` – performs sanity checks meant to detect mistakes of the developer; as such, they are performed *before* argument parsing (when possible); for example, if you try to apply a ``mutually_exclusive`` constraint to an option group containing multiple required options, this method will raise ``UnsatisfiableConstraint`` - :meth:`~Constraint.check_values` – performs user input validation and, when unsatisfied, raises a ``ConstraintViolated`` error with an appropriate message; ``ConstrainedViolated`` is a subclass of ``click.UsageError`` and, as such, is handled by Click itself by showing the command usage and the error message. Using a constraint as a function is equivalent to call the method :meth:`~Constraint.check`, which performs (by default) both kind of checks, unless consistency checks are disabled (see below). When you add constraints through ``@option_group``, ``OptionGroup`` and ``@constraint``, this is what happens: - constraints are checked for consistency *before* parsing - input is parsed and processed; all values are stored by Click in the ``Context`` object, precisely in ``ctx.params`` - constraints validate the parameter values. In all cases, constraints applied to option groups are checked before those added through ``@constraint``. If you use a constraint inside a callback, of course, consistency checks can't be performed before parsing. All checks are performed together after parsing. Disabling consistency checks ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can safely skip this section since disabling consistency checks is a micro-optimization likely to be completely irrelevant in practice. Current consistency checks should not have any relevant impact on performance, so they are enabled by default. Nonetheless, they are completely useless in production, so I added the possibility to turn them off (globally) passing ``check_constraints_consistency=False`` as part of your ``context_settings``. Just because I could. To disable them only in production, you should set an environment variable in your development machine, say ``PYTHON_ENV="dev"``; then you can put the following code at the entry-point of your program: .. code-block:: python import os from cloup import Context SETTINGS = Context.setting( check_constraints_consistency=(os.getenv('PYTHON_ENV') == 'dev') # ... other settings ... ) @group(context_settings=SETTINGS) # ... def main(...): ... Have I already mentioned that this is probably not worth the effort? \*Feature support ----------------- .. note:: If you use command classes/decorators redefined by Cloup, you can skip this section. To support constraints, a ``Command`` must inherit from :class:`ConstraintMixin`. It's worth noting that ``ConstraintMixin`` integrates with ``OptionGroupMixin`` but it **doesn't** require it to work. To use the ``@constraint`` decorator, you must currently use ``@cloup.command`` as command decorator. Using ``@click.command(..., cls=cloup.Command)`` won't work. This may change in the future though. cloup-3.0.5/docs/pages/contributing.rst000066400000000000000000000000441457133754100201410ustar00rootroot00000000000000.. include:: ../../CONTRIBUTING.rst cloup-3.0.5/docs/pages/credits.rst000066400000000000000000000000371457133754100170710ustar00rootroot00000000000000.. include:: ../../CREDITS.rst cloup-3.0.5/docs/pages/formatting.rst000066400000000000000000000323611457133754100176130ustar00rootroot00000000000000.. py:currentmodule:: cloup Help formatting and themes ========================== Formatting settings ------------------- The following formatting settings can be provided both at the context and at the command level: - **align_option_groups** -- whether to align option group sections (see :ref:`here `) - **align_sections** -- whether to align sections of subcommands; this is the analogous of ``align_option_groups`` for subcommand sections - **show_constraints** -- whether to include the "Constraints" section (see :ref:`here `) - **show_subcommand_aliases** -- whether to show aliases of subcommands in the ``--help`` output of a ``Group`` (see :ref:`here `) - **formatter_settings** -- a dictionary of parameters to forward to :class:`HelpFormatter` (click on it for the full list). The following of this chapter is all about these. Context-level settings propagate to subcommands, while command-level settings don't. Of course, command-level settings override context-level settings. In particular, the context-level and command-level ``formatter_settings`` are merged together, with command-level settings having higher priority. An example ~~~~~~~~~~ .. tip:: In Cloup, you can use the static methods :meth:`Context.settings` and :meth:`HelpFormatter.settings` to create dictionaries without leaving your IDE to check the docs. .. code-block:: python from cloup import Context, command, group, HelpFormatter, HelpTheme CONTEXT_SETTINGS = Context.settings( # parameters of Command: align_option_groups=False, align_sections=True, show_constraints=True, # parameters of HelpFormatter: formatter_settings=HelpFormatter.settings( max_width=100, col1_max_width=25, col2_min_width=30, indent_increment=3, col_spacing=3, row_sep='', # empty line between definitions theme=HelpTheme.dark(), ) ) @group(context_settings=CONTEXT_SETTINGS) # ... def main(...): ... @main.command( align_option_groups=True, # overrides the context setting formatter_settings=HelpFormatter.settings( col1_max_width=30, # overrides this specific formatter parameter ) ) # ... def cmd(...): ... Themes ------ You can set a "help theme" using the ``theme`` key of ``formatter_settings``. Since your entire application should have a consistent theme, you should set a theme at the context level of your root command: .. code-block:: python SETTINGS = Context.settings( formatter_settings=HelpFormatter.settings( theme=HelpTheme(...) ) ) @cloup.group(context_settings=SETTINGS) def root_command(...): ... A :class:`HelpTheme` is a collection of styles for several elements of the help page. A "style" is just a function (or a callable) that takes a string and returns a styled version of it. This means you can use your favorite styling/color library (like ``rich``, ``colorful`` etc) with it. Given that Click has some built-in basic styling functionality provided by the function :func:`click.style`, Cloup provides the :class:`~cloup.Style` class, which wraps ``click.style`` to facilitate its use with ``HelpTheme``. .. tip:: Cloup also provides an *enum-like* :class:`Color` class containing all colors supported by Click. The following picture links ``HelpTheme`` arguments to the corresponding visual elements of the help page (only ``epilog`` is missing in the image): .. image:: ../_static/theme-elems.png :alt: Elements The above image was obtained with the following theme:: HelpTheme( invoked_command=Style(fg='bright_yellow'), heading=Style(fg='bright_white', bold=True), constraint=Style(fg='magenta'), col1=Style(fg='bright_yellow'), ) For an always up-to-date list of all possible arguments these classes take, refer to the API reference: .. autosummary:: HelpTheme Style Predefined themes ~~~~~~~~~~~~~~~~~ Cloup provides two predefined themes: .. autosummary:: HelpTheme.dark HelpTheme.light Ideally, you should select a theme based on the terminal background color or let the user decide which one to use (if any) at the application level. If you want, you can use the default themes as a base and change only some of the styles using :meth:`HelpTheme.with_`, e.g.: .. code-block:: python theme = HelpTheme.dark().with_( col1=Style(fg=Color.bright_green), epilog=Style(fg=Color.bright_white, italic=True) ) .. _row-separators: Row separators -------------- You can specify how to separate the rows/entries of a definition list using the ``row_sep`` argument of ``HelpFormatter``. You may want to use this argument to separate definitions with an empty line in order to improve readability. .. note:: ``row_sep`` only affects the "tabular layout", not the linear layout. A constant separator ~~~~~~~~~~~~~~~~~~~~ To use a separator consistently for all definition lists, you can either pass either: - a string **not** ending with ``\n``: the formatter will consistently write a newline character after the separator. You can set ``row_sep=''`` if you want an empty line between rows - or a function ``(width: int) -> str`` that generates such a string based on the width of the definition list; this allows you to pass an instance of :class:`~cloup.formatting.sep.Hline` if you want to use horizontal lines. Note that ``Hline`` is an utility that you can use in other parts of your program as well. .. code-block:: python # No row separator (default) row_sep=None # Separate rows with an empty line row_sep='' # Horizontal lines (various styles) row_sep=Hline.solid row_sep=Hline.dashed row_sep=Hline.densely_dashed row_sep=Hline.dotted Using a separator conditionally ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A fixed separator gives a consistent look to your help page but has the drawback of adding the separator even when unneeded (e.g. in the "Commands" section), wasting vertical space. To overcome this problem, Cloup allows you to specify a "policy" that decides **for each individual definition list** whether to use a row separator (and which one). Such policy must implement the :class:`~cloup.formatting.sep.RowSepPolicy` interface. In practice, you will use :class:`~cloup.formatting.sep.RowSepIf`, which takes the following parameters: - **condition** -- a :class:`~cloup.formatting.sep.RowSepCondition`, i.e. a function that decides, based on the available horizontal space, if a definition list should use a row separator or not - **sep** -- the separator to use in definition lists that satisfy the ``condition``. This may be a string or a ``SepGenerator``. The default separator is ``sep=""``, which corresponds to an empty line between rows. Cloup provides the function :func:`~cloup.formatting.sep.multiline_rows_are_at_least` to create conditions that enable the use of a separator only if the number of rows taking multiple lines is above a certain threshold. The threshold can be specified either as an absolute number or as a percentage relative the total number of rows in the definition list: .. code-block:: python # Insert an empty line only if the definition list has at least 1 multi-line row row_sep=RowSepIf(multiline_rows_are_at_least(1)) # Insert a dotted line only if at least 25% of all rows take multiple lines row_sep=RowSepIf(multiline_rows_are_at_least(.25), sep=Hline.dotted) The linear layout for definition lists -------------------------------------- When the terminal width is "too small" for a standard 2-column definition lists, Cloup ``HelpFormatter`` switches to a "linear layout", where - the option description is always printed below the option name, with an indentation increment of at least 3 spaces - all definitions are separated by an empty line. The following tabs compare the ``--help`` of the manim example ("aligned" and "non-aligned" refer to the ``align_option_groups`` argument): .. tabbed:: Linear layout .. code-block:: none Usage: manim render [OPTIONS] SCRIPT_PATH [SCENE_NAMES]... Render some or all scenes defined in a Python script. Global options: -c, --config_file TEXT Specify the configuration file to use for render settings. --custom_folders Use the folders defined in the [custom_folders] section of the config file to define the output folder structure. --disable_caching Disable the use of the cache (still generates cache files). --flush_cache Remove cached partial movie files. --tex_template TEXT Specify a custom TeX template file. -v, --verbosity [DEBUG|INFO|WARNING|ERROR|CRITICAL] Verbosity of CLI output. Changes ffmpeg log level unless 5+. [...] .. tabbed:: Standard layout (aligned) .. code-block:: none Usage: manim render [OPTIONS] SCRIPT_PATH [SCENE_NAMES]... Render some or all scenes defined in a Python script. Global options: -c, --config_file TEXT Specify the configuration file to use for render settings. --custom_folders Use the folders defined in the [custom_folders] section of the config file to define the output folder structure. --disable_caching Disable the use of the cache (still generates cache files). --flush_cache Remove cached partial movie files. --tex_template TEXT Specify a custom TeX template file. -v, --verbosity [DEBUG|INFO|WARNING|ERROR|CRITICAL] Verbosity of CLI output. Changes ffmpeg log level unless 5+. [...] .. tabbed:: Standard layout (non-aligned) .. code-block:: none Usage: manim render [OPTIONS] SCRIPT_PATH [SCENE_NAMES]... Render some or all scenes defined in a Python script. Global options: -c, --config_file TEXT Specify the configuration file to use for render settings. --custom_folders Use the folders defined in the [custom_folders] section of the config file to define the output folder structure. --disable_caching Disable the use of the cache (still generates cache files). --flush_cache Remove cached partial movie files. --tex_template TEXT Specify a custom TeX template file. -v, --verbosity [DEBUG|INFO|WARNING|ERROR|CRITICAL] Verbosity of CLI output. Changes ffmpeg log level unless 5+. --notify_outdated_version / --silent Display warnings for outdated installation. [...] The linear layout is used when the available width for the 2nd column is below ``col2_min_width``, one of the ``formatter_settings``. You can disable the linear layout settings ``col2_min_width=0``. You make the linear layout your default layout by settings ``col2_min_width`` to a large number, possibly ``math.inf``. Minor differences with Click ---------------------------- - The width of the 1st column of a definition list is computed excluding the rows that exceeds ``col1_max_width``; this results in a better use of space in many cases, especially with ``align_option_groups=False``. - The default ``short_help``'s of commands actually use all the available terminal width (in Click, they don't; see "Related issue" of `this Click issue `_) - The command epilog is not indented (this is just my subjective preference). cloup-3.0.5/docs/pages/installation.rst000066400000000000000000000022641457133754100201410ustar00rootroot00000000000000 Installation ============ To install the latest stable release, run:: pip install cloup Cloup adheres to `semantic versioning `_. Depending on Cloup: recommendations ----------------------------------- 1. Pin Cloup version ~~~~~~~~~~~~~~~~~~~~ I probably don't need to explain this, but make sure you pin the version you are using in your requirements file. Dependency management tools like Poetry will do this automatically but if you still use ``requirements.txt`` or ``setup.py``, you can do it like following: .. parsed-literal:: cloup ~= \ |release|\ Patch releases are guaranteed to be backward-compatible even before v1.0. At each new release, you can check the :doc:`changelog` to see what's changed. 2. Add Click to your requirements too ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Cloup is not a replacement for Click. - Cloup reimplements or just re-exports many Click symbols but not *all*. You may still need to import click for some stuff. - Cloup doesn't force you to use a specific version of Click; it only specifies a range of supported versions; that's an enough reason to add Click to your dependencies: to have control on its version as well. cloup-3.0.5/docs/pages/misc.rst000066400000000000000000000003141457133754100163650ustar00rootroot00000000000000Miscellaneous ============= Shortcuts for creating ``click.Path`` ------------------------------------- .. versionadded:: 0.13.0 .. autosummary:: cloup.path cloup.dir_path cloup.file_path cloup-3.0.5/docs/pages/option-groups.rst000066400000000000000000000252411457133754100202650ustar00rootroot00000000000000Option groups ============= .. highlight:: python The @option_group decorator --------------------------- The recommended way of defining option groups is through the :func:`~cloup.option_group` decorator. This decorator is overloaded with two signatures that only differ by how you provide the optional ``help`` argument:: # help as keyword argument @option_group(title, *options, help=None, ...) # help as 2nd positional argument @option_group(title, help, *options, ...) Here's the full list of parameters: - **title** -- title of the help section describing the option group - **\*options** -- an arbitrary number of decorators like those returned by ``cloup.option`` and ``click.option``. Since v0.9, each decorator can add even multiple options in a row. This was introduced to support constraints as decorators - **help** -- an optional description shown below the title; can be provided as keyword argument or 2nd positional argument - **constraint** -- an optional instance of Constraint (see :doc:`constraints` for more info); a description of the constraint will be shown between squared brackets aside the option group title (or below it if too long) - **hidden** -- if True, the option group and all its options are hidden from the help page (all contained options will have their hidden attribute set to True). .. tabbed:: Code :new-group: .. code-block:: python import cloup from cloup import option_group, option from cloup.constraints import RequireAtLeast @cloup.command() @option_group( "Input options", option("--one", help="1st input option"), option("--two", help="2nd input option"), option("--three", help="3rd input option"), ) @option_group( "Output options", "This is a an optional description of the option group.", option("--four / --no-four", help="1st output option"), option("--five", help="2nd output option"), option("--six", help="3rd output option"), constraint=RequireAtLeast(1), ) # The following will be shown (with --help) under "Other options" @option("--seven", help="1st uncategorized option") @option("--height", help="2nd uncategorized option") def cli(**kwargs): """A CLI that does nothing.""" print(kwargs) cli() .. tabbed:: Generated help .. code-block:: none Usage: clouptest [OPTIONS] A CLI that does nothing. Input options: --one TEXT 1st input option --two TEXT 2nd input option --three TEXT 3rd input option Output options: [at least 1 required] This is a an optional description of the option group. --four / --no-four 1st output option --five TEXT 2nd output option --six TEXT 3rd output option Other options: --seven TEXT 1st uncategorized option --height TEXT 2nd uncategorized option --help Show this message and exit. Options that are not assigned to an option group are included is the so called **default option group**, which is shown for last in the ``--help``. This group is titled "Other options" unless it is the only option group, in which case ``cloup.Command`` behaves like a normal ``click.Command``, naming it just "Options". In the example above, I used the :func:`cloup.option` decorator to define options but that's not required: you can use :func:`click.option` or any other decorator that acts like it. Nonetheless: .. admonition:: Tip: prefer Cloup decorators over Click ones :class: tip Cloup provides detailed type hints for (almost) all arguments you can pass to parameter and command decorators. This translates to a better **IDE support**, i.e. better auto-completion and error detection. .. _aligned-vs-nonaligned-group: Aligned vs non-aligned groups ----------------------------- By default, all option group help sections are **aligned**, meaning that they share the same column widths. Many people find this visually pleasing and this is also the default behavior of ``argparse``. Nonetheless, if some of your option groups have shorter options, alignment may result in a lot of wasted space and definitions quite far from option names, which is bad for readability. See this biased example to compare the two modes: .. tabbed:: Aligned .. code-block:: none Usage: clouptest [OPTIONS] A CLI that does nothing. Input options: --one TEXT This description is more likely to be wrapped when aligning. --two TEXT This description is more likely to be wrapped when aligning. --three TEXT This description is more likely to be wrapped when aligning. Output options: --four This description is more likely to be wrapped when aligning. --five TEXT This description is more likely to be wrapped when aligning. --six TEXT This description is more likely to be wrapped when aligning. Other options: --seven [a|b|c|d|e|f|g|h|i] First uncategorized option. --height TEXT Second uncategorized option. --help Show this message and exit. .. tabbed:: Non-aligned .. code-block:: none Usage: clouptest [OPTIONS] A CLI that does nothing. Input options: --one TEXT This description is more likely to be wrapped when aligning. --two TEXT This description is more likely to be wrapped when aligning. --three TEXT This description is more likely to be wrapped when aligning. Output options: --four This description is more likely to be wrapped when aligning. --five TEXT This description is more likely to be wrapped when aligning. --six TEXT This description is more likely to be wrapped when aligning. Other options: --seven [a|b|c|d|e|f|g|h|i] First uncategorized option. --height TEXT Second uncategorized option. --help Show this message and exit. In Cloup, you can format each option group independently from each other setting the ``@command`` parameter ``align_option_groups=False``. Since v0.8.0, this parameter is also available as a ``Context`` setting:: from cloup import Context, group CONTEXT_SETTINGS = Context.settings( align_option_groups=False, ... ) @group(context_settings=CONTEXT_SETTINGS) def main(): pass .. note:: The problem of aligned groups can sometimes be solved decreasing the :class:`HelpFormatter` parameter ``col1_max_width``, which defaults to 30. Alternative APIs ---------------- Option groups without nesting ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ While I largely prefer ``@option_group``, you may not like the additional level of indentation it requires. In that case, you may prefer the following way of defining option groups: .. code-block:: python from cloup import OptionGroup from cloup.constraints import SetAtLeast # OptionGroup takes all arguments of @option_group but *options input_grp = OptionGroup( 'Input options', help='This is a very useful description of the group' ) output_grp = OptionGroup('Output options', constraint=SetAtLeast(1)) @cloup.command() @input_grp.option('--one') @input_grp.option('--two') @output_grp.option('--three') @output_grp.option('--four') def cli_flat(one, two, three, four): """A CLI that does nothing.""" print(kwargs) The above notation is just syntax sugar on top of ``@cloup.option``: .. code-block:: python @input_grp.option('--one') # is equivalent to: @cloup.option('--one', group=input_grp) Option groups without decorators ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For some reason, you may need to work at a lower level, by passing parameters to a ``Command`` constructor. In that case you can use :class:`cloup.Option` (or the alias ``GroupedOption``):: from cloup import Command, Option, OptionGroup output_opts = OptionGroup("Output options") params = [ Option('--verbose', is_flag=True, group=output_opts), ... ] cmd = Command(..., params=params, ...) Reusing/modularizing option groups ---------------------------------- Some people have asked how to reuse option groups in multiple commands and how to put particularly long option groups in their own files. This is easy if you know how Python decorator works. First, you store the decorator returned by ``option_group`` (called without a ``@``) in a variable:: from cloup import option_group output_options = option_group( "Output options", option(...), option(...), ... ) Then you can use the decorator as many times as you want:: @command() # other decorators... @output_options # other decorators ... def foo() ... Of course, if ``output_options`` is defined in a different file, don't forget to import it! .. admonition:: Terminology-nazi note It's worth noting that ``output_options`` in the example above is **not** an option group, it's just a function that recreate the same ``OptionGroup`` object and all its options every time it is called. So, technically, you're not "reusing an option group". How it works ------------ This feature is implemented simply by annotating each option with an additional attribute ``group`` of type ``Optional[OptionGroup]``. Unless the option is of class ``cloup.Option``, this ``group`` attribute is added and set by monkey-patching. When the ``Command`` is instantiated, it groups all options by their ``group`` attribute. Options that don't have a ``group`` attribute (or have it set to ``None``) are stored in the "default option group" (together with ``--help``). In order to show option groups in the command help, ``OptionGroupMixin`` "overrides" ``Command.format_options``. Feature support --------------- This features depends on two mixins: - (*required*) :class:`~cloup.OptionGroupMixin` - (*optional*) :class:`~cloup.ConstraintMixin`, if you want to use constraints. .. admonition:: New! :class: tip Since Cloup v0.14.0, ``cloup.Group`` supports option groups and constraints too. cloup-3.0.5/docs/pages/sections.rst000066400000000000000000000112441457133754100172650ustar00rootroot00000000000000.. highlight:: none Subcommand sections =================== Cloup allows you to organize the subcommand of a ``Group`` (or, more in general, of a ``MultiCommand``) in multiple help sections. Each such help section is represented by a :class:`~cloup.Section` instance, which is just a titled container for commands. A ``Section`` can be: - **sorted** -- it lists the commands in alphabetical order - **unsorted** -- it lists the commands in the order they are added to the section. All sections defined by the developer are unsorted by default. You can create a sorted section by passing ``sorted=True`` or by using the static method ``Section.sorted(*commands)``. Adding full sections -------------------- This is my favourite way of structuring my sections. You can find a runnable example that implements part of the help of Git `here `_. The code below is based on that example. .. tabbed:: Code :new-group: .. code-block:: python import cloup from .commands import ( # import your subcommands git_clone, git_init, git_rm, git_sparse_checkout, git_mv, git_status, git_log) @cloup.group('git') def git(): return 0 git.section( 'Start a working area (see also: git help tutorial)', git_clone, git_init ) git.section( 'Work on the current change (see also: git help everyday)', git_rm, git_sparse_checkout, git_mv ) # Subcommands that are not assigned to a specific section # populate the "default section" git.add_command(git_status) git.add_command(git_log) .. tabbed:: Generated help .. code-block:: none Usage: git [OPTIONS] COMMAND [ARGS]... Options: --help Show this message and exit. Start a working area (see also: git help tutorial): clone Clone a repository into a new directory init Create an empty Git repository or reinitialize an... Work on the current change (see also: git help everyday): rm Remove files from the working tree and from the index sparse-checkout Initialize and modify the sparse-checkout mv Move or rename a file, a directory, or a symlink Other commands: log Show commit logs status Show the working tree status All commands that are not explicitly assigned to a section are assigned to a **default (sorted) section**. This section is titled "Other commands", unless it is the only section defined, in which case ``cloup.Group`` behaves like a normal ``click.Group``, naming it just "Commands". Each call of :class:`Group.section` instantiates a new :class:`~cloup.Section` and adds it to the ``Group``. Of course, when you add a section, all its commands added to the ``Group``. In alternative, you can create a list of ``Section`` objects and pass it as the ``sections`` argument of :func:`cloup.group`: .. code-block:: python import cloup from cloup import Section # [...] omitting import/definition of subcommands SECTIONS = [ Section('Start a working area (see also: git help tutorial)', [git_clone, git_init]), Section('Work on the current change (see also: git help everyday)', [git_rm, git_sparse_checkout, git_mv]) ] @cloup.group('git', sections=SECTIONS) def git(): return 0 Adding subcommands one at a time -------------------------------- In Cloup, all ``Group`` methods for adding subcommands, i.e. ``Group.command``, ``Group.group`` and ``Group.add_command``, have an additional ``section`` argument that you can (optionally) use to assign a subcommand to a ``Section``. .. code-block:: python import cloup from cloup import Section # Define sections without filling them. # I'm using a class as a namespace here. class Sect: START_WORKING_AREA = Section( 'Start a working area (see also: git help tutorial)') WORK_CURRENT_CHANGE = Section( 'Work on the current change (see also: git help everyday)' @cloup.group('git') def git(): return 0 @git.command('init', section=Sect.START_WORKING_AREA) def git_init(): pass @git.command('mv', section=Sect.WORK_CURRENT_CHANGE) def git_mv(): pass Note that -- differently from ``OptionGroup`` instances -- ``Section`` instances don't act as simple markers, they act as *containers* from the start: they are mutated every time you assign a subcommand to them. cloup-3.0.5/examples/000077500000000000000000000000001457133754100144715ustar00rootroot00000000000000cloup-3.0.5/examples/arguments_with_help.py000066400000000000000000000017231457133754100211160ustar00rootroot00000000000000""" Show-casing the "Positional arguments" help section. """ from pprint import pprint import cloup from cloup import option, option_group @cloup.command(name='cloup', show_constraints=True) @cloup.argument('input_path', help="Input path") @cloup.argument('out_path', help="Output path") @option_group( 'An option group', option('-o', '--one', help='a 1st cool option'), option('-t', '--two', help='a 2nd cool option'), option('--three', help='a 3rd cool option'), ) def main(**kwargs): """A test program for cloup.""" pprint(kwargs, indent=3) if __name__ == '__main__': main() """ Usage: arguments_with_help.py [OPTIONS] INPUT_PATH OUT_PATH A test program for cloup. Positional arguments: INPUT_PATH Input path OUT_PATH Output path An option group: -o, --one TEXT a 1st cool option -t, --two TEXT a 2nd cool option --three TEXT a 3rd cool option Other options: --help Show this message and exit. """ cloup-3.0.5/examples/default_command.py000066400000000000000000000017671457133754100202000ustar00rootroot00000000000000""" This example: - requires click-default-group - was written in response to a StackOverflow question, not because I think it's a good idea to have groups with default command; I tend to not recommend them. """ import click import cloup from click_default_group import DefaultGroup class GroupWithDefaultCommand(cloup.Group, DefaultGroup): # (Optional) Mark the default command with "*". If you don't need this, you # can just write `pass`. def format_subcommand_name( self, ctx: click.Context, name: str, cmd: click.Command ) -> str: if name == self.default_cmd_name: name = name + "*" return super().format_subcommand_name(ctx, name, cmd) @cloup.group(cls=GroupWithDefaultCommand, default='alice') def cli(): pass @cli.command() @cloup.option("--foo") def alice(**kwargs): print("Called alice with", kwargs) @cli.command() @cloup.option("--bar") def bob(**kwargs): print("Called bob with", kwargs) if __name__ == '__main__': cli() cloup-3.0.5/examples/flat_option_groups.py000066400000000000000000000021531457133754100207610ustar00rootroot00000000000000""" Example of option groups, "flat style". """ import click import cloup from cloup import OptionGroup, option from cloup.constraints import If, RequireAtLeast, mutually_exclusive _input = OptionGroup( 'Input options', help='This is a very useful description of the group', constraint=mutually_exclusive, ) _output = OptionGroup( 'Output options', constraint=If('three', then=RequireAtLeast(1)), ) @cloup.command('cloup_flat', align_option_groups=True, no_args_is_help=True) # Input options @_input.option('-o', '--one', help='1st input option') @_input.option('--two', help='2nd input option') @_input.option('--three', help='3rd input option') # Output options @_output.option('--four', help='1st output option') @_output.option('--five', help='2nd output option') @_output.option('--six', help='3rd output option') # Other options @option('--seven', help='first uncategorized option', type=click.Choice('yes no ask'.split())) @option('--height', help='second uncategorized option') def main(**kwargs): """A CLI that does nothing.""" print(kwargs) if __name__ == '__main__': main() cloup-3.0.5/examples/git_sections.py000066400000000000000000000055321457133754100175420ustar00rootroot00000000000000""" This example shows how to use ``cloup.Section`` to organize the subcommands of a multi-command in many ``--help`` sections. The code was generated by parsing ``git --help`` and taking - the first 3 sections; - for each section, the first 3 commands after shuffling them. """ # flake8: noqa E128 import cloup def f(**kwargs): """Dummy command callback.""" print(**kwargs) # In a real big application, you would import the following commands from separate modules ========= git_clone = cloup.command('clone', help='Clone a repository into a new directory')(f) git_init = cloup.command('init', help='Create an empty Git repository or reinitialize an existing one')(f) git_rm = cloup.command('rm', help='Remove files from the working tree and from the index')(f) git_sparse_checkout = cloup.command('sparse-checkout', help='Initialize and modify the sparse-checkout')(f) git_mv = cloup.command('mv', help='Move or rename a file, a directory, or a symlink')(f) git_status = cloup.command('status', help='Show the working tree status')(f) git_diff = cloup.command('diff', help='Show changes between commits, commit and working tree, etc')(f) git_bisect = cloup.command('bisect', help='Use binary search to find the commit that introduced a bug')(f) # ================================================================================================== # If "align_sections=True" (default), the help column of all sections will # be aligned; otherwise, each section will be formatted independently. @cloup.group('git', align_sections=True) def git(): return 0 """ git.section() creates a new Section object, adds it to git and returns it. In the help, sections are shown in the same order they are added. Commands in each sections are shown in the same order they are listed, unless you pass the argument "sorted=True". """ git.section( 'Start a working area (see also: git help tutorial)', git_clone, git_init, ) git.section( 'Work on the current change (see also: git help everyday)', git_rm, git_sparse_checkout, git_mv, ) git.section( 'Examine the history and state (see also: git help revisions)', git_status, git_diff, git_bisect, ) """ In alternative, you can either: - pass a list of Section objects as argument "sections" to cloup.Group or @cloup.group - use git.add_section(section) to add an existing Section object - use git.add_command(cmd, name, section, ...); the section must NOT contain the command - use @git.command(cmd, name, section, ...) Individual commands don't store the section they belong to. Also, cloup.command doesn't accept a "section" argument. """ # The following commands will be added to the "default section" (a sorted Section) git.add_command(cloup.command('fake-2', help='Fake command #2')(f)) git.add_command(cloup.command('fake-1', help='Fake command #1')(f)) if __name__ == '__main__': git() cloup-3.0.5/examples/manim/000077500000000000000000000000001457133754100155725ustar00rootroot00000000000000cloup-3.0.5/examples/manim/config.py000066400000000000000000000013431457133754100174120ustar00rootroot00000000000000import os import cloup @cloup.group( 'config', aliases=['conf', 'cfg'], invoke_without_command=True, no_args_is_help=True, ) def config(): """Manage Manim configuration files.""" @config.command(no_args_is_help=True) @cloup.option( "-l", "--level", type=cloup.Choice(["user", "cwd"], case_sensitive=False), default="cwd", help="Specify if this config is for user or the working directory.", ) @cloup.option("-o", "--open", "openfile", is_flag=True) def write(level: str, openfile: bool) -> None: """Write configurations.""" @config.command() def show(): """Show current configuration.""" @config.command() @cloup.option("-d", "--directory", default=os.getcwd()) def export(directory): pass cloup-3.0.5/examples/manim/main.py000066400000000000000000000034431457133754100170740ustar00rootroot00000000000000""" Example based on the CLI of Manim Community, which actually uses Cloup. This example shows how a real-world application could look like and serves to me as a test bench for trying out styling and formatting. """ import cloup from cloup import ( Color, Context, HelpFormatter, HelpTheme, Style, ) from cloup.formatting.sep import RowSepIf, multiline_rows_are_at_least from config import config from render import render VERSION = '0.5.0' CONTEXT_SETTINGS = Context.settings( help_option_names=["-h", "--help"], align_option_groups=False, align_sections=True, # subcommand help sections # color=False, show_subcommand_aliases=True, formatter_settings=HelpFormatter.settings( # width=None, # max_width=80, # col1_max_width=30 # col2_min_width=35, # indent_increment=2, row_sep=RowSepIf( multiline_rows_are_at_least(0.35), # sep=Hline.densely_dashed ), theme=HelpTheme.dark().with_( # invoked_command=Style(...), # command_help=Style(...), # heading=Style(...), # constraint=Style(fg='red'), # section_help=Style(...), # col1=Style(...), col2=Style(dim=True), epilog=Style(fg=Color.bright_white, italic=True), alias=Style(fg=Color.yellow), alias_secondary=Style(fg=Color.white), ), ), ) @cloup.group( name='Manim', no_args_is_help=True, context_settings=CONTEXT_SETTINGS, epilog="Made with <3 by Manim Community developers.", ) @cloup.version_option(version=VERSION) def main(): """Animation engine for explanatory math videos.""" main.add_command(render) main.add_command(config) if __name__ == "__main__": main(prog_name='manim') cloup-3.0.5/examples/manim/render.py000066400000000000000000000114201457133754100174210ustar00rootroot00000000000000from pprint import pprint import cloup from cloup import argument, command, option, option_group from cloup.constraints import ErrorFmt, mutually_exclusive @command(aliases=["r", "re"]) @argument("script_path", help="Script path.", type=cloup.Path(), required=True) @argument("scene_names", help="Name of the scenes.", required=False, nargs=-1) @option_group( "Global options", option( "-c", "--config_file", help="Specify the configuration file to use for render settings.", ), option( "--custom_folders", is_flag=True, help="Use the folders defined in the [custom_folders] section of the " "config file to define the output folder structure.", ), option( "--disable_caching", is_flag=True, help="Disable the use of the cache (still generates cache files).", ), option("--flush_cache", is_flag=True, help="Remove cached partial movie files."), option("--tex_template", help="Specify a custom TeX template file."), option( "-v", "--verbosity", type=cloup.Choice( ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False, ), help="Verbosity of CLI output. Changes ffmpeg log level unless 5+.", ), option( "--notify_outdated_version/--silent", is_flag=True, default=None, help="Display warnings for outdated installation.", ), ) @option_group( "Output options", option( "-o", "--output_file", multiple=True, help="Specify the filename(s) of the rendered scene(s).", ), option( "--write_to_movie", is_flag=True, default=None, help="Write to a file.", ), option( "--media_dir", type=cloup.Path(), help="Path to store rendered videos and latex.", ), option( "--log_dir", type=cloup.Path(), help="Path to store render logs." ), option( "--log_to_file", is_flag=True, help="Log terminal output to file.", ), ) @option_group( "Render Options", option( "-n", "--from_animation_number", help="Start rendering from n_0 until n_1. If n_1 is left unspecified, " "renders all scenes after n_0.", ), option( "-a", "--write_all", is_flag=True, help="Render all scenes in the input file.", ), option( "-f", "--format", "file_format", default="mp4", type=cloup.Choice(["png", "gif", "mp4"], case_sensitive=False), ), option("-s", "--save_last_frame", is_flag=True), option( "-q", "--quality", default="h", type=cloup.Choice(["l", "m", "h", "p", "k"], case_sensitive=False), help="""\b Resolution and framerate of the render: l = 854x480 30FPS, m = 1280x720 30FPS, h = 1920x1080 60FPS, p = 2560x1440 60FPS, k = 3840x2160 60FPS """, ), option( "-r", "--resolution", help="Resolution in (W,H) for when 16:9 aspect ratio isn't possible.", ), option( "--fps", "--frame_rate", "frame_rate", type=float, help="Render at this frame rate.", ), mutually_exclusive.rephrased( error=f"{ErrorFmt.error}\n" f"Use --renderer, the other two options are deprecated." )( option( "--renderer", type=cloup.Choice(["cairo", "opengl", "webgl"], case_sensitive=False), help="Select a renderer for your Scene.", ), option( "--use_opengl_renderer", is_flag=True, hidden=True, help="(Deprecated) Use --renderer=opengl.", ), option( "--use_cairo_renderer", is_flag=True, hidden=True, help="(Deprecated) Use --renderer=cairo.", ), ), option( "--webgl_renderer_path", type=cloup.Path(), help="The path to the WebGL frontend.", ), option( "-t", "--transparent", is_flag=True, help="Render scenes with alpha channel." ), ) @option_group( "Ease of access options", option( "--progress_bar", default="display", type=cloup.Choice(["display", "leave", "none"], case_sensitive=False), help="Display progress bars and/or keep them displayed.", ), option( "-p", "--preview", is_flag=True, help="Preview the Scene's animation. OpenGL does a live preview in a " "popup window. Cairo opens the rendered video file in the system " "default media player.", ), option( "-f", "--show_in_file_browser", is_flag=True, help="Show the output file in the file browser.", ), option("--jupyter", is_flag=True, help="Using jupyter notebook magic."), ) def render(**kwargs): """Render some or all scenes defined in a Python script.""" pprint(kwargs, indent=2) cloup-3.0.5/examples/option_groups.py000066400000000000000000000034431457133754100177560ustar00rootroot00000000000000""" Example of options groups, "nested style" (recommended). NOTE: the goal of this example is to showcase Cloup's option groups and constraint API in a compact way; the example is neither meaningful nor represents a good usage of the library (in fact, you should always design your CLI so that it doesn't need constraints). """ from pprint import pprint from click import Choice import cloup from cloup import option, option_group from cloup.constraints import ( Equal, If, RequireAtLeast, RequireExactly, constraint, mutually_exclusive, ) @cloup.command(name='cloup', show_constraints=True) @cloup.argument('input_path') @cloup.argument('out_path') @option_group( 'First group title', "This is a very long description of the option group. I don't think this is " "needed very often; still, if you want to provide it, you can pass it as 2nd " "positional argument or as keyword argument 'help' after all options.", option('-o', '--one', help='a 1st cool option'), option('-t', '--two', help='a 2nd cool option'), option('--three', help='a 3rd cool option'), constraint=RequireAtLeast(1), ) @option_group( 'Second group name', option('--four', help='a 4th cool option'), option('--five', help='a 5th cool option'), option('--six', help='a 6th cool option'), constraint=If('three', then=RequireExactly(1)), # conditional constraint ) @option('--seven', help='an uncategorized option', type=Choice(['foo', 'bar'])) @option('--eight', help='second uncategorized option') # Usage of @constraint @constraint(mutually_exclusive, ['one', 'two']) @constraint( If(Equal('one', '123'), then=RequireExactly(1)), ['seven', 'six'] ) def main(**kwargs): """A test program for cloup.""" pprint(kwargs, indent=3) if __name__ == '__main__': main() cloup-3.0.5/requirements/000077500000000000000000000000001457133754100153765ustar00rootroot00000000000000cloup-3.0.5/requirements/dev.in000066400000000000000000000001031457133754100164760ustar00rootroot00000000000000-r test.txt -r docs.txt flake8 mypy tox < 4 twine sphinx-autobuild cloup-3.0.5/requirements/dev.txt000066400000000000000000000126461457133754100167260ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # pip-compile --resolver=backtracking requirements/dev.in # alabaster==0.7.13 # via # -r requirements/docs.txt # sphinx astroid==2.15.6 # via # -r requirements/docs.txt # sphinx-autoapi babel==2.12.1 # via # -r requirements/docs.txt # sphinx beautifulsoup4==4.12.2 # via # -r requirements/docs.txt # furo bleach==6.0.0 # via readme-renderer certifi==2023.5.7 # via # -r requirements/docs.txt # requests cffi==1.15.1 # via cryptography charset-normalizer==3.2.0 # via # -r requirements/docs.txt # requests colorama==0.4.6 # via sphinx-autobuild coverage[toml]==7.2.7 # via # -r requirements/test.txt # pytest-cov cryptography==41.0.2 # via secretstorage distlib==0.3.6 # via virtualenv docutils==0.16 # via # -r requirements/docs.txt # readme-renderer # sphinx # sphinx-panels exceptiongroup==1.1.2 # via # -r requirements/test.txt # pytest filelock==3.12.2 # via # tox # virtualenv flake8==6.0.0 # via -r requirements/dev.in furo==2021.4.11b34 # via -r requirements/docs.txt idna==3.4 # via # -r requirements/docs.txt # requests imagesize==1.4.1 # via # -r requirements/docs.txt # sphinx importlib-metadata==6.8.0 # via # keyring # twine importlib-resources==6.0.0 # via keyring iniconfig==2.0.0 # via # -r requirements/test.txt # pytest jaraco-classes==3.3.0 # via keyring jeepney==0.8.0 # via # keyring # secretstorage jinja2==3.0.3 # via # -r requirements/docs.txt # sphinx # sphinx-autoapi keyring==24.2.0 # via twine lazy-object-proxy==1.9.0 # via # -r requirements/docs.txt # astroid livereload==2.6.3 # via sphinx-autobuild markdown-it-py==3.0.0 # via rich markupsafe==2.1.3 # via # -r requirements/docs.txt # jinja2 mccabe==0.7.0 # via flake8 mdurl==0.1.2 # via markdown-it-py more-itertools==9.1.0 # via jaraco-classes mypy==1.4.1 # via -r requirements/dev.in mypy-extensions==1.0.0 # via mypy packaging==23.1 # via # -r requirements/docs.txt # -r requirements/test.txt # pytest # sphinx # tox pkginfo==1.9.6 # via twine platformdirs==3.8.1 # via virtualenv pluggy==1.2.0 # via # -r requirements/test.txt # pytest # tox py==1.11.0 # via tox pycodestyle==2.10.0 # via flake8 pycparser==2.21 # via cffi pyflakes==3.0.1 # via flake8 pygments==2.15.1 # via # -r requirements/docs.txt # readme-renderer # rich # sphinx pytest==7.4.0 # via # -r requirements/test.txt # pytest-cov pytest-cov==4.1.0 # via -r requirements/test.txt pytz==2023.3 # via # -r requirements/docs.txt # babel pyyaml==6.0 # via # -r requirements/docs.txt # sphinx-autoapi readme-renderer==40.0 # via twine requests==2.31.0 # via # -r requirements/docs.txt # requests-toolbelt # sphinx # twine requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine rich==13.4.2 # via twine secretstorage==3.3.3 # via keyring six==1.16.0 # via # bleach # livereload # tox snowballstemmer==2.2.0 # via # -r requirements/docs.txt # sphinx soupsieve==2.4.1 # via # -r requirements/docs.txt # beautifulsoup4 sphinx==3.5.4 # via # -r requirements/docs.txt # furo # sphinx-autoapi # sphinx-autobuild # sphinx-copybutton # sphinx-issues # sphinx-panels # sphinx-version-warning sphinx-autoapi==1.8.4 # via -r requirements/docs.txt sphinx-autobuild==2021.3.14 # via -r requirements/dev.in sphinx-copybutton==0.5.2 # via -r requirements/docs.txt sphinx-issues==3.0.1 # via -r requirements/docs.txt sphinx-panels==0.6.0 # via -r requirements/docs.txt sphinx-version-warning==1.1.2 # via -r requirements/docs.txt sphinxcontrib-applehelp==1.0.4 # via # -r requirements/docs.txt # sphinx sphinxcontrib-devhelp==1.0.2 # via # -r requirements/docs.txt # sphinx sphinxcontrib-htmlhelp==2.0.1 # via # -r requirements/docs.txt # sphinx sphinxcontrib-jsmath==1.0.1 # via # -r requirements/docs.txt # sphinx sphinxcontrib-qthelp==1.0.3 # via # -r requirements/docs.txt # sphinx sphinxcontrib-serializinghtml==1.1.5 # via # -r requirements/docs.txt # sphinx tomli==2.0.1 # via # -r requirements/test.txt # coverage # mypy # pytest # tox tornado==6.3.2 # via livereload tox==3.28.0 # via -r requirements/dev.in twine==4.0.2 # via -r requirements/dev.in typing-extensions==4.7.1 # via # -r requirements/docs.txt # astroid # mypy # rich unidecode==1.3.6 # via # -r requirements/docs.txt # sphinx-autoapi urllib3==2.0.3 # via # -r requirements/docs.txt # requests # twine virtualenv==20.23.1 # via tox webencodings==0.5.1 # via bleach wrapt==1.15.0 # via # -r requirements/docs.txt # astroid zipp==3.16.1 # via # importlib-metadata # importlib-resources # The following packages are considered to be unsafe in a requirements file: # setuptools cloup-3.0.5/requirements/docs.in000066400000000000000000000002601457133754100166540ustar00rootroot00000000000000sphinx <5 sphinx-autoapi ~= 1.8.4 furo == 2021.4.11b34 sphinx-panels ~= 0.6.0 sphinx_copybutton ~= 0.5.0 sphinx-version-warning ~= 1.1.2 sphinx-issues ~= 3.0.1 jinja2 == 3.0.3 cloup-3.0.5/requirements/docs.txt000066400000000000000000000037071457133754100170760ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # pip-compile --resolver=backtracking requirements/docs.in # alabaster==0.7.13 # via sphinx astroid==2.15.6 # via sphinx-autoapi babel==2.12.1 # via sphinx beautifulsoup4==4.12.2 # via furo certifi==2023.5.7 # via requests charset-normalizer==3.2.0 # via requests docutils==0.16 # via # sphinx # sphinx-panels furo==2021.4.11b34 # via -r requirements/docs.in idna==3.4 # via requests imagesize==1.4.1 # via sphinx jinja2==3.0.3 # via # -r requirements/docs.in # sphinx # sphinx-autoapi lazy-object-proxy==1.9.0 # via astroid markupsafe==2.1.3 # via jinja2 packaging==23.1 # via sphinx pygments==2.15.1 # via sphinx pytz==2023.3 # via babel pyyaml==6.0 # via sphinx-autoapi requests==2.31.0 # via sphinx snowballstemmer==2.2.0 # via sphinx soupsieve==2.4.1 # via beautifulsoup4 sphinx==3.5.4 # via # -r requirements/docs.in # furo # sphinx-autoapi # sphinx-copybutton # sphinx-issues # sphinx-panels # sphinx-version-warning sphinx-autoapi==1.8.4 # via -r requirements/docs.in sphinx-copybutton==0.5.2 # via -r requirements/docs.in sphinx-issues==3.0.1 # via -r requirements/docs.in sphinx-panels==0.6.0 # via -r requirements/docs.in sphinx-version-warning==1.1.2 # via -r requirements/docs.in sphinxcontrib-applehelp==1.0.4 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==2.0.1 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx typing-extensions==4.7.1 # via astroid unidecode==1.3.6 # via sphinx-autoapi urllib3==2.0.3 # via requests wrapt==1.15.0 # via astroid # The following packages are considered to be unsafe in a requirements file: # setuptools cloup-3.0.5/requirements/test.in000066400000000000000000000000221457133754100166770ustar00rootroot00000000000000pytest pytest-cov cloup-3.0.5/requirements/test.txt000066400000000000000000000010061457133754100171130ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # pip-compile --resolver=backtracking requirements/test.in # coverage[toml]==7.2.7 # via pytest-cov exceptiongroup==1.1.2 # via pytest iniconfig==2.0.0 # via pytest packaging==23.1 # via pytest pluggy==1.2.0 # via pytest pytest==7.4.0 # via # -r requirements/test.in # pytest-cov pytest-cov==4.1.0 # via -r requirements/test.in tomli==2.0.1 # via # coverage # pytest cloup-3.0.5/scripts/000077500000000000000000000000001457133754100143425ustar00rootroot00000000000000cloup-3.0.5/scripts/browser.py000066400000000000000000000002071457133754100163760ustar00rootroot00000000000000import os import webbrowser import sys path = os.path.abspath(sys.argv[1]) print('Opening with browser:', path) webbrowser.open(path) cloup-3.0.5/scripts/copytree.py000066400000000000000000000005571457133754100165550ustar00rootroot00000000000000import shutil import click from click import argument, command @command(no_args_is_help=True) @argument('src', type=click.Path(exists=True)) @argument('dest', type=click.Path(file_okay=False)) def f(src: str, dest: str): """Copy a SRC file/folder to DEST with merging.""" shutil.copytree(src, dest, dirs_exist_ok=True) if __name__ == '__main__': f() cloup-3.0.5/scripts/generate_git_example.py000066400000000000000000000126711457133754100210730ustar00rootroot00000000000000import random from pathlib import Path random.seed(12345) EXAMPLE_FILE_PATH = Path(__file__).parent.parent / 'examples/git_sections.py' MAX_SECTION_COUNT = 3 MAX_COMMANDS_PER_SECTION = 3 GIT_HELP = """ start a working area (see also: git help tutorial) clone Clone a repository into a new directory init Create an empty Git repository or reinitialize an existing one work on the current change (see also: git help everyday) add Add file contents to the index mv Move or rename a file, a directory, or a symlink restore Restore working tree files rm Remove files from the working tree and from the index sparse-checkout Initialize and modify the sparse-checkout examine the history and state (see also: git help revisions) bisect Use binary search to find the commit that introduced a bug diff Show changes between commits, commit and working tree, etc grep Print lines matching a pattern log Show commit logs show Show various types of objects status Show the working tree status grow, mark and tweak your common history branch List, create, or delete branches commit Record changes to the repository merge Join two or more development histories together rebase Reapply commits on top of another base tip reset Reset current HEAD to the specified state switch Switch branches tag Create, list, delete or verify a tag object signed with GPG """.strip() CODE_TEMPLATE = """ \"\"\" This example shows how to use ``cloup.Section`` to organize the subcommands of a multi-command in many ``--help`` sections. The code was generated by parsing "git --help" and taking - the first {max_section_count} sections - for each section, the first {max_commands_per_section} commands after shuffling them. \"\"\" import cloup def f(**kwargs): \"\"\" Dummy command callback \"\"\" print(**kwargs) # In a real big application, you would import the following commands from separate modules ========= {command_list} # ================================================================================================== \"\"\" If "align_sections=True" (default), the help column of all sections will be aligned; otherwise, each section will be formatted independently. \"\"\" @cloup.group('git', align_sections=True) def git(): return 0 \"\"\" git.section() creates a new Section object, adds it to git and returns it. In the help, sections are shown in the same order they are added. Commands in each sections are shown in the same order they are listed, unless you pass the argument "sorted_=True". \"\"\" {section_list} \"\"\" In alternative, you can either: - pass a list of Section objects as argument "sections" to cloup.Group or @cloup.group - use git.add_section(section) to add an existing Section object - use git.add_command(cmd, name, section, ...); the section must NOT contain the command - use @git.command(cmd, name, section, ...) Individual commands don't store the section they belong to. Also, cloup.command doesn't accept a "section" argument. \"\"\" # The following commands will be added to the "default section" (a sorted Section) git.add_command(cloup.command('fake-2', help='Fake command #2')(f)) git.add_command(cloup.command('fake-1', help='Fake command #1')(f)) if __name__ == '__main__': git() """.lstrip() def parse_help(help_text): sections = [] for text in help_text.split('\n\n'): title, *rows = map(str.strip, text.split('\n')) title = title.strip().capitalize() commands = [] for row in rows: cmd, desc = map(str.strip, row.split(' ', 1)) commands.append((cmd, desc)) random.shuffle(commands) sections.append([title, commands]) return sections def get_command_var_name(cmd_name): return 'git_' + cmd_name.replace('-', '_') def generate(out_path=EXAMPLE_FILE_PATH, max_section_count=MAX_SECTION_COUNT, max_commands_per_section=MAX_COMMANDS_PER_SECTION): sections = parse_help(GIT_HELP)[:max_section_count] for s in sections: s[1] = s[1][:max_commands_per_section] # Subcommand definitions commands_buffer = [] for _, commands in sections: for cmd_name, desc in commands: var_name = get_command_var_name(cmd_name) commands_buffer.append( f'{var_name} = cloup.command({cmd_name!r}, help={desc!r})(f)') commands_buffer.append('') commands_list = '\n'.join(commands_buffer).strip() # Section list sections_buffer = [] for title, commands in sections: sections_buffer.append("git.section(") sections_buffer.append(f" {title!r},") for cmd_name, _ in commands: var_name = get_command_var_name(cmd_name) sections_buffer.append(f" {var_name},") sections_buffer.append(')') section_list = '\n'.join(sections_buffer) code = CODE_TEMPLATE.format(max_section_count=max_section_count, max_commands_per_section=max_commands_per_section, command_list=commands_list, section_list=section_list) print(code) with open(out_path, 'w', encoding='utf-8') as out_file: out_file.write(code) if __name__ == '__main__': generate() cloup-3.0.5/scripts/make-help.py000066400000000000000000000003351457133754100165600ustar00rootroot00000000000000import re import sys regex = re.compile(r'^([a-zA-Z_-]+):.*?## (.*)$') for line in sys.stdin: match = regex.match(line) if match: target, help = match.groups() print("%-20s %s" % (target, help)) cloup-3.0.5/scripts/remove.py000066400000000000000000000021061457133754100162100ustar00rootroot00000000000000import os import shutil from glob import glob import argparse from itertools import chain parser = argparse.ArgumentParser( description=""" Remove full directories and files matching the provided glob patterns. If -r/--recursive, the pattern '**' will match any files and zero or more directories and subdirectories. """ ) parser.add_argument('paths', nargs='+') parser.add_argument('-r', '--recursive', action='store_true', help="Use recursive globs, i.e. the pattern '**' will match any " "files and zero or more directories and subdirectories.") parser.add_argument('-d', '--dry-run', action='store_true', help='Do not remove files, just print a list of them') args = parser.parse_args() paths = set(chain.from_iterable( glob(arg, recursive=args.recursive) for arg in args.paths )) if args.dry_run: print('\n'.join(paths)) else: for path in paths: if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) print('Removed', path) cloup-3.0.5/setup.cfg000066400000000000000000000004321457133754100144730ustar00rootroot00000000000000[bdist_wheel] universal = 1 [flake8] exclude = docs max_line_length = 90 ignore = E241, E251, W503 [aliases] test = pytest [mypy] ignore_missing_imports = True [coverage:report] exclude_lines = pragma: no cover raise NotImplementedError \.\.\. if TYPE_CHECKING: cloup-3.0.5/setup.py000066400000000000000000000034361457133754100143730ustar00rootroot00000000000000#!/usr/bin/env python from pathlib import Path from setuptools import find_packages, setup def make_long_description(write_file=False): readme = Path('README.rst').read_text(encoding='utf-8') # PyPI doesn't support the `raw::` directive. Skip it. start = readme.find('.. docs-index-start') long_description = readme[start:] if write_file: Path('PYPI_README.rst').write_text(long_description, encoding='utf-8') return long_description setup( name='cloup', setup_requires=['setuptools_scm'], use_scm_version={ 'write_to': 'cloup/_version.py' }, author='Gianluca Gippetto', author_email='gianluca.gippetto@gmail.com', description="Adds features to Click: option groups, constraints, subcommand " "sections and help themes.", long_description_content_type='text/x-rst', long_description=make_long_description(), url='https://github.com/janLuke/cloup', license="BSD 3-Clause", keywords=['CLI', 'click', 'argument groups', 'option groups', 'constraints', 'help colors', 'help themes', 'help styles'], classifiers=[ 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Natural Language :: English', 'Programming Language :: Python :: 3', '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', ], packages=find_packages(include=['cloup', 'cloup.*']), zip_safe=False, include_package_data=True, python_requires='>=3.7', install_requires=[ 'click >= 8.0, < 9.0', 'typing_extensions; python_version<="3.8"', ], ) cloup-3.0.5/tests/000077500000000000000000000000001457133754100140155ustar00rootroot00000000000000cloup-3.0.5/tests/__init__.py000066400000000000000000000000431457133754100161230ustar00rootroot00000000000000"""Unit test package for cloup.""" cloup-3.0.5/tests/conftest.py000066400000000000000000000014071457133754100162160ustar00rootroot00000000000000from functools import partial from click.testing import CliRunner from pytest import fixture from tests.example_command import make_example_command from tests.example_group import make_example_group @fixture() def runner(): runner = CliRunner() runner.invoke = partial(runner.invoke, catch_exceptions=False) return runner @fixture(scope='session') def get_example_command(): def get_command(tabular_help=True, align_option_groups=True): return make_example_command( align_option_groups=align_option_groups, tabular_help=tabular_help) return get_command @fixture(scope='session') def get_example_group(): def get_group(align_sections): return make_example_group(align_sections=align_sections) return get_group cloup-3.0.5/tests/constraints/000077500000000000000000000000001457133754100163645ustar00rootroot00000000000000cloup-3.0.5/tests/constraints/__init__.py000066400000000000000000000000001457133754100204630ustar00rootroot00000000000000cloup-3.0.5/tests/constraints/conftest.py000066400000000000000000000026321457133754100205660ustar00rootroot00000000000000from typing import cast from unittest.mock import Mock import click from click import Context from pytest import fixture import cloup from cloup import Command @fixture() def dummy_ctx(): """Useful for testing methods that needs a Context but don't actually use it.""" dummy = Mock(spec_set=Context(Command('name', params=[]))) dummy.command.__class__ = Command return dummy @fixture() def sample_cmd() -> Command: """Useful for testing constraints against a variety of parameter kinds. Parameters have names that should make easy to remember their "kind" without the need for looking up this code.""" @cloup.command() # Optional arguments @click.argument('arg1', required=False) @click.argument('arg2', required=False) # Plain options without default @cloup.option('--str-opt') @cloup.option('--int-opt', type=int) @cloup.option('--bool-opt', type=bool) # Flags @cloup.option('--flag / --no-flag') @cloup.option('--flag2', is_flag=True) # Options with default @cloup.option('--def1', default=1) @cloup.option('--def2', default=2) # Options that take a tuple @cloup.option('--tuple', nargs=2, type=int) # Options that can be specified multiple times @cloup.option('--mul1', type=int, multiple=True) @cloup.option('--mul2', type=int, multiple=True) def f(**kwargs): print('It works') return cast(Command, f) cloup-3.0.5/tests/constraints/test_common.py000066400000000000000000000043011457133754100212630ustar00rootroot00000000000000from click import Argument, Option from cloup.constraints.common import ( format_param, format_param_list, get_param_label, join_with_and, param_value_is_set, ) from tests.util import bool_opt, flag_opt, int_opt, parametrize, multi_opt, tuple_opt @parametrize( 'param_type, value, expected', (Argument, None, False), (Argument, 'bu', True), (Option, None, False), (Option, 'bu', True), (int_opt, 0, True), (bool_opt, False, True), # non-flag boolean opts are set even if False (flag_opt, False, False), (flag_opt, True, True), (tuple_opt, (), False), (tuple_opt, (1, 2), True), (multi_opt, (), False), (multi_opt, (1, 2), True), ids=lambda val: val.__name__ if callable(val) else None, ) def test_param_value_is_set(param_type, value, expected): param = param_type(['-o']) actual = param_value_is_set(param, value) assert actual == expected def test_get_param_label(): assert get_param_label(Argument(['arg'])) == 'ARG' assert get_param_label(Option(['--opt'])) == '--opt' assert get_param_label(Option(['--opt', '-o'])) == '--opt' assert get_param_label(Option(['-o', '--opt'])) == '--opt' assert get_param_label(Option(['-o/-O', '--opt/--no-opt'])) == '--opt' def test_format_param(): assert format_param(Argument(['arg'])) == 'ARG' assert format_param(Option(['--opt'])) == '--opt' assert format_param(Option(['-o'])) == '-o' assert format_param(Option(['--opt', '-o'])) == '--opt (-o)' assert format_param(Option(['-o', '--opt'])) == '--opt (-o)' assert format_param(Option(['-o/-O', '--opt/--no-opt', 'blah'])) == '--opt (-o)' def test_format_param_list(): params = [ Argument(['arg']), Option(['--one']), Option(['--two', '-t']), ] expected = (' ARG\n' ' --one\n' ' --two (-t)\n') assert format_param_list(params, indent=1) == expected def test_join_with_and(): assert join_with_and([]) == '' assert join_with_and('A') == 'A' assert join_with_and('ABC', sep='; ') == 'A; B and C' cloup-3.0.5/tests/constraints/test_conditional_constraints.py000066400000000000000000000333651457133754100247410ustar00rootroot00000000000000from itertools import combinations from typing import Optional from unittest.mock import Mock import pytest from click import Context from pytest import mark from cloup.constraints import ConstraintViolated, If from cloup.constraints.conditions import ( AllSet, AnySet, Equal, IsSet, Predicate, _And, _Or ) from tests.constraints.test_constraints import FakeConstraint from tests.util import make_context, parametrize, mock_repr class FakePredicate(Predicate): def __init__(self, value: bool = True, desc: str = 'description', neg_desc: Optional[str] = None): self.value = value self._desc = desc self._neg_desc = neg_desc def description(self, ctx: Context) -> str: assert isinstance(ctx, Context) return self._desc def negated_description(self, ctx: Context) -> str: assert isinstance(ctx, Context) if self._neg_desc: return self._neg_desc return super().negated_description(ctx) def __call__(self, ctx: Context) -> bool: assert isinstance(ctx, Context) return self.value class TestIf: def test_help(self, sample_cmd): ctx = make_context(sample_cmd, 'arg1 --str-opt=ciao --bool-opt=0') condition = FakePredicate(desc='') then_branch = FakeConstraint(help='') else_branch = FakeConstraint(help='') constr = If(condition, then_branch) assert constr.help(ctx) == ' if ' constr = If(condition, then_branch, else_branch) assert constr.help(ctx) == ' if , otherwise ' @mark.parametrize('condition_is_true', [True, False]) @mark.parametrize( 'else_is_provided', [True, False], ids=['else_given', 'else_missing'] ) def test_branches_check_methods_are_called_correctly( self, sample_cmd, condition_is_true, else_is_provided ): ctx = make_context(sample_cmd, 'arg1 --str-opt=ciao --bool-opt=0') param_names = ['arg1', 'bool_opt'] params = sample_cmd.get_params_by_name(param_names) then_branch = Mock(wraps=FakeConstraint()) else_branch = Mock(wraps=FakeConstraint()) if else_is_provided else None constraint = If( condition=FakePredicate(value=condition_is_true), then=then_branch, else_=else_branch ) constraint.check(param_names, ctx=ctx) # check_consistency() is called on both branches whatever the condition value is then_branch.check_consistency.assert_called_once_with(params) if else_is_provided: else_branch.check_consistency.assert_called_once_with(params) if condition_is_true: then_branch.check_values.assert_called_once_with(params, ctx=ctx) if else_is_provided: else_branch.check_values.assert_not_called() else: then_branch.check_values.assert_not_called() if else_is_provided: else_branch.check_values.assert_called_once_with(params, ctx=ctx) def test_error_message(self, sample_cmd): ctx = make_context(sample_cmd, 'arg1 --str-opt=ciao --bool-opt=0') dummy_params = ['arg1', 'bool_opt'] then_branch = FakeConstraint(satisfied=False, error='') else_branch = FakeConstraint(satisfied=False, error='') true_predicate = FakePredicate(True, desc='') with pytest.raises(ConstraintViolated) as info: If(true_predicate, then_branch, else_branch).check(dummy_params, ctx=ctx) assert info.value.message == 'when , ' false_predicate = FakePredicate(False, desc='', neg_desc='') with pytest.raises(ConstraintViolated) as info: If(false_predicate, then_branch, else_branch).check(dummy_params, ctx=ctx) assert info.value.message == 'when , ' def test_init_with_string_as_condition(self): constr = If('name', FakeConstraint()) assert isinstance(constr._condition, IsSet) assert constr._condition.param_name == 'name' def test_init_with_sequence_of_strings_as_condition(self): param_names = ('opt1', 'opt2') constr = If(param_names, FakeConstraint()) assert isinstance(constr._condition, AllSet) assert constr._condition.param_names == param_names def test_repr(self): predicate = mock_repr('', spec=FakePredicate, wraps=FakePredicate()) then_branch = mock_repr('', wraps=FakeConstraint()) else_branch = mock_repr('', wraps=FakeConstraint()) constr = If(predicate, then_branch) assert repr(constr) == 'If(, then=)' constr = If(predicate, then_branch, else_branch) assert repr(constr) == 'If(, then=, else_=)' class TestNot: def test_descriptions(self, dummy_ctx): neg = ~FakePredicate(desc='') assert neg.desc(dummy_ctx) == 'NOT()' assert neg.neg_desc(dummy_ctx) == '' neg = ~FakePredicate(desc='', neg_desc='') assert neg.desc(dummy_ctx) == '' assert neg.neg_desc(dummy_ctx) == '' def test_double_negation_returns_original_predicate(self): fake = FakePredicate() assert ~~fake is fake class TestAnd: @mark.parametrize('b_value', [False, True]) @mark.parametrize('a_value', [False, True]) def test_evaluation(self, a_value, b_value, dummy_ctx): a = FakePredicate(value=a_value) b = FakePredicate(value=b_value) c = a & b assert c(dummy_ctx) == (a_value and b_value) def test_operand_merging(self): a, b, c, d = (FakePredicate() for _ in range(4)) res = (a & b) & c assert res.predicates == (a, b, c) res = (a & b) & (c & d) assert res.predicates == (a, b, c, d) res = (a & b) & (c | d) assert len(res.predicates) == 3 def test_descriptions(self, dummy_ctx): a, b, c = (FakePredicate(desc=name) for name in 'ABC') assert (a & b & c).description(dummy_ctx) == 'A and B and C' assert (a & b & c).neg_desc(dummy_ctx) == 'NOT(A) or NOT(B) or NOT(C)' class TestOr: @mark.parametrize('b_value', [False, True]) @mark.parametrize('a_value', [False, True]) def test_evaluation(self, a_value, b_value, dummy_ctx): a = FakePredicate(value=a_value) b = FakePredicate(value=b_value) c = a | b assert c(dummy_ctx) == (a_value or b_value) def test_operands_merging(self): a, b, c, d = (FakePredicate() for _ in range(4)) res = (a | b) | c assert res.predicates == (a, b, c) res = (a | b) | (c | d) assert res.predicates == (a, b, c, d) res = (a | b) | (c & d) assert len(res.predicates) == 3 def test_descriptions(self, dummy_ctx): a, b, c = (FakePredicate(desc=desc) for desc in 'ABC') assert (a | b | c).description(dummy_ctx) == 'A or B or C' assert (a | b | c).neg_desc(dummy_ctx) == 'NOT(A) and NOT(B) and NOT(C)' def test_description_with_mixed_operators(dummy_ctx): ctx = dummy_ctx a, b, c, d = (FakePredicate(desc=desc) for desc in 'ABCD') assert (a & b & c).desc(ctx) == 'A and B and C' assert (a | b | c).desc(ctx) == 'A or B or C' assert (a | b & c).desc(ctx) == 'A or (B and C)' assert (a & b | c).desc(ctx) == '(A and B) or C' assert ((a | b) & (c | d)).desc(ctx) == '(A or B) and (C or D)' class TestIsSet: SHELL_INPUT = 'arg1 --bool-opt=0 --flag2 --mul1 1 --mul1 2' @parametrize( # These cases are relative to [sample_cmd] with [SHELL_INPUT] as input # "provided" means provided by the user in the command line ['param', 'is_set'], pytest.param('arg1', True, id='provided argument'), pytest.param('arg2', False, id='unset argument'), pytest.param('bool_opt', True, id='provided option'), pytest.param('str_opt', False, id='unprovided option without default'), pytest.param('def1', True, id='unprovided option with default'), pytest.param('flag', False, id='unset boolean flag'), pytest.param('flag2', True, id='provided boolean flag'), pytest.param('mul1', True, id='provided multi-option'), pytest.param('mul2', False, id='unprovided multi-option'), pytest.param('tuple', False, id='unprovided option with nargs>1'), ) def test_evaluation(self, sample_cmd, param, is_set): ctx = make_context(sample_cmd, self.SHELL_INPUT) assert IsSet(param)(ctx) == is_set assert ~IsSet(param)(ctx) != is_set def test_descriptions(self, sample_cmd): ctx = make_context(sample_cmd, '') assert IsSet('arg1').desc(ctx) == 'ARG1 is set' assert IsSet('str_opt').desc(ctx) == '--str-opt is set' assert IsSet('arg1').neg_desc(ctx) == 'ARG1 is not set' assert IsSet('str_opt').neg_desc(ctx) == '--str-opt is not set' def test_and(self): a, b, c = IsSet('opt1'), IsSet('opt2'), Equal('opt2', 'value') assert a & b == AllSet('opt1', 'opt2') assert a & c == _And(a, c) def test_or(self): a, b, c = IsSet('opt1'), IsSet('opt2'), Equal('opt2', 'value') assert a | b == AnySet('opt1', 'opt2') assert a | c == _Or(a, c) def test_eq(self): assert IsSet('a') == IsSet('a') assert IsSet('a') != IsSet('b') assert IsSet('a') != Equal('a', 'ciao') class TestEqual: def test_evaluation(self, sample_cmd): ctx = make_context(sample_cmd, 'xxx --bool-opt=0 --flag --tuple 1 2') for name, value in ctx.params.items(): assert Equal(name, value)(ctx) assert not Equal(name, 'blah')(ctx) assert not (~Equal(name, value))(ctx) assert ~Equal(name, 'blah')(ctx) def test_descriptions(self, sample_cmd): ctx = make_context(sample_cmd, '') p = Equal('arg1', 'value') assert p.desc(ctx) == 'ARG1="value"' assert p.neg_desc(ctx) == 'ARG1!="value"' def test_eq(self): assert Equal('name', 'value') == Equal('name', 'value') assert Equal('name', 'foo') != Equal('name', 'bar') assert Equal('foo', 'value') != Equal('bar', 'value') class TestAllSet: def test_evaluation_of_true_predicates(self, sample_cmd): ctx = make_context(sample_cmd, 'xxx --bool-opt=0 --flag --tuple 1 2') set_params = ['arg1', 'bool_opt', 'flag', 'tuple'] for n in range(1, len(set_params)): for param_group in combinations(set_params, n): true = AllSet(*param_group) assert true(ctx), param_group assert not (~true)(ctx), param_group def test_evaluation_of_false_predicates(self, sample_cmd): ctx = make_context(sample_cmd, 'xxx --bool-opt=0 --flag --tuple 1 2') param_groups = [('arg1', 'arg2'), ('arg1', 'int_opt'), ('arg2', 'str_opt')] for param_group in param_groups: false = AllSet(*param_group) assert not false(ctx), param_group assert (~false)(ctx), param_group def test_descriptions(self, sample_cmd): ctx = make_context(sample_cmd, '') assert AllSet('arg1').desc(ctx) == 'ARG1 is set' assert AllSet('arg1').neg_desc(ctx) == 'ARG1 is not set' assert AllSet('arg1', 'flag').desc(ctx) \ == 'ARG1 and --flag are both set' assert AllSet('arg1', 'flag').neg_desc(ctx) \ == 'ARG1 and --flag are not both set' assert AllSet('arg1', 'flag', 'int_opt').desc(ctx) \ == 'ARG1, --flag and --int-opt are all set' assert AllSet('arg1', 'flag', 'int_opt').neg_desc(ctx) \ == 'ARG1, --flag and --int-opt are not all set' def test_and(self): allset1 = AllSet('a', 'b') allset2 = AllSet('c', 'd') anyset = AnySet('foo', 'bar') assert allset1 & allset2 == AllSet('a', 'b', 'c', 'd') assert allset1 & anyset == _And(allset1, anyset) def test_eq(self): assert AllSet('a', 'b') == AllSet('a', 'b') assert AllSet('a') != AllSet('a', 'b') assert AllSet('a', 'b') != AnySet('a', 'b') class TestAnySet: def test_evaluation(self, sample_cmd): ctx = make_context(sample_cmd, 'xxx --bool-opt=0 --flag --tuple 1 2') set_params = ['arg1', 'bool_opt', 'flag', 'tuple'] unset_params = ['arg2', 'int_opt', 'str_opt'] for set_param in set_params: true = AnySet(*unset_params, set_param) assert true(ctx) assert not (~true)(ctx) false = AnySet(*unset_params) assert not false(ctx) assert (~false)(ctx) def test_descriptions(self, sample_cmd): ctx = make_context(sample_cmd, '') assert AnySet('arg1').desc(ctx) == 'ARG1 is set' assert AnySet('arg1').neg_desc(ctx) == 'ARG1 is not set' assert AnySet('arg1', 'flag').desc(ctx) \ == 'either ARG1 or --flag is set' assert AnySet('arg1', 'flag').neg_desc(ctx) \ == 'neither ARG1 nor --flag is set' assert AnySet('arg1', 'flag', 'int_opt').desc(ctx) \ == 'any of ARG1, --flag and --int-opt is set' assert AnySet('arg1', 'flag', 'int_opt').neg_desc(ctx) \ == 'none of ARG1, --flag and --int-opt is set' def test_or(self): anyset1 = AnySet('a', 'b') anyset2 = AnySet('c', 'd') allset = AllSet('foo', 'bar') assert anyset1 | anyset2 == AnySet('a', 'b', 'c', 'd') assert anyset1 | allset == _Or(anyset1, allset) def test_eq(self): assert AnySet('a', 'b') == AnySet('a', 'b') assert AnySet('a') != AnySet('a', 'b') assert AnySet('a', 'b') != AllSet('a', 'b') cloup-3.0.5/tests/constraints/test_constraints.py000066400000000000000000000344761457133754100223620ustar00rootroot00000000000000from functools import partial from typing import Sequence from unittest import mock from unittest.mock import Mock import click import pytest from click import Command, Context, Parameter from pytest import mark from cloup.constraints import ( AcceptAtMost, AcceptBetween, Constraint, ErrorFmt, Rephraser, RequireAtLeast, RequireExactly, require_all, ) from cloup.constraints.exceptions import ConstraintViolated, UnsatisfiableConstraint from tests.util import ( make_context, make_fake_context, make_options, parametrize, should_raise, ) class FakeConstraint(Constraint): """Sometimes it's useful to use Mock(wraps=FakeConstraint(...)) to create a test double with characteristics of both a mock and a fake.""" def __init__( self, satisfied=True, consistent=True, help='help', error='error', inconsistency_reason='consistency_error' ): self.satisfied = satisfied self.consistent = consistent self._help = help self.error = error self.inconsistency_reason = inconsistency_reason self.check_consistency_calls = [] self.check_values_calls = [] def help(self, ctx: Context) -> str: return self._help def check_consistency(self, params: Sequence[Parameter]) -> None: self.check_consistency_calls.append(dict(params=params)) if not self.consistent: raise UnsatisfiableConstraint(self, params, self.inconsistency_reason) def check_values(self, params: Sequence[Parameter], ctx: Context): self.check_values_calls.append(dict(params=params, ctx=ctx)) if not self.satisfied: raise ConstraintViolated(self.error, ctx=ctx, constraint=self, params=params) class TestBaseConstraint: def test_rephrased_calls_Rephraser_correctly(self): with mock.patch('cloup.constraints._core.Rephraser') as rephraser_cls: cons = FakeConstraint() cons.rephrased(help='ciao') rephraser_cls.assert_called_with(cons, help='ciao', error=None) cons.rephrased(error='ciao') rephraser_cls.assert_called_with(cons, help=None, error='ciao') def test_hidden_constraint_returns_empty_help(self, dummy_ctx): hidden = FakeConstraint(help='non-empty help').hidden() assert isinstance(hidden, Rephraser) assert hidden.help(dummy_ctx) == '' @mark.parametrize('satisfied', [True, False]) @mark.parametrize('consistent', [True, False]) def test__call__raises_iff_check_raises(self, satisfied, consistent): ctx = make_fake_context(make_options('abc')) cons = FakeConstraint(satisfied=satisfied, consistent=consistent) exc_class = UnsatisfiableConstraint if not consistent else ConstraintViolated with should_raise(exc_class, when=not (consistent and satisfied)): cons.check(['a', 'b'], ctx) @parametrize( ['ctx_kwargs', 'should_check'], pytest.param(dict(cls=click.Context), True, id='click.Context [no setting]'), pytest.param(dict(), True, id='cloup.Context [default]'), pytest.param(dict(check_constraints_consistency=False), False, id='disabled'), ) def test_check_consistency_is_called_unless_disabled(self, ctx_kwargs, should_check): ctx = make_fake_context(make_options('abc'), **ctx_kwargs) constr = FakeConstraint() constr.check(['a', 'b'], ctx) assert Constraint.must_check_consistency(ctx) == should_check assert len(constr.check_consistency_calls) == int(should_check) def test_error_is_raised_when_using_call_the_old_way(self): constr = FakeConstraint() with pytest.raises(TypeError, match='since Cloup v0.9, calling a constraint'): constr(['a', 'b']) class TestAnd: @mark.parametrize('b_satisfied', [False, True]) @mark.parametrize('a_satisfied', [False, True]) def test_check(self, a_satisfied, b_satisfied): ctx = make_fake_context(make_options(['arg1', 'str_opt', 'int_opt', 'flag'])) a = FakeConstraint(satisfied=a_satisfied) b = FakeConstraint(satisfied=b_satisfied) c = a & b with should_raise(ConstraintViolated, when=not (a_satisfied and b_satisfied)): c.check(params=['arg1', 'str_opt'], ctx=ctx) def test_operand_merging(self): a, b, c, d = (FakeConstraint() for _ in range(4)) res = (a & b) & c assert res.constraints == (a, b, c) res = (a & b) & (c & d) assert res.constraints == (a, b, c, d) res = (a & b) & (c | d) assert len(res.constraints) == 3 class TestOr: @mark.parametrize('b_satisfied', [False, True]) @mark.parametrize('a_satisfied', [False, True]) def test_check(self, a_satisfied, b_satisfied): ctx = make_fake_context(make_options(['arg1', 'str_opt', 'int_opt', 'flag'])) a = FakeConstraint(satisfied=a_satisfied) b = FakeConstraint(satisfied=b_satisfied) c = a | b with should_raise(ConstraintViolated, when=not (a_satisfied or b_satisfied)): c.check(params=['arg1', 'str_opt'], ctx=ctx) def test_operands_merging(self): a, b, c, d = (FakeConstraint() for _ in range(4)) res = (a | b) | c assert res.constraints == (a, b, c) res = (a | b) | (c | d) assert res.constraints == (a, b, c, d) res = (a | b) | (c & d) assert len(res.constraints) == 3 def test_operator_help(dummy_ctx): ctx = dummy_ctx a, b, c = RequireAtLeast(3), AcceptAtMost(10), RequireExactly(8) a_help, b_help, c_help = (cons.help(ctx) for cons in [a, b, c]) assert (a | b | c).help(ctx) == f'{a_help} or {b_help} or {c_help}' assert (a | b & c).help(ctx) == f'{a_help} or ({b_help} and {c_help})' class TestRequireAtLeast: def test_init_raises_for_invalid_n(self): RequireAtLeast(0) with pytest.raises(ValueError): RequireAtLeast(-1) def test_help(self, dummy_ctx): assert '3' in RequireAtLeast(3).help(dummy_ctx) def test_check_consistency(self): check_consistency = RequireAtLeast(3).check_consistency check_consistency(make_options('abc')) with pytest.raises(UnsatisfiableConstraint): check_consistency(make_options('ab')) def test_check(self, sample_cmd: Command): ctx = make_context(sample_cmd, 'a1 --str-opt=ciao --bool-opt=0') check = partial(RequireAtLeast(2).check, ctx=ctx) check(['str_opt', 'int_opt', 'bool_opt']) # str-opt and bool-opt check(['arg1', 'int_opt', 'def1']) # arg1 and def1 check(['arg1', 'str_opt', 'def1']) # arg1, str-opt and def1 with pytest.raises(ConstraintViolated): check(['str_opt', 'arg2', 'flag']) # only str-opt is set class TestAcceptAtMost: def test_init_raises_for_invalid_n(self): AcceptAtMost(0) with pytest.raises(ValueError): AcceptAtMost(-1) def test_help(self, dummy_ctx): assert '3' in AcceptAtMost(3).help(dummy_ctx) def test_check_consistency(self): check_consistency = AcceptAtMost(2).check_consistency check_consistency(make_options('abc')) with pytest.raises(UnsatisfiableConstraint): check_consistency(make_options('abc', required=True)) def test_check(self, sample_cmd: Command): ctx = make_context(sample_cmd, 'a1 --str-opt=ciao --bool-opt=0') check = partial(AcceptAtMost(2).check, ctx=ctx) check(['str_opt', 'int_opt', 'bool_opt']) # str-opt and bool-opt check(['arg1', 'int_opt', 'flag']) # arg1 with pytest.raises(ConstraintViolated): check(['arg1', 'str_opt', 'def1']) # arg1, str-opt, def1 class TestRequireExactly: def test_init_raises_for_invalid_n(self): with pytest.raises(ValueError): RequireExactly(0) with pytest.raises(ValueError): RequireExactly(-1) def test_help(self, dummy_ctx): assert '3' in RequireExactly(3).help(dummy_ctx) def test_check_consistency(self): check_consistency = RequireExactly(3).check_consistency check_consistency(make_options('abcd')) with pytest.raises(UnsatisfiableConstraint): check_consistency(make_options('ab')) with pytest.raises(UnsatisfiableConstraint): check_consistency(make_options('abcde', required=True)) def test_check(self, sample_cmd: Command): ctx = make_context(sample_cmd, 'a1 --str-opt=ciao --bool-opt=0') check = partial(RequireExactly(2).check, ctx=ctx) check(['str_opt', 'int_opt', 'bool_opt']) # str-opt and bool-opt check(['arg1', 'int_opt', 'bool_opt']) # arg1 and bool-opt with pytest.raises(ConstraintViolated): check(['arg1', 'str_opt', 'def1']) # arg1, str-opt, def1 with pytest.raises(ConstraintViolated): check(['arg1', 'int_opt', 'flag']) # arg1 class TestAcceptBetween: def test_init_raises_for_invalid_n(self): AcceptBetween(0, 10) AcceptBetween(0, 1) with pytest.raises(ValueError): AcceptBetween(-1, 2) with pytest.raises(ValueError): AcceptBetween(2, 2) with pytest.raises(ValueError): AcceptBetween(3, 2) def test_help(self, dummy_ctx): help = AcceptBetween(3, 5).help(dummy_ctx) assert help == 'at least 3 required, at most 5 accepted' def test_check_consistency(self): check_consistency = AcceptBetween(2, 4).check_consistency check_consistency(make_options('abcd')) with pytest.raises(UnsatisfiableConstraint): check_consistency(make_options('a')) # too little params with pytest.raises(UnsatisfiableConstraint): check_consistency(make_options('abcde', required=True)) # too many required def test_check(self, sample_cmd: Command): ctx = make_context(sample_cmd, 'a1 --str-opt=ciao --bool-opt=0 --flag --mul1=4') check = partial(AcceptBetween(2, 4).check, ctx=ctx) check(['str_opt', 'int_opt', 'bool_opt']) # str-opt and bool-opt check(['arg1', 'int_opt', 'flag']) # arg1, bool-opt and flag check(['def1', 'int_opt', 'flag', 'mul1']) # all with pytest.raises(ConstraintViolated): check(['arg2', 'int_opt', 'def1']) # only def1 with pytest.raises(ConstraintViolated): check(['arg1', 'def1', 'def2', 'str_opt', 'flag']) # all class TestRequiredAll: def test_help(self, dummy_ctx): assert 'all required' in require_all.help(dummy_ctx) def test_check_consistency(self): require_all.check_consistency(make_options('abc')) def test_check(self, sample_cmd: Command): ctx = make_context(sample_cmd, 'arg1 --str-opt=0 --bool-opt=0') check = partial(require_all.check, ctx=ctx) check(['arg1']) check(['str_opt']) check(['arg1', 'str_opt']) check(['arg1', 'str_opt', 'bool_opt']) check(['arg1', 'str_opt', 'bool_opt', 'def1']) with pytest.raises(ConstraintViolated): check(['arg2']) with pytest.raises(ConstraintViolated): check(['arg1', 'arg2']) with pytest.raises(ConstraintViolated): check(['arg1', 'def1', 'int_opt']) class TestRephraser: def test_init_raises_if_neither_help_nor_error_is_provided(self): with pytest.raises(ValueError): Rephraser(FakeConstraint()) def test_help_override_with_string(self, dummy_ctx): wrapped = FakeConstraint() rephrased = Rephraser(wrapped, help='rephrased help') assert rephrased.help(dummy_ctx) == 'rephrased help' def test_help_override_with_function(self, dummy_ctx): wrapped = FakeConstraint() get_help = Mock(return_value='rephrased help') rephrased = Rephraser(wrapped, help=get_help) assert rephrased.help(dummy_ctx) == 'rephrased help' get_help.assert_called_once_with(dummy_ctx, wrapped) def test_error_is_overridden_passing_string(self): fake_ctx = make_fake_context(make_options('abcd')) wrapped = FakeConstraint(satisfied=False, error='__error__') rephrased = Rephraser(wrapped, error=f'error:\n{ErrorFmt.param_list}') with pytest.raises(ConstraintViolated) as exc_info: rephrased.check(['a', 'b'], ctx=fake_ctx) assert exc_info.value.message == 'error:\n --a\n --b\n' def test_error_template_key(self): fake_ctx = make_fake_context(make_options('abcd')) wrapped = FakeConstraint(satisfied=False, error='__error__') rephrased = Rephraser(wrapped, error=f'{ErrorFmt.error}\nExtra info here.') with pytest.raises(ConstraintViolated) as exc_info: rephrased.check(['a', 'b'], ctx=fake_ctx) assert str(exc_info.value) == '__error__\nExtra info here.' def test_error_is_overridden_passing_function(self): params = make_options('abc') fake_ctx = make_fake_context(params) wrapped = FakeConstraint(satisfied=False) error_rephraser_mock = Mock(return_value='rephrased error') rephrased = Rephraser(wrapped, error=error_rephraser_mock) with pytest.raises(ConstraintViolated, match='rephrased error'): rephrased.check(params, ctx=fake_ctx) # Check the function is called with a single argument of type ConstraintViolated error_rephraser_mock.assert_called_once() args = error_rephraser_mock.call_args[0] assert len(args) == 1 error = args[0] assert isinstance(error, ConstraintViolated) # Check the error has all fields set assert isinstance(error.ctx, Context) assert isinstance(error.constraint, Constraint) assert len(error.params) == 3 def test_check_consistency_raises_if_wrapped_constraint_raises(self): constraint = FakeConstraint(consistent=True) rephraser = Rephraser(constraint, help='help') params = make_options('abc') rephraser.check_consistency(params) def test_check_consistency_doesnt_raise_if_wrapped_constraint_doesnt_raise(self): constraint = FakeConstraint(consistent=False) rephraser = Rephraser(constraint, help='help') params = make_options('abc') with pytest.raises(UnsatisfiableConstraint): rephraser.check_consistency(params) def test_a_constraint_can_be_copied_and_deep_copied(): import copy copy.copy(RequireAtLeast(1)) copy.deepcopy(RequireAtLeast(1)) cloup-3.0.5/tests/constraints/test_support.py000066400000000000000000000156771457133754100215310ustar00rootroot00000000000000from unittest.mock import Mock import click import pytest from click import Argument, Option import cloup from cloup import Context from cloup._util import pick_non_missing, reindent from cloup.constraints import ( Constraint, RequireAtLeast, mutually_exclusive, require_all, require_one ) from cloup.typing import MISSING from tests.constraints.test_constraints import FakeConstraint from tests.util import new_dummy_func, pick_first_bool class TestConstraintMixin: def test_params_are_correctly_grouped_by_name(self): params = [ Argument(('arg1',)), Option(('--str-opt',)), Option(('--int-opt', 'option2')), ] cmd = cloup.Command(name='cmd', params=params, callback=new_dummy_func()) for param in params: assert cmd.get_param_by_name(param.name) == param with pytest.raises(KeyError): cmd.get_param_by_name('non-existing') assert cmd.get_params_by_name(['arg1', 'option2']) == (params[0], params[2]) @pytest.mark.parametrize('command_type', ["command", "group"]) @pytest.mark.parametrize('do_check_consistency', [ pytest.param(True, id="with_consistency_checks"), pytest.param(False, id="without_consistency_checks") ]) def test_constraints_are_checked_according_to_protocol( runner, command_type, do_check_consistency ): constraints = [ Mock(spec_set=Constraint, wraps=FakeConstraint()) for _ in range(3) ] settings = Context.settings(check_constraints_consistency=do_check_consistency) command_decorator = cloup.group if command_type == "group" else cloup.command @command_decorator(context_settings=settings) @cloup.option_group('first', cloup.option('--a'), cloup.option('--b'), constraint=constraints[0]) @cloup.option_group('second', cloup.option('--c'), cloup.option('--d'), constraint=constraints[1]) @cloup.constraint(constraints[2], ['a', 'c']) @cloup.pass_context def cmd(ctx, a, b, c, d): assert Constraint.must_check_consistency(ctx) == do_check_consistency print(f'{a}, {b}, {c}, {d}') shell = '--a=1 --c=2' if isinstance(cmd, cloup.Group): cmd.add_command(cloup.Command(name="dummy", callback=lambda: 0)) shell += ' dummy' result = runner.invoke(cmd, args=shell.split()) assert result.output.strip() == '1, None, 2, None' for constr, opt_names in zip(constraints, [['a', 'b'], ['c', 'd'], ['a', 'c']]): opts = cmd.get_params_by_name(opt_names) if do_check_consistency: constr.check_consistency.assert_called_once_with(opts) else: constr.check_consistency.assert_not_called() constr.check_values.assert_called_once() @pytest.mark.parametrize('command_type', ["command", "group"]) @pytest.mark.parametrize( 'cmd_value', [MISSING, None, True, False], ids=lambda val: f'cmd_{val}' ) @pytest.mark.parametrize( 'ctx_value', [MISSING, None, True, False], ids=lambda val: f'ctx_{val}' ) def test_constraints_are_shown_in_help_only_if_feature_is_enabled( runner, command_type, cmd_value, ctx_value ): should_show = pick_first_bool([cmd_value, ctx_value], default=False) cxt_settings = pick_non_missing(dict( show_constraints=ctx_value, terminal_width=80, )) cmd_kwargs = pick_non_missing(dict( show_constraints=cmd_value, context_settings=cxt_settings )) command_decorator = cloup.group if command_type == "group" else cloup.command @command_decorator(**cmd_kwargs) @cloup.option('--a') @cloup.option('--b') @cloup.option('--c') @cloup.constraint(FakeConstraint(help='a constraint'), ['a', 'b']) @cloup.constraint(FakeConstraint(help='another constraint'), ['b', 'c']) def cmd(a, b, c, d): pass if isinstance(cmd, cloup.Group): cmd.add_command(cloup.Command(name="dummy", callback=lambda: 0)) result = runner.invoke(cmd, args=['--help'], catch_exceptions=False, prog_name='test') out = result.output if command_type == "group": if should_show: assert out == reindent(""" Usage: test [OPTIONS] COMMAND [ARGS]... Options: --a TEXT --b TEXT --c TEXT --help Show this message and exit. Constraints: {--a, --b} a constraint {--b, --c} another constraint Commands: dummy """) else: assert out == reindent(""" Usage: test [OPTIONS] COMMAND [ARGS]... Options: --a TEXT --b TEXT --c TEXT --help Show this message and exit. Commands: dummy """) else: base_help = reindent(""" Usage: test [OPTIONS] Options: --a TEXT --b TEXT --c TEXT --help Show this message and exit. """) if should_show: constraints_section = reindent(""" Constraints: {--a, --b} a constraint {--b, --c} another constraint """) expected = base_help + "\n" + constraints_section assert out == expected else: assert out == base_help def test_usage_of_constraints_as_decorators(runner): require_any = RequireAtLeast(1) @cloup.command() @require_any( cloup.argument('arg', required=False), cloup.option('-a'), cloup.option('-b'), ) @mutually_exclusive( cloup.option('-c'), cloup.option('-d'), ) def cmd(arg, a, b, c, d): pass assert runner.invoke(cmd, args='ARG -c CCC'.split()).exit_code == 0 assert runner.invoke(cmd, args='-a AAA -d DDD'.split()).exit_code == 0 res = runner.invoke(cmd, args=[]) assert res.exit_code == click.UsageError.exit_code assert 'at least 1 of the following' in res.output res = runner.invoke(cmd, args='ARG -c CCC -d DDD'.split()) assert res.exit_code == click.UsageError.exit_code assert 'mutually exclusive' in res.output def test_group_constraints_doesnt_prevent_displaying_help_in_subcommand(runner): @cloup.group() @cloup.option_group( "Credentials", require_all(cloup.option("--user"), cloup.option("--password")) ) def cli(user, password): """Top level group text.""" @cli.group() @cloup.option_group( "Required", require_one(cloup.option("--foo"), cloup.option("--bar")) ) def subgroup(foo, bar): """Subgroup help text.""" @subgroup.command() def subcommand(): """Subcommand help text.""" res = runner.invoke(cli, ["subgroup", "subcommand", "--help"]) assert res.exit_code == 0, res.output cloup-3.0.5/tests/example_command.py000066400000000000000000000137171457133754100175310ustar00rootroot00000000000000# flake8: noqa E128 from typing import cast import click import cloup from cloup import Command, HelpFormatter, argument, option, option_group from cloup.constraints import AcceptAtMost, If, RequireAtLeast def make_example_command( align_option_groups: bool, tabular_help: bool = True, ) -> Command: @cloup.command( 'clouptest', align_option_groups=align_option_groups, formatter_settings=HelpFormatter.settings( width=80, col2_min_width=30 if tabular_help else 80, ), epilog='Made with love by Gianluca.' ) @argument("arg_one", help="This is the description of argument #1.") @argument("arg_two", help="This is the description of argument #2.", required=False) @argument("arg_three", required=False) @option_group( 'Option group A', option('--one', help='The one thing you need to run this command.'), option('--two', help='This is long description that should be wrapped into ' 'multiple lines so that the entire text stays inside ' 'the allowed width.'), option('--three', help='The 3rd option of group A.'), help="This is a very useful description of group A. This is a rarely used " "feature but, as the others, needs to be tested. I'm making this " "unnecessarily long in order to test wrapping.", constraint=AcceptAtMost(2) ) @option_group( 'Option group B', 'Help as positional argument.', option('--four / --no-four', help='The 1st option of group B.'), option('--five', help='The 2nd option of group B.', hidden=True), # hidden option option('--six', help='The 3rd option of group B.'), constraint=If('three', then=RequireAtLeast(1)) ) @option('--seven', help='First uncategorized option.', type=click.Choice('yes no ask'.split())) @option('--height', help='Second uncategorized option.') @option('--nine', help='Third uncategorized option.', hidden=True) def cmd(**kwargs): """A CLI that does nothing.""" print(kwargs) if tabular_help: expected_help = (_TABULAR_ALIGNED_HELP if align_option_groups else _TABULAR_NON_ALIGNED_HELP) else: expected_help = _LINEAR_HELP cmd.expected_help = expected_help # type: ignore return cast(Command, cmd) _TABULAR_ALIGNED_HELP = """ Usage: clouptest [OPTIONS] ARG_ONE [ARG_TWO] [ARG_THREE] A CLI that does nothing. Positional arguments: ARG_ONE This is the description of argument #1. [ARG_TWO] This is the description of argument #2. [ARG_THREE] Option group A: [at most 2 accepted] This is a very useful description of group A. This is a rarely used feature but, as the others, needs to be tested. I'm making this unnecessarily long in order to test wrapping. --one TEXT The one thing you need to run this command. --two TEXT This is long description that should be wrapped into multiple lines so that the entire text stays inside the allowed width. --three TEXT The 3rd option of group A. Option group B: [at least 1 required if --three is set] Help as positional argument. --four / --no-four The 1st option of group B. --six TEXT The 3rd option of group B. Other options: --seven [yes|no|ask] First uncategorized option. --height TEXT Second uncategorized option. --help Show this message and exit. Made with love by Gianluca. """.strip() _TABULAR_NON_ALIGNED_HELP = """ Usage: clouptest [OPTIONS] ARG_ONE [ARG_TWO] [ARG_THREE] A CLI that does nothing. Positional arguments: ARG_ONE This is the description of argument #1. [ARG_TWO] This is the description of argument #2. [ARG_THREE] Option group A: [at most 2 accepted] This is a very useful description of group A. This is a rarely used feature but, as the others, needs to be tested. I'm making this unnecessarily long in order to test wrapping. --one TEXT The one thing you need to run this command. --two TEXT This is long description that should be wrapped into multiple lines so that the entire text stays inside the allowed width. --three TEXT The 3rd option of group A. Option group B: [at least 1 required if --three is set] Help as positional argument. --four / --no-four The 1st option of group B. --six TEXT The 3rd option of group B. Other options: --seven [yes|no|ask] First uncategorized option. --height TEXT Second uncategorized option. --help Show this message and exit. Made with love by Gianluca. """.strip() _LINEAR_HELP = """ Usage: clouptest [OPTIONS] ARG_ONE [ARG_TWO] [ARG_THREE] A CLI that does nothing. Positional arguments: ARG_ONE This is the description of argument #1. [ARG_TWO] This is the description of argument #2. [ARG_THREE] Option group A: [at most 2 accepted] This is a very useful description of group A. This is a rarely used feature but, as the others, needs to be tested. I'm making this unnecessarily long in order to test wrapping. --one TEXT The one thing you need to run this command. --two TEXT This is long description that should be wrapped into multiple lines so that the entire text stays inside the allowed width. --three TEXT The 3rd option of group A. Option group B: [at least 1 required if --three is set] Help as positional argument. --four / --no-four The 1st option of group B. --six TEXT The 3rd option of group B. Other options: --seven [yes|no|ask] First uncategorized option. --height TEXT Second uncategorized option. --help Show this message and exit. Made with love by Gianluca. """.strip() if __name__ == '__main__': make_example_command(align_option_groups=False, tabular_help=True)( ['--help'], prog_name='clouptest' ) cloup-3.0.5/tests/example_group.py000066400000000000000000000067231457133754100172460ustar00rootroot00000000000000# flake8: noqa E128 import cloup from cloup import Section, argument, option, option_group def make_example_group(align_sections): def f(**kwargs): print(**kwargs) git_clone = cloup.command( 'clone', help='Clone a repository into a new directory')(f) git_hidden1 = cloup.command( 'hidden1', hidden=True)(f) git_init = cloup.command( 'init', help='Create an empty Git repository or reinitialize an existing one')(f) git_rm = cloup.command( 'rm', help='Remove files from the working tree and from the index')(f) git_sparse_checkout = cloup.command( 'sparse-checkout', help='Initialize and modify the sparse-checkout')(f) git_mv = cloup.command( 'mv', help='Move or rename a file, a directory, or a symlink')(f) @cloup.group( 'git', align_sections=align_sections, align_option_groups=align_sections, context_settings={'terminal_width': 80}, ) @option_group( "Useful options", option("-C", type=cloup.Path(), help="Configuration file."), option("-p", "--paginate", is_flag=True, help="Paginate output."), ) def git(): return 0 # We'll add commands/sections in all possible ways first_section = git.section( 'Start a working area (see also: git help tutorial)', git_init, git_hidden1) first_section.add_command(git_clone) git.add_section(Section( 'Work on the current change (see also: git help everyday)', [git_rm, git_sparse_checkout, git_mv], is_sorted=True )) git.add_command(cloup.command('fake-3', hidden=True)(f)) git.add_command(cloup.command('fake-2', help='Fake command #2')(f)) git.add_command(cloup.command('fake-1', help='Fake command #1')(f)) git.expected_help = (EXPECTED_ALIGNED_HELP if align_sections else EXPECTED_NON_ALIGNED_HELP) return git EXPECTED_ALIGNED_HELP = """ Usage: git [OPTIONS] COMMAND [ARGS]... Useful options: -C PATH Configuration file. -p, --paginate Paginate output. Other options: --help Show this message and exit. Start a working area (see also: git help tutorial): init Create an empty Git repository or reinitialize an existing... clone Clone a repository into a new directory Work on the current change (see also: git help everyday): mv Move or rename a file, a directory, or a symlink rm Remove files from the working tree and from the index sparse-checkout Initialize and modify the sparse-checkout Other commands: fake-1 Fake command #1 fake-2 Fake command #2 """.strip() EXPECTED_NON_ALIGNED_HELP = """ Usage: git [OPTIONS] COMMAND [ARGS]... Useful options: -C PATH Configuration file. -p, --paginate Paginate output. Other options: --help Show this message and exit. Start a working area (see also: git help tutorial): init Create an empty Git repository or reinitialize an existing one clone Clone a repository into a new directory Work on the current change (see also: git help everyday): mv Move or rename a file, a directory, or a symlink rm Remove files from the working tree and from the index sparse-checkout Initialize and modify the sparse-checkout Other commands: fake-1 Fake command #1 fake-2 Fake command #2 """.strip() if __name__ == '__main__': make_example_group(align_sections=False)(['--help'], prog_name='git') cloup-3.0.5/tests/test_aliases.py000066400000000000000000000140761457133754100170570ustar00rootroot00000000000000from typing import Optional import click import pytest import cloup from cloup import Color, Group, HelpTheme, Style from cloup._util import first_bool, identity, reindent from cloup.styling import IStyle from cloup.typing import MISSING @pytest.fixture(params=["section", "init_arg"]) def cli(request) -> cloup.Group: @cloup.command(aliases=['i', 'add']) @cloup.argument('pkg') def install(pkg: str): """Install a package.""" print('install', pkg) @cloup.group(aliases=['conf', 'cfg']) def config(): """Manage the configuration.""" print('config') @cloup.command(aliases=['clr'], cls=click.Command) def clear(): """Remove all installed packages.""" print('clear') if request.param == "section": @cloup.group() def cli(): """A package installer.""" cli.section("Commands", install, clear, config, is_sorted=True) elif request.param == "init_arg": cli = Group( name="cli", help="A package installer.", commands=[install, clear, config], ) else: raise ValueError(request.param) return cli cli_help_without_aliases = reindent(""" Usage: cli [OPTIONS] COMMAND [ARGS]... A package installer. Options: --help Show this message and exit. Commands: clear Remove all installed packages. config Manage the configuration. install Install a package. """) cli_help_with_aliases = reindent(""" Usage: cli [OPTIONS] COMMAND [ARGS]... A package installer. Options: --help Show this message and exit. Commands: clear (clr) Remove all installed packages. config (conf, cfg) Manage the configuration. install (i, add) Install a package. """) def test_command_aliases_are_stored_in_the_command(cli): assert cli.commands['install'].aliases == ['i', 'add'] assert cli.commands['clear'].aliases == ['clr'] def test_simple_command_name_resolution(cli): ctx = cloup.Context(command=cli) assert cli.resolve_command_name(ctx, 'install') == 'install' assert cli.resolve_command_name(ctx, 'i') == 'install' assert cli.resolve_command_name(ctx, 'add') == 'install' assert cli.resolve_command_name(ctx, 'config') == 'config' assert cli.resolve_command_name(ctx, 'conf') == 'config' assert cli.resolve_command_name(ctx, 'clear') == 'clear' assert cli.resolve_command_name(ctx, 'clr') == 'clear' def test_command_name_resolution_with_token_normalization_function(cli): ctx = cloup.Context(command=cli, token_normalize_func=str.lower) assert cli.resolve_command_name(ctx, 'INSTALL') == 'install' assert cli.resolve_command_name(ctx, 'ADD') == 'install' assert cli.resolve_command_name(ctx, 'CLR') == 'clear' assert cli.resolve_command_name(ctx, 'cONF') == 'config' @pytest.mark.parametrize('alias', ['install', 'i', 'add']) def test_command_resolution_with_cloup_subcommand(cli, runner, alias): res = runner.invoke(cli, [alias, 'cloup']) assert res.output.strip() == 'install cloup' @pytest.mark.parametrize('alias', ['clear', 'clr']) def test_command_resolution_with_click_subcommand(cli, runner, alias): res = runner.invoke(cli, [alias]) assert res.output.strip() == 'clear' @pytest.mark.parametrize( 'cmd_value', [MISSING, None, True, False], ids=lambda val: f'cmd_{val}' ) @pytest.mark.parametrize( 'ctx_value', [MISSING, None, True, False], ids=lambda val: f'ctx_{val}' ) def test_show_subcommand_aliases_setting(cli, runner, ctx_value, cmd_value): if ctx_value is not MISSING: cli.context_settings['show_subcommand_aliases'] = ctx_value if cmd_value is not MISSING: cli.show_subcommand_aliases = cmd_value should_show_aliases = first_bool(cmd_value, ctx_value, Group.SHOW_SUBCOMMAND_ALIASES) expected_help = (cli_help_with_aliases if should_show_aliases else cli_help_without_aliases) res = runner.invoke(cli, ['--help']) assert res.output == expected_help def test_cloup_subcommand_help(cli, runner): res = runner.invoke(cli, ['i', '--help']) # 1. Shows the full subcommand name even if an alias was used. # 2. Shows aliases after help text. expected = reindent(""" Usage: cli install [OPTIONS] PKG Aliases: i, add Install a package. Options: --help Show this message and exit. """) assert res.output == expected def test_click_subcommand_help(cli, runner): res = runner.invoke(cli, ['clr', '--help']) # Shows the full subcommand name even if an alias was used. # Aliases are not shown (need Cloup commands for that). expected = reindent(""" Usage: cli clear [OPTIONS] Remove all installed packages. Options: --help Show this message and exit. """) assert res.output == expected def test_cloup_subgroup_help(cli, runner): res = runner.invoke(cli, ['conf', '--help']) # 1. Shows the full subcommand name even if an alias was used. # 2. Shows aliases after help text. expected = reindent(""" Usage: cli config [OPTIONS] COMMAND [ARGS]... Aliases: conf, cfg Manage the configuration. Options: --help Show this message and exit. """) assert res.output == expected def test_alias_are_correctly_styled(runner): red = Style(fg=Color.red) green = Style(fg=Color.green) def fmt(alias: IStyle = identity, alias_secondary: Optional[IStyle] = None): theme = HelpTheme(alias=alias, alias_secondary=alias_secondary) return Group.format_subcommand_aliases(["i", "add"], theme) # No styles (default theme) assert fmt() == "(i, add)" # Only theme.alias assert fmt(alias=green) == f"{green('(i, add)')}" # Only theme.alias_secondary assert fmt(alias_secondary=green) == ( green("(") + "i" + green(", ") + "add" + green(")") ) # Both assert fmt(alias=red, alias_secondary=green) == ( green("(") + red("i") + green(", ") + red("add") + green(")") ) cloup-3.0.5/tests/test_commands.py000066400000000000000000000116731457133754100172370ustar00rootroot00000000000000import re import click import pytest import cloup from cloup._util import reindent from tests.util import new_dummy_func def test_command_handling_of_unknown_argument(): with pytest.raises(TypeError, match='Hint: you set `cls='): cloup.command(cls=click.Command, align_option_groups=True)(new_dummy_func()) with pytest.raises(TypeError, match='nonexisting') as info: cloup.command(nonexisting=True)(new_dummy_func()) assert re.search(str(info.value), 'Hint') is None def test_group_raises_if_cls_is_not_subclass_of_click_Group(): cloup.group() cloup.group(cls=click.Group) cloup.group(cls=cloup.Group) with pytest.raises(TypeError): cloup.group(cls=click.Command) def test_group_handling_of_unknown_argument(): with pytest.raises(TypeError, match='Hint'): cloup.group(cls=click.Group, align_sections=True)(new_dummy_func()) with pytest.raises(TypeError) as info: cloup.group(unexisting_arg=True)(new_dummy_func()) assert re.search(str(info.value), 'Hint') is None def test_command_works_with_no_parameters(runner): cmd = cloup.Command(name='cmd', callback=new_dummy_func()) res = runner.invoke(cmd, '--help') assert res.output == reindent(""" Usage: cmd [OPTIONS] Options: --help Show this message and exit. """) def test_group_works_with_no_params_and_subcommands(runner): cmd = cloup.Group(name='cmd') res = runner.invoke(cmd, '--help') assert res.output == reindent(""" Usage: cmd [OPTIONS] COMMAND [ARGS]... Options: --help Show this message and exit. """) class TestDidYouMean: @pytest.fixture(scope="class") def cmd(self): cmd = cloup.Group(name="cmd") subcommands = [ ('install', ['ins']), ('remove', ['rm']), ('clear', []) ] for name, aliases in subcommands: cmd.add_command( cloup.Command(name=name, aliases=aliases, callback=new_dummy_func())) return cmd def test_with_no_matches(self, runner, cmd): res = runner.invoke(cmd, 'asdfdsgdfgdf') assert res.output == reindent(""" Usage: cmd [OPTIONS] COMMAND [ARGS]... Try 'cmd --help' for help. Error: No such command 'asdfdsgdfgdf'. """) def test_with_one_match(self, runner, cmd): res = runner.invoke(cmd, 'clearr') assert res.output == reindent(""" Usage: cmd [OPTIONS] COMMAND [ARGS]... Try 'cmd --help' for help. Error: No such command 'clearr'. Did you mean 'clear'? """) def test_with_multiple_matches(self, runner, cmd): res = runner.invoke(cmd, 'inst') assert res.output == reindent(""" Usage: cmd [OPTIONS] COMMAND [ARGS]... Try 'cmd --help' for help. Error: No such command 'inst'. Did you mean one of these? ins install """) @pytest.mark.parametrize("decorator", [cloup.command, cloup.group]) def test_error_is_raised_when_command_decorators_are_used_without_parenthesis(decorator): with pytest.raises(Exception, match="parenthesis"): @decorator def cmd(): pass def test_error_is_raised_when_group_subcommand_decorators_are_used_without_parenthesis(): @cloup.group() def root(): pass with pytest.raises(Exception, match="parenthesis"): @root.group def subgroup(): pass with pytest.raises(Exception, match="parenthesis"): @root.command def subcommand(): pass def test_group_command_class_is_used_to_create_subcommands(runner): class CustomCommand(cloup.Command): def __init__(self, *args, **kwargs): kwargs.setdefault("context_settings", {"help_option_names": ("--help", "-h")}) super().__init__(*args, **kwargs) class CustomGroup(cloup.Group): command_class = CustomCommand @cloup.group("cli", cls=CustomGroup) def my_cli(): pass @my_cli.command() def subcommand(): pass assert isinstance(subcommand, CustomCommand) res = runner.invoke(my_cli, ["subcommand", "--help"]) assert res.output == reindent(""" Usage: cli subcommand [OPTIONS] Options: -h, --help Show this message and exit. """) def test_group_class_is_used_to_create_subgroups(runner): class CustomGroup(cloup.Group): group_class = type class OtherCustomGroup(cloup.Group): group_class = cloup.Group @cloup.group("cli", cls=CustomGroup) def my_cli(): pass @my_cli.group() def sub_group(): pass @my_cli.group(cls=OtherCustomGroup) def other_group(): pass @other_group.group() def other_sub_group(): pass assert isinstance(sub_group, CustomGroup) assert isinstance(other_group, OtherCustomGroup) assert isinstance(other_sub_group, cloup.Group) cloup-3.0.5/tests/test_context.py000066400000000000000000000036531457133754100171210ustar00rootroot00000000000000from unittest.mock import Mock import pytest import cloup from cloup import Context from tests.util import parametrize @parametrize( ['ctx_arg_name', 'formatter_arg_name'], pytest.param('terminal_width', 'width', id='width'), pytest.param('max_content_width', 'max_width', id='max_width'), ) @parametrize( ['ctx_arg_value', 'formatter_arg_value', 'should_warn'], pytest.param(80, None, False, id='only_ctx'), pytest.param(None, 90, False, id='only_formatter'), pytest.param(80, 90, True, id='both'), ) def test_warning_is_raised_iff_arg_is_provided_both_as_context_and_formatter_arg( ctx_arg_name, formatter_arg_name, ctx_arg_value, formatter_arg_value, should_warn, recwarn ): kwargs = { ctx_arg_name: ctx_arg_value, "formatter_settings": {formatter_arg_name: formatter_arg_value} } Context(command=Mock(), **kwargs) if should_warn: assert len(recwarn) == 1 warning = recwarn.pop(UserWarning) assert warning.category is UserWarning assert 'You provided both' in str(warning.message) else: assert len(recwarn) == 0 @parametrize( ['ctx_arg_name', 'formatter_arg_name'], pytest.param('terminal_width', 'width', id='width'), pytest.param('max_content_width', 'max_width', id='max_width'), ) def test_warning_suppression(ctx_arg_name, formatter_arg_name, recwarn): kwargs = { ctx_arg_name: 80, "formatter_settings": {formatter_arg_name: 90} } cloup.warnings.formatter_settings_conflict = False Context(command=Mock(), **kwargs) cloup.warnings.formatter_settings_conflict = True assert len(recwarn) == 0 def test_context_settings_creation(): assert Context.settings() == {} assert Context.settings( resilient_parsing=False, align_sections=True, formatter_settings={'width': 80} ) == dict( resilient_parsing=False, align_sections=True, formatter_settings={'width': 80} ) cloup-3.0.5/tests/test_formatting.py000066400000000000000000000203221457133754100175770ustar00rootroot00000000000000""" Tip: in your editor, set a ruler at 80 characters. """ import inspect from textwrap import dedent from typing import Optional import click import pytest from cloup import HelpFormatter from cloup.formatting import HelpSection, unstyled_len from cloup.formatting.sep import ( Hline, RowSepIf, RowSepPolicy, multiline_rows_are_at_least ) from cloup.styling import HelpTheme, Style from cloup.typing import Possibly from tests.util import parametrize LOREM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor." ROWS = [ ('-l, --long-option-name TEXT', LOREM), ('--another-option INT', LOREM), ('--short', LOREM), ] def test_write_dl_with_col1_max_width_equal_to_longest_col1_value(): formatter = HelpFormatter(width=80, col1_max_width=len(ROWS[0][0])) formatter.current_indent = 4 expected = """ -l, --long-option-name TEXT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --another-option INT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --short Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. """[1:-4] formatter.write_dl(ROWS) assert formatter.getvalue() == expected def test_formatter_excludes_rows_exceeding_col1_max_width_from_col1_width_computation(): formatter = HelpFormatter(width=80, col1_max_width=len('--short')) formatter.current_indent = 4 expected = """ -l, --long-option-name TEXT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --another-option INT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --short Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. """[1:-4] formatter.write_dl(ROWS) assert formatter.getvalue() == expected def test_col2_min_width(): formatter = HelpFormatter(width=80) formatter.current_indent = 4 formatter.col2_min_width = ( formatter.available_width - len(ROWS[0][0]) - formatter.col_spacing) expected = """ -l, --long-option-name TEXT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --another-option INT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --short Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. """[1:-4] formatter.write_dl(ROWS) assert formatter.getvalue() == expected formatter.buffer = [] formatter.col2_min_width += 1 expected = """ -l, --long-option-name TEXT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --another-option INT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --short Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. """[1:-4] formatter.write_dl(ROWS) assert formatter.getvalue() == expected def test_value_error_if_row_sep_string_ends_with_newline(): with pytest.raises(ValueError, match=r"row_sep must not end with '\\n'"): HelpFormatter(row_sep='\n') @parametrize( 'row_sep', pytest.param('-' * (80 - 4), id='string'), pytest.param(Hline.dashed, id='SepGenerator'), ) def test_fixed_row_sep(row_sep): formatter = HelpFormatter(row_sep=row_sep, width=80) formatter.current_indent = 4 expected = """ -l, --long-option-name TEXT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. ---------------------------------------------------------------------------- --another-option INT Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. ---------------------------------------------------------------------------- --short Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. """[1:-4] formatter.write_dl(ROWS) actual = formatter.getvalue() assert actual == expected @parametrize( ['policy', 'expected_sep'], # unless None, expected_sep should end with \n # Three of the test rows are "multi-line" pytest.param( RowSepIf(multiline_rows_are_at_least(3)), '', id='empty_line'), pytest.param( RowSepIf(multiline_rows_are_at_least(3), sep=Hline.dashed), Hline.dashed(80), id='dashed_line'), pytest.param( RowSepIf(multiline_rows_are_at_least(4)), None, id='no_sep'), ) def test_conditional_row_sep(policy: RowSepPolicy, expected_sep: Optional[str]): formatter = HelpFormatter( width=80, col1_max_width=30, col2_min_width=0, col_spacing=3, row_sep=policy, ) rows = [ ('Longest than 30 characters for sure', 'Short help'), ('Longest than 30 characters for sure', 'Another short help'), ('Below 30 characters', LOREM), ('Short help', 'Short help'), ] actual_expected_sep = '' if expected_sep is None else (expected_sep + '\n') expected = """ Longest than 30 characters for sure Short help --- Longest than 30 characters for sure Another short help --- Below 30 characters Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor. --- Short help Short help """[1:-4].replace('---\n', actual_expected_sep) formatter.write_dl(rows) actual = formatter.getvalue() assert actual == expected def test_formatter_settings_creation(): assert HelpFormatter.settings() == {} assert HelpFormatter.settings( indent_increment=4, col_spacing=3 ) == dict(indent_increment=4, col_spacing=3) def test_settings_signature_matches_HelpFormatter(): cls_params = dict(inspect.signature(HelpFormatter).parameters) settings = dict(inspect.signature(HelpFormatter.settings).parameters) assert set(cls_params) == set(settings) # Check type annotations for name, param in cls_params.items(): assert settings[name].annotation == Possibly[cls_params[name].annotation], name def test_write_heading(): formatter = HelpFormatter(theme=HelpTheme( heading=Style(fg='blue', bold=True), )) formatter.write_heading('Title') assert formatter.getvalue() == click.style('Title:', fg='blue', bold=True) + '\n' def test_write_text_with_styles(): formatter = HelpFormatter(width=80) formatter.current_indent = 4 WRAPPED = dedent(""" Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."""[1:]) INPUT_TEXT = ' '.join(WRAPPED.split()) indentation = ' ' * formatter.current_indent style = Style(fg='yellow') EXPECTED = '\n'.join( indentation + style(line) for line in WRAPPED.splitlines() ) formatter.write_text(INPUT_TEXT, style=style) actual = formatter.getvalue().rstrip() for line in actual.splitlines(): assert unstyled_len(line) <= formatter.width assert actual == EXPECTED def test_write_section_print_long_constraint_on_a_new_line(): formatter = HelpFormatter(width=72, indent_increment=4) section = HelpSection( 'The heading', [('term', 'This is the definition.')], help='This is the help.', constraint=""" This is a long constraint description that doesn't fit the line xx. """.strip() ) expected = dedent(""" The heading: [This is a long constraint description that doesn't fit the line xx.] This is the help. term This is the definition. """) formatter.write_section(section) actual = formatter.getvalue() assert actual == expected cloup-3.0.5/tests/test_option_groups.py000066400000000000000000000205631457133754100203430ustar00rootroot00000000000000"""Test for the "option groups" feature/module.""" from textwrap import dedent from typing import cast import click import pytest from click import pass_context import cloup from cloup import OptionGroup, option, option_group from cloup._util import pick_non_missing, reindent from cloup.constraints import RequireAtLeast, mutually_exclusive from cloup.typing import MISSING from tests.util import (make_options, new_dummy_func, parametrize, pick_first_bool) def test_error_message_if_first_arg_is_not_a_string(): with pytest.raises( TypeError, match="the first argument of `@option_group` must be its title" ): @option_group( option('--one'), option('--two'), ) def f(one, two): pass @parametrize( ['tabular_help', 'align_option_groups'], pytest.param(True, True, id='tabular-aligned'), pytest.param(True, False, id='tabular-non_aligned'), pytest.param(False, None, id='linear'), ) def test_option_groups_are_correctly_displayed_in_help( runner, tabular_help, align_option_groups, get_example_command ): cmd = get_example_command( tabular_help=tabular_help, align_option_groups=align_option_groups ) result = runner.invoke(cmd, args=('--help',)) assert result.exit_code == 0 assert result.output.strip() == cmd.expected_help def test_option_group_constraints_are_checked(runner, get_example_command): cmd = get_example_command(align_option_groups=False) result = runner.invoke(cmd, args='arg1 --one=1') assert result.exit_code == 0 result = runner.invoke(cmd, args='arg1 --one=1 --three=3 --five=4') assert result.exit_code == 0 result = runner.invoke(cmd, args='arg1 --one=1 --three=3') assert result.exit_code == 2 error_prefix = ('Error: when --three is set, at least 1 of the following ' 'parameters must be set') assert error_prefix in result.output def test_option_group_decorator_raises_if_group_is_passed_to_contained_option(): with pytest.raises(ValueError): @option_group( 'a group', cloup.option('--opt', group=OptionGroup('another group')) ) def f(opt): pass def test_option_group_decorator_raises_for_no_options(): with pytest.raises(ValueError): cloup.option_group('grp') @pytest.mark.parametrize( 'cmd_value', [MISSING, None, True, False], ids=lambda val: f'cmd_{val}' ) @pytest.mark.parametrize( 'ctx_value', [MISSING, None, True, False], ids=lambda val: f'ctx_{val}' ) def test_align_option_groups_context_setting(runner, ctx_value, cmd_value): should_align = pick_first_bool([cmd_value, ctx_value], default=True) cxt_settings = pick_non_missing(dict( align_option_groups=ctx_value, terminal_width=80, )) cmd_kwargs = pick_non_missing(dict( align_option_groups=cmd_value, context_settings=cxt_settings )) @cloup.command(**cmd_kwargs) @cloup.option_group('First group', option('--opt', help='first option')) @cloup.option_group('Second group', option('--much-longer-opt', help='second option')) @pass_context def cmd(ctx, one, much_longer_opt): assert cmd.must_align_groups(ctx) == should_align result = runner.invoke(cmd, args=('--help',)) start = result.output.find('First') if should_align: expected = """ First group: --opt TEXT first option Second group: --much-longer-opt TEXT second option Other options: --help Show this message and exit.""" else: expected = """ First group: --opt TEXT first option Second group: --much-longer-opt TEXT second option Other options: --help Show this message and exit.""" expected = dedent(expected).strip() end = start + len(expected) assert result.output[start:end] == expected def test_context_settings_propagate_to_children(runner): @cloup.group(context_settings=dict(align_option_groups=False)) def grp(): pass @grp.command() @pass_context def cmd(ctx): assert cmd.must_align_option_groups(ctx) is False runner.invoke(grp, ('cmd',)) def test_that_neither_optgroup_nor_its_options_are_shown_if_optgroup_is_hidden(runner): @cloup.command('name') @cloup.option_group( 'Hidden group', cloup.option('--one'), hidden=True ) def cmd(): pass result = runner.invoke(cmd, args=('--help',), catch_exceptions=False) assert 'Hidden group' not in result.output assert '--one' not in result.output def test_that_optgroup_is_hidden_if_all_its_options_are_hidden(runner): @cloup.command('name') @cloup.option_group( 'Hidden group', cloup.option('--one', hidden=True), cloup.option('--two', hidden=True), ) def cmd(): pass assert cmd.option_groups[0].hidden result = runner.invoke(cmd, args=('--help',), catch_exceptions=False) assert 'Hidden group' not in result.output def test_option_group_options_setter_set_the_hidden_attr_of_options(): opts = make_options('abc') group = OptionGroup('name') group.options = opts assert not any(opt.hidden for opt in opts) group.hidden = True group.options = opts assert all(opt.hidden for opt in opts) def test_option_group_with_constrained_subgroups(runner): @cloup.command() @option_group( "Some options", RequireAtLeast(1)( option('-a', is_flag=True), option('-b', is_flag=True) ), mutually_exclusive( option('-c', is_flag=True), option('-d', is_flag=True), ), option('-e', is_flag=True), ) def cmd(a, b, c, d, e): pass cmd = cast(cloup.Command, cmd) assert len(cmd.option_groups) == 1 assert len(cmd.option_groups[0]) == 5 assert runner.invoke(cmd, ['-abc']).exit_code == 0 assert 'Error: at least 1' in runner.invoke(cmd, ['-c']).output assert 'mutually exclusive' in runner.invoke(cmd, ['-acd']).output expected_help = dedent(""" Usage: cmd [OPTIONS] Some options: -a -b -c -d -e Other options: --help Show this message and exit. """).lstrip() actual_help = runner.invoke(cmd, ['--help']).output assert actual_help == expected_help def test_usage_of_constraints_as_decorators_inside_option_group(runner): @cloup.command() @cloup.option_group( "Options", mutually_exclusive( cloup.option('-a', is_flag=True), cloup.option('-b', is_flag=True), cloup.option('-c', is_flag=True), ), cloup.option('-d', is_flag=True), ) def cmd(a, b, c, d): pass expected_help = dedent(""" Usage: cmd [OPTIONS] Options: -a -b -c -d Other options: --help Show this message and exit. """).lstrip() res = runner.invoke(cmd, args=['--help']) assert res.output == expected_help assert runner.invoke(cmd, args=[]).exit_code == 0 assert runner.invoke(cmd, args='-d'.split()).exit_code == 0 assert runner.invoke(cmd, args='-ad'.split()).exit_code == 0 res = runner.invoke(cmd, args='-ab'.split()) assert res.exit_code == click.UsageError.exit_code assert 'mutually exclusive' in res.output def test_option_groups_raises_if_input_decorator_add_an_argument(): with pytest.raises( TypeError, match='only parameter of type `Option` can be added to option groups' ): option_group( 'Title', mutually_exclusive( cloup.argument('arg'), cloup.option('--opt') ) )(new_dummy_func()) def test_default_option_group_title_when_the_only_other_section_is_positional_arguments( runner ): @cloup.command() @cloup.argument("arg", help="An argument.") @cloup.option("--opt", help="An option.") def cmd(**kwargs): pass res = runner.invoke(cmd, ["--help"]) assert res.output == reindent(""" Usage: cmd [OPTIONS] ARG Positional arguments: ARG An argument. Options: --opt TEXT An option. --help Show this message and exit. """) cloup-3.0.5/tests/test_sections.py000066400000000000000000000111611457133754100172550ustar00rootroot00000000000000"""Test for the "subcommand sections" feature/module.""" import click import pytest from click import pass_context import cloup from cloup import Section from cloup._util import pick_non_missing, reindent from cloup.typing import MISSING from tests.util import new_dummy_func, pick_first_bool @pytest.mark.parametrize( 'align_sections', [True, False], ids=['aligned', 'non-aligned'] ) def test_subcommand_sections_are_correctly_rendered_in_help( runner, align_sections, get_example_group ): grp = get_example_group(align_sections) result = runner.invoke(grp, args=('--help',)) if result.exception: raise result.exception assert result.exit_code == 0 assert result.output.strip() == grp.expected_help @pytest.mark.parametrize( 'subcommand_cls', [click.Command, cloup.Command, click.Group, cloup.Group], ids=['click_Command', 'cloup_Command', 'click_Group', 'cloup_Group'], ) @pytest.mark.parametrize( 'assign_to_section', [True, False], ids=['with_section', 'without_section'], ) def test_Group_subcommand_decorator(subcommand_cls, assign_to_section): grp = cloup.Group('name') # Use @grp.group if subcommand_class is a Group, else @grp.Command method_name = ('group' if issubclass(subcommand_cls, cloup.Group) else 'command') decorator = getattr(grp, method_name) # Add a subcommand to the Group using the decorator subcommand_name = 'cmd' section_arg = Section('title') if assign_to_section else None subcommand = decorator( subcommand_name, section=section_arg, cls=subcommand_cls, help='Help' )(new_dummy_func()) assert subcommand.__class__ is subcommand_cls assert subcommand.help == 'Help' assert grp.commands[subcommand_name] is subcommand if assign_to_section: section = grp._user_sections[0] assert section is section_arg assert section.commands[subcommand_name] is subcommand else: assert grp._default_section.commands[subcommand_name] is subcommand @pytest.mark.parametrize( 'cmd_value', [MISSING, None, True, False], ids=lambda val: f'cmd_{val}' ) @pytest.mark.parametrize( 'ctx_value', [MISSING, None, True, False], ids=lambda val: f'ctx_{val}' ) def test_align_sections_context_setting(runner, ctx_value, cmd_value): should_align = pick_first_bool([cmd_value, ctx_value], default=True) cxt_settings = pick_non_missing(dict( align_sections=ctx_value, terminal_width=80, )) cmd_kwargs = pick_non_missing(dict( align_sections=cmd_value, context_settings=cxt_settings )) @cloup.group(**cmd_kwargs) @pass_context def cmd(ctx, one, much_longer_opt): assert cmd.must_align_sections(ctx) == should_align cmd.section( "First section", cloup.command('cmd', help='First command help')(new_dummy_func()), ) cmd.section( "Second section", cloup.command('longer-cmd', help='Second command help')(new_dummy_func()), ) result = runner.invoke(cmd, args=('--help',)) start = result.output.find('First section') if should_align: expected = """ First section: cmd First command help Second section: longer-cmd Second command help""" else: expected = """ First section: cmd First command help Second section: longer-cmd Second command help""" expected = reindent(expected) end = start + len(expected) assert result.output[start:end] == expected def test_override_format_subcommand_name(runner): class MyGroup(cloup.Group): def format_subcommand_name(self, ctx, name, cmd) -> str: return '*special*' if name == 'special' else name main = MyGroup(name='main') main.section( 'Commands', cloup.Command(name='special', help='A special command.'), cloup.Command(name='ordinary', help='An ordinary command.') ) res = runner.invoke(main, ['--help']) expected_help = reindent(""" Usage: main [OPTIONS] COMMAND [ARGS]... Options: --help Show this message and exit. Commands: *special* A special command. ordinary An ordinary command. """) assert res.output == expected_help def test_section_error_if_first_arg_is_not_a_string(): with pytest.raises(TypeError, match="the first argument must be a string"): Section([cloup.Command('cmd')]) grp = cloup.Group() with pytest.raises(TypeError, match="the first argument must be a string"): grp.section([cloup.Command('cmd')]) cloup-3.0.5/tests/test_sep.py000066400000000000000000000047261457133754100162260ustar00rootroot00000000000000from functools import partial import pytest from cloup.formatting.sep import ( Hline, RowSepIf, count_multiline_rows, multiline_rows_are_at_least ) # Use the same widths for both columns cols_width = 30 col_widths = (cols_width, cols_width) col_spacing = 2 # We'll use these in many tests (a = above_limit, b = below_limit) above_limit = 'a' * (cols_width + 1) below_limit = 'b' * cols_width aa = (above_limit, above_limit) ab = (above_limit, below_limit) ba = (below_limit, above_limit) bb = (below_limit, below_limit) def test_count_multiline_rows(): count = partial(count_multiline_rows, col_widths=col_widths) assert count([bb]) == 0 assert count([ba]) == 1 assert count([ab]) == 1 assert count([aa]) == 1 assert count([bb, ba, ab, aa]) == 3 with pytest.raises(Exception): count([tuple('1234')]) # len(row) > len(col_widths) class TestMultilineRowsAreAtLeast: @pytest.mark.parametrize('bad_value', [0, 0.0, -1, -1.4, 1.1, 11.0]) def test_args_validation(self, bad_value): with pytest.raises(ValueError): multiline_rows_are_at_least(bad_value) def test_with_count(self): at_least_2_multiline_rows = partial(multiline_rows_are_at_least(2), col_widths=(30, 30), col_spacing=2) assert at_least_2_multiline_rows([ab, bb, bb, ba]) assert at_least_2_multiline_rows([ab, bb, bb, ba, aa]) assert not at_least_2_multiline_rows([bb, bb, bb]) def test_with_percentage(self): at_least_half_multiline_rows = partial(multiline_rows_are_at_least(.5), col_widths=(30, 30), col_spacing=2) assert at_least_half_multiline_rows([ bb, bb, bb, aa, ab, ba, ]) assert at_least_half_multiline_rows([ bb, bb, bb, aa, ab, ba, aa, ab ]) assert not at_least_half_multiline_rows([ bb, bb, bb, bb, aa, ab, ba, ]) assert not at_least_half_multiline_rows([bb] * 5) def test_value_error_if_sep_string_ends_with_newline(): with pytest.raises(ValueError, match=r"sep must not end with '\\n'"): RowSepIf(multiline_rows_are_at_least(1), sep='\n') def test_Hline(): assert Hline.solid(5) == "─────" assert Hline.dashed(5) == "-----" assert Hline.densely_dashed(5) == "╌╌╌╌╌" assert Hline.dotted(5) == "┄┄┄┄┄" assert Hline('-.')(5) == '-.-.-' cloup-3.0.5/tests/test_styling.py000066400000000000000000000033561457133754100171260ustar00rootroot00000000000000import inspect import click import pytest from cloup.styling import Color, HelpTheme, Style def test_help_theme_copy_with(): s1, s2 = Style(), Style() r1, r2 = Style(), Style() theme = HelpTheme(heading=s1, col1=s2).with_(col1=r1, col2=r2) assert theme == HelpTheme(heading=s1, col1=r1, col2=r2) def test_help_theme_copy_with_takes_the_same_parameters_of_constructor(): def get_param_names(func): params = inspect.signature(func).parameters return list(params.keys()) constructor_params = get_param_names(HelpTheme) method_params = get_param_names(HelpTheme.with_)[1:] # skip self assert method_params == constructor_params def test_help_theme_default_themes(): assert isinstance(HelpTheme.dark(), HelpTheme) assert isinstance(HelpTheme.light(), HelpTheme) def test_style(): text = 'hi there' kwargs = dict(fg=Color.green, bold=True, blink=True) assert Style(**kwargs)(text) == click.style(text, **kwargs) def test_unsupported_style_args_are_ignored_in_click_7(): Style(overline=True, italic=True, strikethrough=True) def test_color_class(): # Check values of some attributes assert Color.red == 'red' assert Color.bright_blue == 'bright_blue' # Check it's not instantiable with pytest.raises(Exception, match="it's not instantiable"): Color() # Check only __dunder__ fields are settable Color.__annotations__ = "whatever" with pytest.raises(Exception, match="you can't set attributes on this class"): Color.red = 'blue' # Test __contains__ assert 'red' in Color assert 'pippo' not in Color # Test Color.asdict() d = Color.asdict() for k, v in d.items(): assert k in Color assert Color[k] == v cloup-3.0.5/tests/test_types.py000066400000000000000000000007541457133754100166000ustar00rootroot00000000000000import pathlib import click import cloup def test_path(): p = cloup.path() assert isinstance(p, click.Path) assert p.type == pathlib.Path def test_dir_path(): p = cloup.dir_path() assert isinstance(p, click.Path) assert p.type == pathlib.Path assert not p.file_okay assert p.dir_okay def test_file_path(): p = cloup.file_path() assert isinstance(p, click.Path) assert p.type == pathlib.Path assert not p.dir_okay assert p.file_okay cloup-3.0.5/tests/test_util.py000066400000000000000000000025731457133754100164120ustar00rootroot00000000000000import pytest from cloup._util import check_positive_int, coalesce, first_bool, make_repr def test_make_repr(): expected_repr = "list('arg', name='Alan', surname='Turing')" n = len(expected_repr) r = make_repr([], 'arg', name='Alan', surname='Turing', _line_len=n) assert r == expected_repr r = make_repr([], 'arg', name='Alan', surname='Turing', _line_len=n - 1, _indent=3) assert r == ("list(\n" " 'arg',\n" " name='Alan',\n" " surname='Turing'\n" ")") def test_check_positive_int(): check_positive_int(1, 'name') with pytest.raises(ValueError): check_positive_int(0, 'name') with pytest.raises(ValueError): check_positive_int(-1, 'name') with pytest.raises(TypeError): check_positive_int(None, 'name') with pytest.raises(TypeError): check_positive_int(1.23, 'name') def test_first_bool(): assert first_bool(0, 1, None, '', [], False, True) is False assert first_bool(0, 1, None, '', [], True, False) is True with pytest.raises(StopIteration): first_bool(1, 2, '', [], None) def test_coalesce(): assert coalesce() is None assert coalesce(None, None, None) is None for expected in [0, '', [], False, 123]: assert coalesce(None, None, expected, True, 12) == expected cloup-3.0.5/tests/util.py000066400000000000000000000036141457133754100153500ustar00rootroot00000000000000from contextlib import contextmanager from typing import Iterable, List from unittest.mock import Mock import click import pytest import cloup from cloup import Context def pick_first_bool(args: Iterable, *, default: bool) -> bool: return next((arg for arg in args if isinstance(arg, bool)), default) def new_dummy_func(): return lambda *args, **kwargs: 1 def int_opt(*args, **kwargs): return click.Option(*args, type=int, **kwargs) def bool_opt(*args, **kwargs): return click.Option(*args, type=bool, **kwargs) def flag_opt(*args, **kwargs): return click.Option(*args, is_flag=True, **kwargs) def multi_opt(*args, **kwargs): return click.Option(*args, multiple=True, **kwargs) def tuple_opt(*args, **kwargs): return click.Option(*args, nargs=3, **kwargs) def parametrize(argnames, *argvalues, **kwargs): return pytest.mark.parametrize(argnames, argvalues, **kwargs) def make_context(cmd: click.Command, shell: str) -> click.Context: args = shell.split() return cmd.make_context(cmd.name, args) def make_fake_context( params: Iterable[click.Parameter], command_cls=cloup.Command, cls=Context, **ctx_kwargs ) -> Context: """Create a simple instance of Command with the specified parameters, then create a fake context without actually invoking the command.""" return cls( command_cls('fake', params=params, callback=new_dummy_func()), **ctx_kwargs ) def make_options(names: Iterable[str], **common_kwargs) -> List[click.Option]: return [click.Option([f'--{name}'], **common_kwargs) for name in names] def should_raise(expected_exception, *, when, **kwargs): if when: return pytest.raises(expected_exception, **kwargs) @contextmanager def manager(): yield return manager() def mock_repr(value, *args, **kwargs): m = Mock(*args, **kwargs) m.__repr__ = lambda x: value return m cloup-3.0.5/tox.ini000066400000000000000000000021611457133754100141660ustar00rootroot00000000000000[tox] envlist = lint mypy twine py{37,38,39,310,311}-click8 report docs [tool:pytest] addopts = --basetemp={envtmpdir} [testenv] setenv = PYTHONPATH = {toxinidir} deps = -r requirements/test.in click8: click >=8, <9 commands = pytest {posargs:-vv} depends = report: py39-click8 [testenv:py38-click8] commands = pytest --cov=cloup {posargs:-vv} [testenv:report] skip_install = true deps = coverage commands = coverage html coverage report [testenv:lint] skip_install = true deps = flake8 commands = flake8 cloup tests examples [testenv:mypy] deps = mypy typing-extensions commands = mypy --strict cloup mypy tests examples [testenv:twine] deps = twine commands = twine check {distdir}/* [testenv:dev] # Create a development environment envdir = {toxinidir}/venv basepython = python3.8 usedevelop = true deps = -r requirements/dev.txt commands = python --version [testenv:docs] basepython = python3.8 deps = -r requirements/docs.txt commands = python scripts/remove.py docs/_build sphinx-build {posargs:-E} -b html docs docs/_build/html sphinx-build -b linkcheck docs docs/_build/html