├── feud ├── py.typed ├── _internal │ ├── __init__.py │ ├── _types │ │ ├── __init__.py │ │ └── defaults.py │ ├── _meta.py │ ├── _group.py │ ├── _docstring.py │ ├── _decorators.py │ ├── _sections.py │ ├── _inflect.py │ └── _metaclass.py ├── click │ ├── __init__.py │ └── context.py ├── __init__.py ├── exceptions.py ├── typing │ ├── __init__.py │ ├── typing.py │ ├── stdlib.py │ ├── custom.py │ ├── pydantic_extra_types.py │ └── pydantic.py ├── version.py ├── core │ └── command.py └── config.py ├── tests ├── .gitkeep ├── __init__.py └── unit │ ├── __init__.py │ ├── test_core │ ├── __init__.py │ └── fixtures │ │ ├── __init__.py │ │ └── module.py │ ├── test_click │ ├── __init__.py │ └── test_context.py │ ├── test_internal │ ├── __init__.py │ ├── test_types │ │ └── test_click │ │ │ ├── __init__.py │ │ │ ├── test_get_click_type │ │ │ ├── __init__.py │ │ │ ├── test_custom.py │ │ │ ├── conftest.py │ │ │ ├── test_pydantic_extra_types.py │ │ │ ├── test_stdlib.py │ │ │ ├── test_typing.py │ │ │ ├── test_literal.py │ │ │ └── test_pydantic.py │ │ │ ├── conftest.py │ │ │ ├── test_metavars.py │ │ │ └── test_is_collection_type.py │ ├── test_metaclass.py │ ├── test_command.py │ ├── test_decorators.py │ └── test_docstring.py │ ├── test_version.py │ └── test_config.py ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature.yml │ └── bug.yml ├── workflows │ ├── create-pypi-release.yml │ ├── create-github-release.yml │ ├── semantic-pull-request.yml │ └── create-release-pr.yml └── pull_request_template.md ├── docs ├── source │ ├── _static │ │ ├── css │ │ │ └── heading.css │ │ └── images │ │ │ ├── logo │ │ │ └── logo.png │ │ │ ├── help │ │ │ └── typing.png │ │ │ ├── examples │ │ │ ├── typing.png │ │ │ ├── typing │ │ │ │ ├── bool │ │ │ │ │ ├── help.png │ │ │ │ │ └── error.png │ │ │ │ ├── path │ │ │ │ │ └── help.png │ │ │ │ ├── str │ │ │ │ │ └── help.png │ │ │ │ ├── literal │ │ │ │ │ ├── help.png │ │ │ │ │ └── error.png │ │ │ │ ├── number │ │ │ │ │ ├── error.png │ │ │ │ │ └── help.png │ │ │ │ ├── union │ │ │ │ │ ├── error.png │ │ │ │ │ └── help.png │ │ │ │ ├── uuids │ │ │ │ │ ├── error.png │ │ │ │ │ └── help.png │ │ │ │ ├── datetime │ │ │ │ │ ├── error.png │ │ │ │ │ └── help.png │ │ │ │ ├── enumeration │ │ │ │ │ ├── error.png │ │ │ │ │ └── help.png │ │ │ │ ├── sequence_fixed │ │ │ │ │ ├── help.png │ │ │ │ │ └── error.png │ │ │ │ └── sequence_variable │ │ │ │ │ ├── help.png │ │ │ │ │ └── error.png │ │ │ └── README.md │ │ │ ├── favicon │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ └── apple-touch-icon.png │ │ │ └── readme │ │ │ ├── error-rich.png │ │ │ ├── help-rich.png │ │ │ ├── error-no-rich.png │ │ │ └── help-no-rich.png │ ├── __init__.py │ ├── sections │ │ ├── core │ │ │ ├── index.rst │ │ │ ├── run.rst │ │ │ ├── group.rst │ │ │ └── command.rst │ │ ├── decorators │ │ │ ├── index.rst │ │ │ ├── section.rst │ │ │ ├── alias.rst │ │ │ ├── env.rst │ │ │ └── rename.rst │ │ ├── exceptions.rst │ │ ├── typing │ │ │ ├── other.rst │ │ │ ├── index.rst │ │ │ ├── pydantic_extra_types.rst │ │ │ └── pydantic.rst │ │ └── config.rst │ ├── index.rst │ └── conf.py ├── robots.txt ├── Makefile └── make.bat ├── mise.toml ├── make ├── __init__.py ├── cov.py ├── types.py ├── docs.py ├── lint.py ├── release.py └── tests.py ├── .readthedocs.yaml ├── Makefile ├── .pre-commit-config.yaml ├── notice.py ├── tox.ini ├── LICENSE ├── .gitignore ├── .circleci └── config.yml ├── tasks.py ├── cliff.toml ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── pyproject.toml /feud/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @eonu -------------------------------------------------------------------------------- /docs/source/_static/css/heading.css: -------------------------------------------------------------------------------- 1 | section#feud > h1 { 2 | text-align: center; 3 | } -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: /*/stable/ 3 | Allow: /en/stable/ 4 | Disallow: / 5 | -------------------------------------------------------------------------------- /docs/source/_static/images/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/logo/logo.png -------------------------------------------------------------------------------- /docs/source/_static/images/help/typing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/help/typing.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing.png -------------------------------------------------------------------------------- /docs/source/_static/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/favicon/favicon.ico -------------------------------------------------------------------------------- /docs/source/_static/images/readme/error-rich.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/readme/error-rich.png -------------------------------------------------------------------------------- /docs/source/_static/images/readme/help-rich.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/readme/help-rich.png -------------------------------------------------------------------------------- /docs/source/_static/images/readme/error-no-rich.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/readme/error-no-rich.png -------------------------------------------------------------------------------- /docs/source/_static/images/readme/help-no-rich.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/readme/help-no-rich.png -------------------------------------------------------------------------------- /docs/source/_static/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /docs/source/_static/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | poetry = { version = 'latest', pyproject = 'pyproject.toml' } 3 | python = '3.13' 4 | 5 | [env] 6 | _.python.venv = ".venv" 7 | -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/bool/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/bool/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/path/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/path/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/str/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/str/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/bool/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/bool/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/literal/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/literal/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/number/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/number/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/number/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/number/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/union/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/union/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/union/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/union/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/uuids/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/uuids/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/uuids/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/uuids/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/datetime/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/datetime/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/datetime/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/datetime/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/literal/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/literal/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/enumeration/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/enumeration/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/enumeration/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/enumeration/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/sequence_fixed/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/sequence_fixed/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/sequence_fixed/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/sequence_fixed/error.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/sequence_variable/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/sequence_variable/help.png -------------------------------------------------------------------------------- /docs/source/_static/images/examples/typing/sequence_variable/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eonu/feud/HEAD/docs/source/_static/images/examples/typing/sequence_variable/error.png -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /feud/_internal/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /tests/unit/test_core/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /tests/unit/test_click/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /tests/unit/test_internal/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /tests/unit/test_core/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Got a question? 4 | url: "https://github.com/eonu/feud/discussions/new?category=questions" 5 | about: Start a discussion on GitHub discussions where Feud developers and users can respond. 6 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /docs/source/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Package documentation builder.""" 7 | -------------------------------------------------------------------------------- /docs/source/sections/core/index.rst: -------------------------------------------------------------------------------- 1 | Core 2 | ==== 3 | 4 | This module contains tools for defining and running *commands* and *groups*, 5 | the two core components of Feud CLIs. 6 | 7 | ---- 8 | 9 | .. toctree:: 10 | :titlesonly: 11 | 12 | command.rst 13 | group.rst 14 | run.rst 15 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | -------------------------------------------------------------------------------- /docs/source/_static/images/examples/README.md: -------------------------------------------------------------------------------- 1 | All screenshots in subdirectories in this folder were generated with 2 | [`termshot`](https://github.com/homeport/termshot) using the following `rich-click` settings: 3 | 4 | ```python 5 | click.rich_click.WIDTH = 70 6 | click.rich_click.SHOW_ARGUMENTS = True 7 | ``` 8 | -------------------------------------------------------------------------------- /docs/source/sections/decorators/index.rst: -------------------------------------------------------------------------------- 1 | Decorators 2 | ========== 3 | 4 | This module consists of decorators that modify :doc:`../core/command` and their parameters. 5 | 6 | ---- 7 | 8 | .. toctree:: 9 | :titlesonly: 10 | 11 | alias.rst 12 | env.rst 13 | rename.rst 14 | section.rst 15 | -------------------------------------------------------------------------------- /feud/_internal/_types/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from feud._internal._types import click, defaults 7 | -------------------------------------------------------------------------------- /make/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Tasks for package development.""" 7 | 8 | from . import cov, docs, lint, release, tests, types 9 | -------------------------------------------------------------------------------- /docs/source/sections/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | ---- 11 | 12 | API reference 13 | ------------- 14 | 15 | .. automodule:: feud.exceptions 16 | :members: 17 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | jobs: 8 | post_create_environment: 9 | - pip install poetry 10 | - poetry config virtualenvs.create false 11 | post_install: 12 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --only base,main,docs 13 | 14 | sphinx: 15 | configuration: docs/source/conf.py 16 | fail_on_warning: false 17 | -------------------------------------------------------------------------------- /.github/workflows/create-pypi-release.yml: -------------------------------------------------------------------------------- 1 | name: Create PyPI release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repository code 13 | uses: actions/checkout@v4 14 | - name: Build and publish to PyPI 15 | uses: JRubics/poetry-publish@v1.17 16 | with: 17 | python_version: "3.11.3" 18 | poetry_version: "==1.7.1" 19 | pypi_token: ${{ secrets.PYPI_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | 6 | 7 | 8 | ## Checklist 9 | 10 | - [ ] I have added new tests (if necessary). 11 | - [ ] I have ensured that tests and coverage are passing on CI. 12 | - [ ] I have updated any relevant documentation (if necessary). 13 | - [ ] I have used a descriptive pull request title. 14 | -------------------------------------------------------------------------------- /make/cov.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Tasks for running coverage checks.""" 7 | 8 | from invoke.config import Config 9 | from invoke.tasks import task 10 | 11 | 12 | @task 13 | def install(c: Config) -> None: 14 | """Install package with core and coverage dependencies.""" 15 | c.run("poetry install --sync --only base,main,cov -E extra-types -E email") 16 | -------------------------------------------------------------------------------- /feud/click/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Overrides for ``click``.""" 7 | 8 | #: Whether ``rich_click`` is installed or not. 9 | is_rich: bool 10 | 11 | try: 12 | from rich_click import * 13 | 14 | is_rich = True 15 | except (ImportError, ModuleNotFoundError): 16 | from click import * # type: ignore[no-redef, assignment] 17 | 18 | is_rich = False 19 | 20 | from feud.click.context import * # noqa: E402 21 | -------------------------------------------------------------------------------- /docs/source/sections/core/run.rst: -------------------------------------------------------------------------------- 1 | Running and building CLIs 2 | ========================= 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | - :py:func:`.run`: **Build and run** runnable objects as a :py:class:`click.Command` or :py:class:`click.Group`. 11 | - :py:func:`.build`: **Build** runnable object(s) into a 12 | :py:class:`click.Command`, :py:class:`click.Group` (or :py:class:`.Group`). 13 | 14 | ---- 15 | 16 | API reference 17 | ------------- 18 | 19 | .. automodule:: feud.core 20 | :members: run, build 21 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import typing as t 9 | 10 | import pytest 11 | 12 | 13 | class Helpers: 14 | @staticmethod 15 | def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: 16 | return t.Annotated[hint, "annotation"] 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def helpers() -> type[Helpers]: 21 | return Helpers 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This Makefile is based on https://github.com/pydantic/pydantic/blob/main/Makefile. 2 | 3 | .DEFAULT_GOAL := dev 4 | 5 | # check that poetry is installed 6 | .PHONY: .check-poetry 7 | .check-poetry: 8 | @poetry -V || echo 'Please install Poetry: https://python-poetry.org/' 9 | 10 | # install invoke and tox 11 | .PHONY: base 12 | base: .check-poetry 13 | poetry install --sync --only base 14 | 15 | # install development dependencies 16 | .PHONY: dev 17 | dev: .check-poetry 18 | poetry install --sync --only base 19 | poetry run invoke install 20 | 21 | # clean temporary repository files 22 | .PHONY: clean 23 | clean: .check-poetry 24 | poetry run invoke clean 25 | -------------------------------------------------------------------------------- /tests/unit/test_click/test_context.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import click 7 | 8 | import feud 9 | 10 | 11 | @feud.command 12 | def func(ctx: feud.click.Context) -> None: 13 | return ctx 14 | 15 | 16 | def test_no_validation() -> None: 17 | """Test that the context argument is not validated, 18 | and returns the current Click context. 19 | """ 20 | ctx: click.core.Context = func([], standalone_mode=False) 21 | assert isinstance(ctx, click.core.Context) 22 | -------------------------------------------------------------------------------- /feud/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Build powerful CLIs with simple idiomatic Python, driven by type hints. 7 | Not all arguments are bad. 8 | """ 9 | 10 | import feud.version 11 | 12 | __version__ = feud.version.VERSION 13 | 14 | from feud import click as click 15 | from feud import exceptions as exceptions 16 | from feud import typing as typing 17 | from feud.config import * 18 | from feud.core import * 19 | from feud.decorators import * 20 | from feud.exceptions import * 21 | -------------------------------------------------------------------------------- /feud/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Package exceptions.""" 7 | 8 | __all__ = ["CompilationError", "RegistrationError"] 9 | 10 | 11 | class CompilationError(Exception): 12 | """An exception indicating an issue that would prevent a 13 | :py:class:`click.Command` command from being generated as expected. 14 | """ 15 | 16 | 17 | class RegistrationError(Exception): 18 | """An exception relating to the registering or deregistering of 19 | :py:class:`.Group` classes. 20 | """ 21 | -------------------------------------------------------------------------------- /feud/typing/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Officially supported types. 7 | 8 | Note that other arbitrary types may work with Feud, 9 | but those imported within this module are the officially supported types. 10 | """ 11 | 12 | from feud.typing.custom import * 13 | from feud.typing.pydantic import * 14 | from feud.typing.pydantic_extra_types import * 15 | from feud.typing.stdlib import * 16 | from feud.typing.typing import * 17 | 18 | __all__ = [name for name in dir() if not name.startswith("__")] 19 | -------------------------------------------------------------------------------- /.github/workflows/create-github-release.yml: -------------------------------------------------------------------------------- 1 | name: Create GitHub release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: Check out repository code 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: "Get previous tag" 19 | id: latest-tag 20 | uses: "WyriHaximus/github-action-get-previous-tag@v1" 21 | - uses: ncipollo/release-action@v1 22 | with: 23 | tag: ${{ steps.latest-tag.outputs.tag }} 24 | generateReleaseNotes: true 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /make/types.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Tasks for running static type checks.""" 7 | 8 | from __future__ import annotations 9 | 10 | from invoke.config import Config 11 | from invoke.tasks import task 12 | 13 | 14 | @task 15 | def install(c: Config) -> None: 16 | """Install package with core and dev dependencies.""" 17 | c.run("poetry install --sync --only base,main,types -E all") 18 | 19 | 20 | @task 21 | def check(c: Config) -> None: 22 | """Type check Python package files.""" 23 | c.run("poetry run mypy feud") 24 | -------------------------------------------------------------------------------- /feud/typing/typing.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Officially supported types from the ``typing`` package.""" 7 | 8 | from __future__ import annotations 9 | 10 | import typing 11 | 12 | types: list[str] = [ 13 | "Annotated", 14 | "Any", 15 | "Deque", 16 | "FrozenSet", 17 | "List", 18 | "Literal", 19 | "NamedTuple", 20 | "Optional", 21 | "Pattern", 22 | "Set", 23 | "Text", 24 | "Tuple", 25 | "Union", 26 | ] 27 | 28 | globals().update({attr: getattr(typing, attr) for attr in types}) 29 | 30 | __all__ = list(types) 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Request a new feature or improvement 2 | description: If you have a suggestion for something that might improve feud, let us know here! 3 | labels: [feature, pending] 4 | 5 | body: 6 | - type: checkboxes 7 | id: exists 8 | attributes: 9 | label: Does this suggestion already exist? 10 | description: If you haven't already, please look through the documentation and other existing issues to see if this feature is already implemented. 11 | options: 12 | - label: This is a new feature! 13 | required: true 14 | 15 | - type: textarea 16 | id: feature-description 17 | attributes: 18 | label: Feature description 19 | description: Please describe the new feature or improvement that you would like. 20 | validations: 21 | required: true 22 | -------------------------------------------------------------------------------- /feud/_internal/_meta.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | from dataclasses import dataclass, field 9 | from typing import TypedDict 10 | 11 | __all__ = ["FeudMeta", "NameDict"] 12 | 13 | 14 | class NameDict(TypedDict): 15 | command: str | None 16 | params: dict[str, str] 17 | 18 | 19 | @dataclass 20 | class FeudMeta: 21 | aliases: dict[str, list[str]] = field(default_factory=dict) 22 | envs: dict[str, str] = field(default_factory=dict) 23 | names: NameDict = field( 24 | default_factory=lambda: NameDict(command=None, params={}) 25 | ) 26 | sections: dict[str, str] = field(default_factory=dict) 27 | -------------------------------------------------------------------------------- /docs/source/sections/decorators/section.rst: -------------------------------------------------------------------------------- 1 | Grouping command options 2 | ======================== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | In cases when a command has many options, it can be useful to divide these 11 | options into different sections which are displayed on the command help page. 12 | For instance, basic and advanced options. 13 | 14 | The :py:func:`.section` decorator can be used to define these sections for a command. 15 | 16 | .. seealso:: 17 | 18 | :py:obj:`.Group.__sections__()` can be used to similarly partition commands 19 | and subgroups displayed on a :py:class:`.Group` help page. 20 | 21 | ---- 22 | 23 | API reference 24 | ------------- 25 | 26 | .. autofunction:: feud.decorators.section 27 | -------------------------------------------------------------------------------- /feud/_internal/_types/defaults.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import datetime 7 | import enum 8 | import typing as t 9 | 10 | 11 | def convert_default(default: t.Any) -> t.Any: 12 | if isinstance(default, enum.Enum): 13 | return convert_default(default.value) 14 | if isinstance(default, datetime.datetime): 15 | # this would be caught by isinstance(default, datetime.date) otherwise 16 | return default 17 | if isinstance(default, (datetime.date, datetime.time)): 18 | return str(default) 19 | if isinstance(default, (set, frozenset, tuple, list)): 20 | return type(default)(map(convert_default, default)) 21 | return default 22 | -------------------------------------------------------------------------------- /tests/unit/test_version.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import re 7 | 8 | import feud 9 | from feud.version import VERSION, version_info 10 | 11 | 12 | def test_version() -> None: 13 | """Check that the version is a valid SemVer version.""" 14 | assert re.match(r"\d+\.\d+\.\d+[a-z0-9]*", VERSION) 15 | 16 | 17 | def test_version_info() -> None: 18 | """Check that the version appears in the version info. 19 | 20 | FIXME: Not a thorough check of version_info() details. 21 | """ 22 | assert VERSION in version_info() 23 | 24 | 25 | def test_dunder() -> None: 26 | """Check that VERSION is the same as __version__.""" 27 | assert feud.__version__ == VERSION 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # license notice 3 | - repo: local 4 | hooks: 5 | - id: notice 6 | name: notice 7 | entry: poetry run python notice.py 8 | language: system 9 | types: [python] 10 | always_run: true 11 | pass_filenames: false 12 | # ruff check (w/autofix) 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | rev: v0.8.4 # should match version in pyproject.toml 15 | hooks: 16 | - id: ruff 17 | args: [--fix, --exit-non-zero-on-fix] 18 | # ruff format 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: v0.8.4 # should match version in pyproject.toml 21 | hooks: 22 | - id: ruff-format 23 | # pydoclint - docstring formatting 24 | - repo: https://github.com/jsh9/pydoclint 25 | rev: 0.3.8 26 | hooks: 27 | - id: pydoclint 28 | args: [--config=pyproject.toml] 29 | -------------------------------------------------------------------------------- /notice.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Adds a notice to the top of all Python source code files. 7 | 8 | This script is based on: 9 | https://github.com/fatiando/maintenance/issues/10#issuecomment-718754908 10 | """ 11 | 12 | from pathlib import Path 13 | 14 | notice = """ 15 | # Copyright (c) 2023 Feud Developers. 16 | # Distributed under the terms of the MIT License (see the LICENSE file). 17 | # SPDX-License-Identifier: MIT 18 | # This source code is part of the Feud project (https://feud.wiki). 19 | """.strip() 20 | 21 | 22 | for f in Path(".").glob("**/*.py"): 23 | if not str(f).startswith("."): 24 | code = f.read_text() 25 | if not code.startswith(notice): 26 | f.write_text(f"{notice}\n\n{code}") 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/sections/typing/other.rst: -------------------------------------------------------------------------------- 1 | Other types 2 | =========== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | Feud provides the following additional types for common CLI needs. 11 | 12 | .. tip:: 13 | 14 | All of the types listed on this page are easily accessible from the :py:mod:`feud.typing` module. 15 | 16 | It is recommended to import the :py:mod:`feud.typing` module with an alias such as ``t`` for convenient short-hand use, e.g. 17 | 18 | .. code:: python 19 | 20 | from feud import typing as t 21 | 22 | t.Counter # feud.typing.custom.Counter 23 | t.concounter # feud.typing.custom.concounter 24 | 25 | ---- 26 | 27 | Counting types 28 | -------------- 29 | 30 | .. autodata:: feud.typing.custom.Counter 31 | :annotation: 32 | 33 | .. autofunction:: feud.typing.custom.concounter 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | 4 | [testenv:tests] 5 | allowlist_externals = poetry 6 | ; commands = poetry run invoke tests.install tests.doctest tests.unit {posargs} 7 | commands = poetry run invoke tests.install tests.unit {posargs} 8 | 9 | [testenv:tests.doctest] 10 | allowlist_externals = poetry 11 | commands = poetry run invoke tests.install tests.doctest 12 | 13 | [testenv:tests.unit] 14 | allowlist_externals = poetry 15 | commands = poetry run invoke tests.install tests.unit {posargs} 16 | 17 | [testenv:docs] 18 | allowlist_externals = poetry 19 | commands = poetry run invoke docs.install docs.build {posargs} 20 | 21 | [testenv:lint] 22 | allowlist_externals = poetry 23 | commands = poetry run invoke lint.install lint.check 24 | 25 | [testenv:format] 26 | allowlist_externals = poetry 27 | commands = poetry run invoke lint.install lint.format 28 | 29 | [testenv:types] 30 | allowlist_externals = poetry 31 | commands = poetry run invoke types.install types.check 32 | -------------------------------------------------------------------------------- /make/docs.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Tasks for generating Sphinx documentation.""" 7 | 8 | from invoke.config import Config 9 | from invoke.tasks import task 10 | 11 | 12 | @task 13 | def install(c: Config) -> None: 14 | """Install package with core and docs dependencies.""" 15 | c.run("poetry install --sync --only base,main,docs") 16 | 17 | 18 | @task 19 | def build(c: Config, *, watch: bool = True) -> None: 20 | """Build package Sphinx documentation.""" 21 | if watch: 22 | command = ( 23 | "poetry run sphinx-autobuild " 24 | "docs/source/ docs/build/html/ " 25 | "--watch docs/source/ --watch feud/ " 26 | "--ignore feud/_internal/" 27 | ) 28 | else: 29 | command = "cd docs && make html" 30 | c.run(command) 31 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Enforce semantic PR title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | pull-requests: read 12 | 13 | jobs: 14 | main: 15 | name: validate 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@v5 19 | with: 20 | subjectPattern: ^(?![A-Z]).+$ 21 | subjectPatternError: | 22 | The subject "{subject}" found in the pull request title "{title}" 23 | didn't match the configured pattern. Please ensure that the subject 24 | doesn't start with an uppercase character. 25 | types: | 26 | build 27 | chore 28 | ci 29 | docs 30 | feat 31 | fix 32 | perf 33 | refactor 34 | release 35 | revert 36 | style 37 | tests 38 | scopes: | 39 | deps 40 | git 41 | pkg 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Edwin Onuonga (eonu) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/source/sections/config.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | Configuration 4 | ============= 5 | 6 | .. contents:: Table of Contents 7 | :class: this-will-duplicate-information-and-it-is-still-useful-here 8 | :local: 9 | :backlinks: none 10 | :depth: 3 11 | 12 | :doc:`core/command` are defined by :py:func:`.command`, 13 | which accepts various Feud configuration key-word arguments such as 14 | ``negate_flags`` or ``show_help_defaults`` directly. 15 | 16 | Similarly, :doc:`core/group` can be directly configured with Feud 17 | configuration key-word arguments provided when subclassing :py:class:`.Group`. 18 | 19 | However, in some cases it may be useful to have a reusable configuration 20 | object that can be provided to other commands or groups. This functionality is 21 | implemented by :py:func:`.config`, which creates a configuration which can be 22 | provided to :py:func:`.command` or :py:class:`.Group`. 23 | 24 | ---- 25 | 26 | API reference 27 | ------------- 28 | 29 | .. autofunction:: feud.config.config 30 | 31 | .. autopydantic_model:: feud.config.Config 32 | :model-show-json: False 33 | :model-show-config-summary: False 34 | :exclude-members: __init__ -------------------------------------------------------------------------------- /make/lint.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Tasks for running linting and formatting.""" 7 | 8 | from __future__ import annotations 9 | 10 | from invoke.config import Config 11 | from invoke.tasks import task 12 | 13 | 14 | @task 15 | def install(c: Config) -> None: 16 | """Install package with core and dev dependencies.""" 17 | c.run("poetry install --sync --only base,main,lint") 18 | 19 | 20 | @task 21 | def check(c: Config) -> None: 22 | """Lint Python files.""" 23 | commands: list[str] = [ 24 | "poetry run ruff check .", 25 | "poetry run ruff format --check .", 26 | "poetry run pydoclint .", 27 | ] 28 | for command in commands: 29 | c.run(command) 30 | 31 | 32 | @task(name="format") 33 | def format_(c: Config) -> None: 34 | """Format Python files.""" 35 | commands: list[str] = [ 36 | "poetry run ruff check --fix .", 37 | "poetry run ruff format .", 38 | ] 39 | for command in commands: 40 | c.run(command) 41 | -------------------------------------------------------------------------------- /feud/typing/stdlib.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Officially supported types from the below standard library packages. 7 | 8 | - ``collections`` 9 | - ``datetime`` 10 | - ``decimal`` 11 | - ``fractions`` 12 | - ``enum`` 13 | - ``pathlib`` 14 | - ``uuid`` 15 | """ 16 | 17 | from __future__ import annotations 18 | 19 | import collections 20 | import datetime 21 | import decimal 22 | import enum 23 | import fractions 24 | import pathlib 25 | import uuid 26 | from itertools import chain 27 | from types import ModuleType 28 | 29 | types: dict[ModuleType, list[str]] = { 30 | collections: ["deque"], 31 | datetime: ["date", "datetime", "time", "timedelta"], 32 | decimal: ["Decimal"], 33 | fractions: ["Fraction"], 34 | enum: ["Enum", "IntEnum", "StrEnum"], 35 | pathlib: ["Path"], 36 | uuid: ["UUID"], 37 | } 38 | 39 | globals().update( 40 | { 41 | attr: getattr(module, attr) 42 | for module, attrs in types.items() 43 | for attr in attrs 44 | } 45 | ) 46 | 47 | __all__ = list(chain.from_iterable(types.values())) 48 | -------------------------------------------------------------------------------- /make/release.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Tasks for bumping the package version.""" 7 | 8 | import os 9 | import re 10 | from pathlib import Path 11 | 12 | from invoke.config import Config 13 | from invoke.tasks import task 14 | 15 | 16 | @task 17 | def build(c: Config, *, v: str) -> None: 18 | """Build release.""" 19 | root: Path = Path(os.getcwd()) 20 | 21 | # bump Sphinx documentation version - docs/source/conf.py 22 | conf_path: Path = root / "docs" / "source" / "conf.py" 23 | with open(conf_path) as f: 24 | conf: str = f.read() 25 | with open(conf_path, "w") as f: 26 | f.write(re.sub(r'release = ".*"', f'release = "{v}"', conf)) 27 | 28 | # bump package version - feud/version.py) 29 | init_path: Path = root / "feud" / "version.py" 30 | with open(init_path) as f: 31 | init: str = f.read() 32 | with open(init_path, "w") as f: 33 | f.write(re.sub(r'VERSION = ".*"', f'VERSION = "{v}"', init)) 34 | 35 | # bump project version - pyproject.toml 36 | c.run(f"poetry version -q {v}") 37 | -------------------------------------------------------------------------------- /feud/click/context.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Override for :py:class:`click.Context`.""" 7 | 8 | from __future__ import annotations 9 | 10 | import typing as t 11 | 12 | import click 13 | from pydantic_core import core_schema 14 | 15 | __all__ = ["Context"] 16 | 17 | 18 | class Context(click.Context): 19 | """Override :py:class:`click.Context` for type hints that skip 20 | validation. 21 | 22 | Example 23 | ------- 24 | >>> import feud 25 | >>> from feud import click 26 | >>> class CLI(feud.Group): 27 | ... def f(*, arg: int) -> int: 28 | ... return arg 29 | ... def g(ctx: click.Context, *, arg: int) -> int: 30 | ... return ctx.forward(CLI.f) 31 | >>> feud.run(CLI, ["g", "--arg", "3"], standalone_mode=False) 32 | 3 33 | """ 34 | 35 | @classmethod 36 | def __get_pydantic_core_schema__( 37 | cls, _source: t.Any, _handler: t.Any 38 | ) -> core_schema.CoreSchema: 39 | """Override pydantic schema to validate as typing.Any.""" 40 | return core_schema.any_schema() 41 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/test_custom.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import click 9 | import pytest 10 | 11 | from feud import typing as t 12 | from feud.config import Config 13 | 14 | 15 | @pytest.mark.parametrize("annotated", [False, True]) 16 | @pytest.mark.parametrize( 17 | ("hint", "expected"), 18 | [ 19 | # counter types 20 | (t.Counter, click.INT), 21 | ( 22 | t.concounter(ge=0, le=3), 23 | lambda x: isinstance(x, click.IntRange) 24 | and x.min == 0 25 | and x.min_open is False 26 | and x.max == 3 27 | and x.max_open is False, 28 | ), 29 | ], 30 | ) 31 | def test_custom( 32 | helpers: type, 33 | *, 34 | config: Config, 35 | annotated: bool, 36 | hint: t.Any, 37 | expected: click.ParamType | None, 38 | ) -> None: 39 | helpers.check_get_click_type( 40 | config=config, 41 | annotated=annotated, 42 | hint=hint, 43 | expected=expected, 44 | ) 45 | -------------------------------------------------------------------------------- /make/tests.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Tasks for running tests.""" 7 | 8 | from __future__ import annotations 9 | 10 | from invoke.config import Config 11 | from invoke.tasks import task 12 | 13 | 14 | @task 15 | def install(c: Config) -> None: 16 | """Install package with core and test dependencies.""" 17 | c.run( 18 | "poetry install --sync --only base,main,tests -E extra-types -E email" 19 | ) 20 | 21 | 22 | @task 23 | def doctest(c: Config) -> None: 24 | """Run doctests.""" 25 | # skip: 26 | # - feud/click/context.py 27 | # - feud/decorators.py 28 | files: list[str] = [ 29 | "feud/config.py", 30 | "feud/core/__init__.py", 31 | "feud/core/command.py", 32 | "feud/core/group.py", 33 | ] 34 | c.run(f"poetry run python -m doctest {' '.join(files)}") 35 | 36 | 37 | @task 38 | def unit(c: Config, *, cov: bool = False) -> None: 39 | """Run unit tests.""" 40 | command: str = "poetry run pytest tests/" 41 | 42 | if cov: 43 | command = f"{command} --cov feud --cov-report xml" 44 | 45 | c.run(command) 46 | -------------------------------------------------------------------------------- /docs/source/sections/core/group.rst: -------------------------------------------------------------------------------- 1 | Groups 2 | ====== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | Groups are a component of CLIs that allow you to group together related :doc:`command`. 11 | 12 | In addition to commands, groups may also contain further nested groups by :py:obj:`.register`\ ing subgroups, 13 | allowing for the construction of complex CLIs with many levels. 14 | 15 | Groups and their subgroups or commands can be executed using :py:func:`.run`. 16 | 17 | .. seealso:: 18 | 19 | The Click API documentation does a great job at clarifying the following command-line terminology: 20 | 21 | - `Commands and groups `__ 22 | - `Parameters `__ 23 | 24 | - `Arguments `__ 25 | - `Options `__ 26 | 27 | ---- 28 | 29 | API reference 30 | ------------- 31 | 32 | .. autoclass:: feud.core.group.Group 33 | :members: 34 | :special-members: __sections__ 35 | :exclude-members: from_dict, from_iter, from_module 36 | 37 | .. autopydantic_model:: feud.Section 38 | :model-show-json: False 39 | :model-show-config-summary: False 40 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_metavars.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import typing as t 7 | 8 | import pytest 9 | 10 | import feud 11 | 12 | 13 | def test_union(capsys: pytest.CaptureFixture) -> None: 14 | @feud.command 15 | def f( 16 | *, 17 | opt1: int | float, 18 | opt2: t.Union[int, float], 19 | opt3: str | int | None, 20 | opt4: t.Optional[t.Union[str, int]], 21 | opt5: t.Union[int, t.Union[float, str]], 22 | opt6: int | None, 23 | opt7: str | t.Annotated[str, "annotated"], 24 | ) -> None: 25 | pass 26 | 27 | with pytest.raises(SystemExit): 28 | f(["--help"]) 29 | 30 | out, _ = capsys.readouterr() 31 | 32 | assert ( 33 | out.strip() 34 | == """ 35 | Usage: pytest [OPTIONS] 36 | 37 | Options: 38 | --opt1 INTEGER | FLOAT [required] 39 | --opt2 INTEGER | FLOAT [required] 40 | --opt3 TEXT | INTEGER [required] 41 | --opt4 TEXT | INTEGER [required] 42 | --opt5 INTEGER | FLOAT | TEXT [required] 43 | --opt6 INTEGER [required] 44 | --opt7 TEXT [required] 45 | --help Show this message and exit. 46 | """.strip() 47 | ) 48 | -------------------------------------------------------------------------------- /feud/_internal/_group.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import typing as t 9 | 10 | from feud import click 11 | from feud._internal import _command, _meta 12 | 13 | if t.TYPE_CHECKING: 14 | from feud.core.group import Group 15 | 16 | 17 | def get_group(__cls: type[Group], /) -> click.Group: # type[Group] 18 | func: t.Callable = __cls.__main__ 19 | if isinstance(func, staticmethod): 20 | func = func.__func__ 21 | 22 | state = _command.CommandState( 23 | config=__cls.__feud_config__, 24 | click_kwargs=__cls.__feud_click_kwargs__, 25 | is_group=True, 26 | meta=getattr(func, "__feud__", _meta.FeudMeta()), 27 | overrides={ 28 | override.name: override 29 | for override in getattr(func, "__click_params__", []) 30 | }, 31 | ) 32 | 33 | # construct command state from signature 34 | _command.build_command_state( 35 | state, func=func, config=__cls.__feud_config__ 36 | ) 37 | 38 | # generate click.Group and attach original function reference 39 | command: click.Group = state.decorate(func) # type: ignore[assignment] 40 | command.__func__ = func # type: ignore[attr-defined] 41 | command.__group__ = __cls # type: ignore[attr-defined] 42 | return command 43 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import pytest 7 | 8 | from feud import typing as t 9 | from feud._internal import _types 10 | from feud.config import Config 11 | 12 | 13 | @pytest.fixture(scope="module") 14 | def config() -> Config: 15 | return Config._create() # noqa: SLF001 16 | 17 | 18 | @pytest.fixture(scope="module") 19 | def helpers(helpers: type) -> type: # type[Helpers] 20 | def is_lambda(__obj: t.Any, /) -> bool: 21 | return ( 22 | callable(__obj) 23 | and getattr(__obj, "__name__", None) == (lambda: None).__name__ 24 | ) 25 | 26 | # register is_lambda helper 27 | helpers.is_lambda = is_lambda 28 | 29 | def check_get_click_type( 30 | *, 31 | config: Config, 32 | annotated: bool, 33 | hint: t.Any, 34 | expected: t.Tuple[bool, t.Any], 35 | ) -> None: 36 | if annotated: 37 | hint = helpers.annotate(hint) 38 | 39 | result = _types.click.get_click_type(hint, config=config) 40 | 41 | if helpers.is_lambda(expected): 42 | assert expected(result) 43 | else: 44 | assert result == expected 45 | 46 | # register check_get_click_type helper 47 | helpers.check_get_click_type = check_get_click_type 48 | 49 | return helpers 50 | -------------------------------------------------------------------------------- /tests/unit/test_core/fixtures/module.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """This is a module.""" 7 | 8 | import types 9 | 10 | import feud 11 | from feud import click 12 | 13 | 14 | def func1(*, opt: bool) -> bool: 15 | """This is the first function. 16 | 17 | Parameters 18 | ---------- 19 | opt: 20 | This is an option. 21 | """ 22 | return opt 23 | 24 | 25 | @feud.command 26 | def command(*, opt: bool) -> bool: 27 | """This is a command. 28 | 29 | Parameters 30 | ---------- 31 | opt: 32 | This is an option. 33 | """ 34 | return opt 35 | 36 | 37 | class Group(feud.Group, name="feud-group"): 38 | """This is a Feud group.""" 39 | 40 | def func(*, opt: bool) -> bool: 41 | """This is a function in the group. 42 | 43 | Parameters 44 | ---------- 45 | opt: 46 | This is an option. 47 | """ 48 | return opt 49 | 50 | 51 | class Subgroup(feud.Group, name="feud-subgroup"): 52 | """This is a subgroup.""" 53 | 54 | def func(*, opt: bool) -> bool: 55 | """This is a function in the subgroup. 56 | 57 | Parameters 58 | ---------- 59 | opt: 60 | This is an option. 61 | """ 62 | return opt 63 | 64 | 65 | Group.register(Subgroup) 66 | 67 | click_group: click.Group = types.new_class( 68 | "ClickGroup", 69 | bases=(Group,), 70 | kwds={ 71 | "name": "click-group", 72 | "help": "This is a Click group.", 73 | }, 74 | ).compile() 75 | -------------------------------------------------------------------------------- /docs/source/sections/decorators/alias.rst: -------------------------------------------------------------------------------- 1 | Aliasing parameters 2 | =================== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | In CLIs, it is common for options to have an alias allowing 11 | for quicker short-hand usage. 12 | 13 | For instance, an option named ``--verbose`` may be aliased as ``-v``. 14 | 15 | Instead of manually specifying an alias with :py:func:`click.option`, e.g. 16 | 17 | .. code:: python 18 | 19 | import feud 20 | from feud import click 21 | 22 | @click.option("--verbose", "-v", help="Whether to print or not.", type=bool) 23 | def my_command(*, verbose: bool = False): 24 | """A command that does some logging.""" 25 | 26 | feud.run(my_command) 27 | 28 | You can use the :py:func:`.alias` decorator to do this, which also means you 29 | do not have to manually provide ``help`` or ``type`` to :py:func:`click.option`, 30 | and can instead rely on type hints and docstrings. 31 | 32 | .. code:: python 33 | 34 | import feud 35 | 36 | @feud.alias(verbose="-v") 37 | def my_command(*, verbose: bool = False): 38 | """A command that does some logging. 39 | 40 | Parameters 41 | ---------- 42 | verbose: 43 | Whether to print or not. 44 | """ 45 | 46 | feud.run(my_command) 47 | 48 | .. note:: 49 | 50 | In the case of boolean flags such as ``--verbose`` in this case, the ``--no-verbose`` 51 | option will also have a corresponding ``--no-v`` alias automatically defined. 52 | 53 | ---- 54 | 55 | API reference 56 | ------------- 57 | 58 | .. autofunction:: feud.decorators.alias 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | ehthumbs.db 8 | Thumbs.db 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | pip-wheel-metadata/ 32 | share/python-wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .nox/ 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Documentation 62 | docs/_build 63 | 64 | # Jupyter 65 | .ipynb_checkpoints 66 | 67 | # pyenv 68 | .python-version 69 | 70 | # pipenv 71 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 72 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 73 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 74 | # install all needed dependencies. 75 | Pipfile.lock 76 | poetry.lock 77 | 78 | # Environments 79 | .env 80 | .venv 81 | env/ 82 | venv/ 83 | ENV/ 84 | env.bak/ 85 | venv.bak/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | .spyproject 90 | .vscode 91 | 92 | # Ruff 93 | .ruff_cache 94 | 95 | # Changelog entry 96 | ENTRY.md 97 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | 3 | orbs: 4 | python: circleci/python@2.1.1 5 | coveralls: coveralls/coveralls@2.2.1 6 | 7 | jobs: 8 | linting: 9 | executor: 10 | name: python/default 11 | tag: "3.13" 12 | steps: 13 | - checkout 14 | - python/install-packages: 15 | pkg-manager: poetry 16 | args: --only base 17 | - run: 18 | name: Linting 19 | command: | 20 | poetry run tox -e lint 21 | typechecks: 22 | executor: 23 | name: python/default 24 | tag: "3.13" 25 | steps: 26 | - checkout 27 | - python/install-packages: 28 | pkg-manager: poetry 29 | args: --only base 30 | - run: 31 | name: Typechecking (MyPy) 32 | command: | 33 | poetry run tox -e types 34 | tests: 35 | parameters: 36 | version: 37 | type: string 38 | executor: 39 | name: python/default 40 | tag: <> 41 | steps: 42 | - checkout 43 | - python/install-packages: 44 | pkg-manager: poetry 45 | args: --only base 46 | # - run: 47 | # name: Docstring tests 48 | # command: | 49 | # poetry run tox -e tests.doctest 50 | - run: 51 | name: Unit tests 52 | command: | 53 | poetry run -- tox -e tests.unit -- --cov 54 | - coveralls/upload: 55 | flag_name: <> 56 | parallel: true 57 | coverage: 58 | executor: 59 | name: python/default 60 | steps: 61 | - coveralls/upload: 62 | carryforward: 3.11, 3.12, 3.13 63 | parallel_finished: true 64 | 65 | workflows: 66 | checks: 67 | jobs: 68 | - linting 69 | - typechecks 70 | - tests: 71 | matrix: 72 | parameters: 73 | version: ["3.11", "3.12", "3.13"] 74 | - coverage: 75 | requires: 76 | - tests 77 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/test_pydantic_extra_types.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import click 9 | import pytest 10 | 11 | from feud import typing as t 12 | from feud.config import Config 13 | 14 | 15 | @pytest.mark.parametrize("annotated", [False, True]) 16 | @pytest.mark.parametrize( 17 | ("hint", "expected"), 18 | [ 19 | # pydantic extra types 20 | (t.Color, click.STRING), 21 | (t.Latitude, click.FLOAT), 22 | (t.Longitude, click.FLOAT), 23 | (t.Coordinate, (click.FLOAT, click.FLOAT)), 24 | (t.CountryAlpha2, click.STRING), 25 | (t.CountryAlpha3, click.STRING), 26 | (t.CountryNumericCode, click.STRING), 27 | (t.CountryShortName, click.STRING), 28 | # (t.CountryOfficialName, click.STRING), not present in >=2.4.0 29 | (t.MacAddress, click.STRING), 30 | (t.PaymentCardNumber, click.STRING), 31 | ( 32 | t.PaymentCardBrand, 33 | lambda x: isinstance(x, click.Choice) and x.choices, 34 | ), 35 | (t.PhoneNumber, click.STRING), 36 | (t.ABARoutingNumber, click.STRING), 37 | (t.ULID, click.STRING), 38 | (t.ISBN, click.STRING), 39 | (t.LanguageAlpha2, click.STRING), 40 | (t.LanguageName, click.STRING), 41 | (t.SemanticVersion, click.STRING), 42 | (t.S3Path, click.STRING), 43 | ], 44 | ) 45 | def test_pydantic_extra( 46 | helpers: type, 47 | *, 48 | config: Config, 49 | annotated: bool, 50 | hint: t.Any, 51 | expected: click.ParamType | None, 52 | ) -> None: 53 | helpers.check_get_click_type( 54 | config=config, 55 | annotated=annotated, 56 | hint=hint, 57 | expected=expected, 58 | ) 59 | -------------------------------------------------------------------------------- /.github/workflows/create-release-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create release PR 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: Version 8 | required: true 9 | 10 | jobs: 11 | create-pull-request: 12 | permissions: write-all 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out repository code 16 | uses: actions/checkout@v4 17 | with: 18 | ref: dev 19 | fetch-depth: 0 20 | - name: Install Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: 3.11.3 24 | - name: Install Poetry 25 | uses: abatilo/actions-poetry@v2 26 | with: 27 | poetry-version: 1.7.1 28 | - name: Install base dependencies 29 | run: poetry install --sync --only base 30 | - name: Bump version 31 | run: | 32 | poetry run -q invoke release.build -- -v ${{ github.event.inputs.version }} 33 | - name: Update changelog 34 | uses: orhun/git-cliff-action@v2 35 | id: cliff-changelog 36 | with: 37 | config: cliff.toml 38 | args: --tag ${{ github.event.inputs.version }} 39 | env: 40 | OUTPUT: CHANGELOG.md 41 | - name: Get changelog entry 42 | uses: orhun/git-cliff-action@v2 43 | id: cliff-entry 44 | with: 45 | config: cliff.toml 46 | args: --unreleased --strip header 47 | env: 48 | OUTPUT: ENTRY.md 49 | - name: Create pull request 50 | uses: peter-evans/create-pull-request@v5.0.2 51 | with: 52 | token: ${{ secrets.GITHUB_TOKEN }} 53 | commit-message: "release: v${{ github.event.inputs.version }}" 54 | title: "release: v${{ github.event.inputs.version }}" 55 | body: "${{ steps.cliff-entry.outputs.content }}" 56 | branch: release/${{ github.event.inputs.version }} 57 | - uses: rickstaa/action-create-tag@v1 58 | with: 59 | tag: v${{ github.event.inputs.version }} 60 | github_token: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /docs/source/sections/decorators/env.rst: -------------------------------------------------------------------------------- 1 | Using environment variables 2 | =========================== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | In CLIs, environment variables are often used as an alternative method of 11 | providing input for options. This is particularly useful for sensitive 12 | information such as API keys, tokens and passwords. 13 | 14 | For example, an option named ``--token`` may be provided by an environment 15 | variable ``SECRET_TOKEN``. 16 | 17 | Instead of manually specifying an environment variable with :py:func:`click.option`, e.g. 18 | 19 | .. code:: python 20 | 21 | # my_command.py 22 | 23 | import feud 24 | from feud import click, typing as t 25 | 26 | @click.option( 27 | "--token", help="A secret token.", type=str, 28 | envvar="SECRET_TOKEN", show_envvar=True, 29 | ) 30 | def my_command(*, token: t.constr(max_length=16)): 31 | """A command requiring a token no longer than 16 characters.""" 32 | 33 | if __name__ == "__main__": 34 | feud.run(my_command) 35 | 36 | You can use the :py:func:`.env` decorator to do this, which also means you 37 | do not have to manually provide ``help`` or ``type`` to :py:func:`click.option`, 38 | and can instead rely on type hints and docstrings. 39 | 40 | .. code:: python 41 | 42 | # my_command.py 43 | 44 | import feud 45 | from feud import typing as t 46 | 47 | @feud.env(token="SECRET_TOKEN") 48 | def my_command(*, token: t.constr(max_length=16)): 49 | """A command requiring a token no longer than 16 characters. 50 | 51 | Parameters 52 | ---------- 53 | token: 54 | A secret token. 55 | """ 56 | 57 | if __name__ == "__main__": 58 | feud.run(my_command) 59 | 60 | This can be called with ``SECRET_TOKEN=hello-world python command.py``, for example. 61 | 62 | ---- 63 | 64 | API reference 65 | ------------- 66 | 67 | .. autofunction:: feud.decorators.env 68 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Main invoke task collection.""" 7 | 8 | from __future__ import annotations 9 | 10 | from invoke.collection import Collection 11 | from invoke.config import Config 12 | from invoke.tasks import task 13 | 14 | from make import cov, docs, lint, release, tests, types 15 | 16 | 17 | @task 18 | def install(c: Config) -> None: 19 | """Install package with pre-commit hooks and core, dev, docs, test 20 | and types dependencies. 21 | """ 22 | # install dependencies 23 | # NOTE: only including docs/tests dependencies to please editors 24 | c.run("poetry install --sync --only base,main,dev,docs,tests,types -E all") 25 | # install pre-commit hooks 26 | c.run("pre-commit install --install-hooks") 27 | 28 | 29 | @task 30 | def clean(c: Config) -> None: 31 | """Clean temporary files, local cache and build artifacts.""" 32 | commands: list[str] = [ 33 | "rm -rf `find . -name __pycache__`", 34 | "rm -f `find . -type f -name '*.py[co]'`", 35 | "rm -f `find . -type f -name '*~'`", 36 | "rm -f `find . -type f -name '.*~'`", 37 | "rm -rf .cache", 38 | "rm -rf .pytest_cache", 39 | "rm -rf .ruff_cache", 40 | "rm -rf .tox", 41 | "rm -rf htmlcov", 42 | "rm -rf *.egg-info", 43 | "rm -f .coverage", 44 | "rm -f .coverage.*", 45 | "rm -rf build", 46 | "rm -rf dist", 47 | "rm -rf site", 48 | "rm -rf docs/build", 49 | "rm -rf coverage.xml", 50 | ] 51 | for command in commands: 52 | c.run(command) 53 | 54 | 55 | # create top-level namespace 56 | namespace = Collection() 57 | 58 | # register top-level commands 59 | for t in (install, clean): 60 | namespace.add_task(t) 61 | 62 | # register namespaces 63 | for module in (docs, tests, types, cov, lint, release): 64 | collection = Collection.from_module(module) 65 | namespace.add_collection(collection) 66 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/test_stdlib.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import enum 9 | 10 | import click 11 | import pytest 12 | 13 | from feud import typing as t 14 | from feud._internal import _types 15 | from feud.config import Config 16 | 17 | 18 | @pytest.mark.parametrize("annotated", [False, True]) 19 | @pytest.mark.parametrize( 20 | ("hint", "expected"), 21 | [ 22 | *[ 23 | ( 24 | path_type, 25 | lambda x: isinstance(x, click.Path) and x.exists is False, 26 | ) 27 | for path_type in _types.click.PATH_TYPES 28 | ], 29 | (t.UUID, click.UUID), 30 | (t.Decimal, click.FLOAT), 31 | (t.Fraction, click.FLOAT), 32 | ( 33 | t.date, 34 | lambda x: isinstance(x, _types.click.DateTime) 35 | and x.name == t.date.__name__, 36 | ), 37 | ( 38 | t.time, 39 | lambda x: isinstance(x, _types.click.DateTime) 40 | and x.name == t.time.__name__, 41 | ), 42 | ( 43 | t.datetime, 44 | lambda x: isinstance(x, _types.click.DateTime) 45 | and x.name == t.datetime.__name__, 46 | ), 47 | ( 48 | t.timedelta, 49 | lambda x: isinstance(x, _types.click.DateTime) 50 | and x.name == t.timedelta.__name__, 51 | ), 52 | ( 53 | enum.Enum("TestEnum", {"A": "a", "B": "b"}), 54 | lambda x: isinstance(x, click.Choice) and x.choices == ["a", "b"], 55 | ), 56 | ], 57 | ) 58 | def test_stdlib( 59 | helpers: type, 60 | *, 61 | config: Config, 62 | annotated: bool, 63 | hint: t.Any, 64 | expected: click.ParamType | None, 65 | ) -> None: 66 | helpers.check_get_click_type( 67 | config=config, 68 | annotated=annotated, 69 | hint=hint, 70 | expected=expected, 71 | ) 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Report unexpected behaviour 2 | description: If you came across something unexpected, let us know here! 3 | labels: [bug, pending] 4 | 5 | body: 6 | - type: checkboxes 7 | id: exists 8 | attributes: 9 | label: Has this already been reported? 10 | description: If you haven't already, please look other existing issues to see if this bug has already been reported. 11 | options: 12 | - label: This is a new bug! 13 | required: true 14 | 15 | - type: textarea 16 | id: expected-behaviour 17 | attributes: 18 | label: Expected behaviour 19 | description: | 20 | Please describe the behaviour that you expected to see. 21 | 22 | If appropriate, provide any links to official Feud documentation that indicate this is the behaviour that is expected. 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | id: observed-behaviour 28 | attributes: 29 | label: Observed behaviour 30 | description: | 31 | Please describe the unexpected behaviour that you observed. 32 | 33 | Make sure to provide as much information as possible, so that we can investigate as thoroughly as we can. 34 | validations: 35 | required: true 36 | 37 | - type: textarea 38 | id: example 39 | attributes: 40 | label: Code to reproduce 41 | description: > 42 | Please provide a snippet of code that shows how to reproduce the bug, 43 | making sure that it is [minimal and reproducible](https://stackoverflow.com/help/minimal-reproducible-example). 44 | 45 | placeholder: | 46 | import feud 47 | 48 | ... 49 | 50 | if __name__ == "__main__": 51 | feud.run() 52 | render: Python 53 | 54 | - type: textarea 55 | id: version 56 | attributes: 57 | label: Version details 58 | description: | 59 | To help us get to the root of the problem as fast as possible, please run the following command to display version information about: 60 | 61 | - Python 62 | - Feud 63 | - Operating system 64 | 65 | ```bash 66 | python -c "import feud; print(feud.version.version_info())" 67 | ``` 68 | 69 | render: Text 70 | validations: 71 | required: true 72 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. feud documentation master file, created by 2 | sphinx-quickstart on Fri Nov 17 19:50:40 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Feud 7 | ==== 8 | 9 | .. raw:: html 10 | 11 | 12 | 13 |

14 | Not all arguments are bad. 15 |

16 | 17 |

18 | Build powerful CLIs with simple idiomatic Python, driven by type hints. 19 |

20 | 21 |

22 |

39 |

40 | 41 | ---- 42 | 43 | Designing a *good* CLI can quickly spiral into chaos without the help of 44 | an intuitive CLI framework. 45 | 46 | **Feud builds on** `Click `__ **for 47 | parsing and** `Pydantic `__ 48 | **for typing to make CLI building a breeze.** 49 | 50 | Contents 51 | ======== 52 | 53 | .. toctree:: 54 | :titlesonly: 55 | 56 | sections/core/index 57 | sections/typing/index 58 | sections/decorators/index 59 | sections/config 60 | sections/exceptions 61 | 62 | Indices and tables 63 | ================== 64 | 65 | * :ref:`genindex` 66 | * :ref:`modindex` 67 | * :ref:`search` 68 | -------------------------------------------------------------------------------- /feud/typing/custom.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Custom types for counting options.""" 7 | 8 | from __future__ import annotations 9 | 10 | import typing as t 11 | 12 | from pydantic import conint 13 | 14 | __all__ = ["Counter", "concounter"] 15 | 16 | 17 | class CounterType: 18 | pass 19 | 20 | 21 | #: Keep count of a repeated command-line option. 22 | #: 23 | #: Examples 24 | #: -------- 25 | #: >>> import feud 26 | #: >>> from feud.typing import Counter 27 | #: >>> @feud.alias(verbose="-v") 28 | #: ... def func(*, verbose: Counter) -> int: 29 | #: ... return verbose 30 | #: >>> feud.run(func, ["-vvv"], standalone_mode=False) 31 | #: 3 32 | Counter = t.Annotated[int, CounterType] 33 | 34 | 35 | def concounter( 36 | *, 37 | strict: bool | None = None, 38 | gt: int | None = None, 39 | ge: int | None = None, 40 | lt: int | None = None, 41 | le: int | None = None, 42 | multiple_of: int | None = None, 43 | ) -> t.Annotated[int, ...]: 44 | """Wrap :py:obj:`pydantic.types.conint` to allow for constrained counting 45 | options. 46 | 47 | Parameters 48 | ---------- 49 | strict: 50 | Whether to validate the integer in strict mode. Defaults to ``None``. 51 | 52 | gt: 53 | The value must be greater than this. 54 | 55 | ge: 56 | The value must be greater than or equal to this. 57 | 58 | lt: 59 | The value must be less than this. 60 | 61 | le: 62 | The value must be less than or equal to this. 63 | 64 | multiple_of: 65 | The value must be a multiple of this. 66 | 67 | Returns 68 | ------- 69 | The wrapped :py:obj:`pydantic.types.conint` type. 70 | 71 | Examples 72 | -------- 73 | >>> import feud 74 | >>> from feud.typing import concounter 75 | >>> @feud.alias(verbose="-v") 76 | ... def func(*, verbose: concounter(le=3)) -> int: 77 | ... return verbose 78 | >>> feud.run(func, ["-vvv"], standalone_mode=False) 79 | 3 80 | """ 81 | return t.Annotated[ # type: ignore[return-value] 82 | conint( 83 | strict=strict, 84 | gt=gt, 85 | ge=ge, 86 | lt=lt, 87 | le=le, 88 | multiple_of=multiple_of, 89 | ), 90 | CounterType, 91 | ] 92 | 93 | 94 | def is_counter(hint: t.Any) -> bool: 95 | args = t.get_args(hint) 96 | if len(args) > 1: 97 | return args[0] is int and CounterType in args 98 | return False 99 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import click 9 | import pytest 10 | 11 | from feud import typing as t 12 | from feud._internal._types.click import Union 13 | from feud.config import Config 14 | 15 | 16 | def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: 17 | return t.Annotated[hint, "annotation"] 18 | 19 | 20 | @pytest.mark.parametrize("annotated", [False, True]) 21 | @pytest.mark.parametrize( 22 | ("hint", "expected"), 23 | [ 24 | (t.Any, None), 25 | (t.Text, click.STRING), 26 | (t.Pattern, None), 27 | (t.Optional[int], click.INT), 28 | (t.Optional[annotate(int)], click.INT), 29 | ( 30 | t.Literal["a", "b"], 31 | lambda x: isinstance(x, click.Choice) and x.choices == ["a", "b"], 32 | ), 33 | (t.Tuple, None), 34 | (t.Tuple[int], (click.INT,)), 35 | (t.Tuple[annotate(int)], (click.INT,)), 36 | (t.Tuple[int, str], (click.INT, click.STRING)), 37 | (t.Tuple[annotate(int), annotate(str)], (click.INT, click.STRING)), 38 | (t.Tuple[int, ...], click.INT), 39 | (t.Tuple[annotate(int), ...], click.INT), 40 | (t.List, None), 41 | (t.List[int], click.INT), 42 | (t.List[annotate(int)], click.INT), 43 | (t.Set, None), 44 | (t.Set[int], click.INT), 45 | (t.Set[annotate(int)], click.INT), 46 | (t.FrozenSet, None), 47 | (t.FrozenSet[int], click.INT), 48 | (t.FrozenSet[annotate(int)], click.INT), 49 | (t.Deque, None), 50 | (t.Deque[int], click.INT), 51 | (t.Deque[annotate(int)], click.INT), 52 | (t.NamedTuple("Point", x=int, y=str), (click.INT, click.STRING)), 53 | ( 54 | t.NamedTuple("Point", x=annotate(int), y=annotate(str)), 55 | (click.INT, click.STRING), 56 | ), 57 | ( 58 | t.Union[int, str], 59 | lambda x: isinstance(x, Union) 60 | and x.types == [click.INT, click.STRING], 61 | ), 62 | ], 63 | ) 64 | def test_typing( 65 | helpers: type, 66 | *, 67 | config: Config, 68 | annotated: bool, 69 | hint: t.Any, 70 | expected: click.ParamType | None, 71 | ) -> None: 72 | helpers.check_get_click_type( 73 | config=config, 74 | annotated=annotated, 75 | hint=hint, 76 | expected=expected, 77 | ) 78 | -------------------------------------------------------------------------------- /docs/source/sections/core/command.rst: -------------------------------------------------------------------------------- 1 | Commands 2 | ======== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | Commands are the core component of a CLI, running a user-defined function that 11 | may be parameterized with arguments or options. 12 | 13 | Commands may be included within :doc:`group`, which usually contain a set of related commands. 14 | 15 | Commands may be executed using :py:func:`.run`. 16 | 17 | .. seealso:: 18 | 19 | The Click API documentation does a great job at clarifying the following command-line terminology: 20 | 21 | - `Commands and groups `__ 22 | - `Parameters `__ 23 | 24 | - `Arguments `__ 25 | - `Options `__ 26 | 27 | ---- 28 | 29 | Understanding function signatures 30 | --------------------------------- 31 | 32 | To understand how Feud converts a function into a :py:class:`click.Command`, 33 | consider the following function. 34 | 35 | .. code:: python 36 | 37 | # func.py 38 | 39 | import feud 40 | 41 | def func(arg1: int, arg2: str, *, opt1: float, opt2: int = 0): 42 | ... 43 | 44 | if __name__ == "__main__": 45 | feud.run(func) 46 | 47 | This function signature consists of: 48 | 49 | - two *positional* parameters ``arg1`` and ``arg2``, 50 | - two *keyword-only* parameters ``opt1`` and ``opt2``. 51 | 52 | .. note:: 53 | 54 | The ``*`` operator in Python is used to indicate where positional parameters end and keyword-only parameters begin. 55 | 56 | When calling ``func`` in Python: 57 | 58 | - values for the positional parameters can be provided without specifying the parameter name, 59 | - values for the keyword-only parameters must be provided by specifying the parameter name. 60 | 61 | .. code:: python 62 | 63 | func(1, "hello", opt1=2.0, opt2=3) 64 | 65 | where ``arg1`` takes the value ``1``, and ``arg2`` takes the value ``"hello"``. 66 | 67 | Similarly, when building a :py:class:`click.Command`, Feud treats: 68 | 69 | - positional parameters as *arguments* (specified **without providing** a parameter name), 70 | - keyword-only parameters as *options* (specified **by providing** a parameter name). 71 | 72 | .. code:: console 73 | 74 | $ python func.py 1 hello --opt1 2.0 --opt2 3 75 | 76 | Note that ``--opt1`` is a required option as it has no default specified, whereas ``--opt2`` is not required. 77 | 78 | API reference 79 | ------------- 80 | 81 | .. autofunction:: feud.core.command 82 | -------------------------------------------------------------------------------- /feud/_internal/_docstring.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import typing as t 9 | 10 | import docstring_parser 11 | 12 | from feud import click 13 | 14 | 15 | def get_description( 16 | obj: docstring_parser.Docstring | click.Command | t.Callable | str, 17 | /, 18 | ) -> str | None: 19 | """Retrieve the description section of a docstring. 20 | 21 | Modified from https://github.com/rr-/docstring_parser/pull/83. 22 | 23 | The MIT License (MIT) 24 | 25 | Copyright (c) 2018 Marcin Kurczewski 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining 28 | a copy of this software and associated documentation files 29 | (the "Software"), to deal in the Software without restriction, 30 | including without limitation the rights to use, copy, modify, merge, 31 | publish, distribute, sublicense, and/or sell copies of the Software, 32 | and to permit persons to whom the Software is furnished to do so, 33 | subject to the following conditions: 34 | 35 | The above copyright notice and this permission notice shall be 36 | included in all copies or substantial portions of the Software. 37 | 38 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 39 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 40 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 41 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 42 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 43 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 44 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 45 | SOFTWARE. 46 | """ 47 | doc: docstring_parser.Docstring | None = None 48 | 49 | if isinstance(obj, str): 50 | doc = docstring_parser.parse(obj) 51 | elif isinstance(obj, click.Command): 52 | if func := getattr(obj, "__func__", None): 53 | doc = docstring_parser.parse_from_object(func) 54 | elif isinstance(obj, docstring_parser.Docstring): 55 | doc = obj 56 | elif callable(obj): 57 | doc = docstring_parser.parse_from_object(obj) 58 | 59 | ret = None 60 | if doc: 61 | ret = [] 62 | if doc.short_description: 63 | ret.append(doc.short_description) 64 | if doc.blank_after_short_description: 65 | ret.append("") 66 | if doc.long_description: 67 | ret.append(doc.long_description) 68 | 69 | if ret is None or ret == []: 70 | return None 71 | 72 | return "\n".join(ret).strip() 73 | -------------------------------------------------------------------------------- /feud/version.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Version information for Feud. 7 | 8 | Source code modified from pydantic (https://github.com/pydantic/pydantic). 9 | 10 | The MIT License (MIT) 11 | 12 | Copyright (c) 2017 to present Pydantic Services Inc. and individual 13 | contributors. 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a 16 | copy of this software and associated documentation files (the "Software"), 17 | to deal in the Software without restriction, including without limitation 18 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 19 | and/or sell copies of the Software, and to permit persons to whom the 20 | Software is furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in 23 | all copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 28 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 30 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 31 | DEALINGS IN THE SOFTWARE. 32 | """ 33 | 34 | __all__ = ["VERSION", "version_info"] 35 | 36 | VERSION = "1.0.1" 37 | 38 | 39 | def version_info() -> str: 40 | """Return complete version information for Feud and its dependencies.""" 41 | import importlib.metadata as importlib_metadata 42 | import platform 43 | import sys 44 | from pathlib import Path 45 | 46 | # get data about packages that: 47 | # - are closely related to feud, 48 | # - use feud, 49 | # - often conflict with feud. 50 | package_names = { 51 | "click", 52 | "pydantic", 53 | "docstring-parser", 54 | "rich-click", 55 | "rich", 56 | "email-validator", 57 | "pydantic-extra-types", 58 | "phonenumbers", 59 | "pycountry", 60 | } 61 | related_packages = [] 62 | 63 | for dist in importlib_metadata.distributions(): 64 | name = dist.metadata["Name"] 65 | if name in package_names: 66 | related_packages.append(f"{name}-{dist.version}") 67 | 68 | info = { 69 | "feud version": VERSION, 70 | "install path": Path(__file__).resolve().parent, 71 | "python version": sys.version, 72 | "platform": platform.platform(), 73 | "related packages": " ".join(related_packages), 74 | } 75 | return "\n".join( 76 | "{:>30} {}".format(k + ":", str(v).replace("\n", " ")) 77 | for k, v in info.items() 78 | ) 79 | -------------------------------------------------------------------------------- /feud/typing/pydantic_extra_types.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Officially supported types from the ``pydantic-extra-types`` package.""" 7 | 8 | from __future__ import annotations 9 | 10 | __all__: list[str] = [] 11 | 12 | from operator import attrgetter 13 | 14 | import packaging.version 15 | 16 | 17 | def split(string: str) -> str: 18 | return string.split(".")[-1] 19 | 20 | 21 | try: 22 | import pydantic_extra_types 23 | 24 | version: packaging.version.Version = packaging.version.parse( 25 | pydantic_extra_types.__version__, 26 | ) 27 | 28 | if version >= packaging.version.parse("2.1.0"): 29 | import pydantic_extra_types.color 30 | import pydantic_extra_types.coordinate 31 | import pydantic_extra_types.country 32 | import pydantic_extra_types.mac_address 33 | import pydantic_extra_types.payment 34 | import pydantic_extra_types.phone_numbers 35 | import pydantic_extra_types.routing_number 36 | 37 | types: list[str] = [ 38 | "color.Color", 39 | "coordinate.Coordinate", 40 | "coordinate.Latitude", 41 | "coordinate.Longitude", 42 | "country.CountryAlpha2", 43 | "country.CountryAlpha3", 44 | "country.CountryNumericCode", 45 | "country.CountryShortName", 46 | "mac_address.MacAddress", 47 | "payment.PaymentCardBrand", 48 | "payment.PaymentCardNumber", 49 | "phone_numbers.PhoneNumber", 50 | "routing_number.ABARoutingNumber", 51 | ] 52 | 53 | if version < packaging.version.parse("2.4.0"): 54 | types.append("country.CountryOfficialName") 55 | 56 | globals().update( 57 | { 58 | split(attr): attrgetter(attr)(pydantic_extra_types) 59 | for attr in types 60 | } 61 | ) 62 | 63 | __all__.extend(map(split, types)) 64 | 65 | if version >= packaging.version.parse("2.2.0"): 66 | from pydantic_extra_types.ulid import ULID 67 | 68 | __all__.append("ULID") 69 | 70 | if version >= packaging.version.parse("2.4.0"): 71 | from pydantic_extra_types.isbn import ISBN 72 | 73 | __all__.append("ISBN") 74 | 75 | if version >= packaging.version.parse("2.7.0"): 76 | from pydantic_extra_types.language_code import ( 77 | LanguageAlpha2, 78 | LanguageName, 79 | ) 80 | 81 | __all__.extend(["LanguageAlpha2", "LanguageName"]) 82 | 83 | if version >= packaging.version.parse("2.9.0"): 84 | from pydantic_extra_types.semantic_version import SemanticVersion 85 | 86 | __all__.append("SemanticVersion") 87 | 88 | if version >= packaging.version.parse("2.10.0"): 89 | from pydantic_extra_types.s3 import S3Path 90 | 91 | __all__.append("S3Path") 92 | 93 | except ImportError: 94 | pass 95 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = """ 11 | # Changelog\n 12 | All notable changes to this project will be documented in this file.\n 13 | """ 14 | # template for the changelog body 15 | # https://keats.github.io/tera/docs/#introduction 16 | body = """ 17 | {% if version %}\ 18 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 19 | {% else %}\ 20 | ## [unreleased] 21 | {% endif %}\ 22 | {% for group, commits in commits | group_by(attribute="group") %} 23 | ### {{ group | upper_first }} 24 | {% for commit in commits %} 25 | {% if commit.message is matching("^.*\\(#\\d+\\)$") %}\ 26 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}\ 27 | {% endif %}\ 28 | {% endfor %} 29 | {% endfor %}\n 30 | """ 31 | # remove the leading and trailing whitespace from the template 32 | trim = true 33 | # changelog footer 34 | footer = """ 35 | 36 | """ 37 | # postprocessors 38 | postprocessors = [ 39 | { pattern = '## \[([0-9]+\.[0-9]+\.[0-9]+[a-z0-9]*)\]', replace = "## [v${1}](https://github.com/eonu/feud/releases/tag/v${1})" }, 40 | { pattern = '\(#([0-9]+)\)', replace = "([#${1}](https://github.com/eonu/feud/issues/${1}))" }, 41 | { pattern = '\n\n\n', replace = "\n\n" }, 42 | ] 43 | 44 | [git] 45 | # parse the commits based on https://www.conventionalcommits.org 46 | conventional_commits = true 47 | # filter out the commits that are not conventional 48 | filter_unconventional = true 49 | # process each line of a commit as an individual commit 50 | split_commits = false 51 | # regex for preprocessing the commit messages 52 | commit_preprocessors = [ 53 | # { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, # replace issue numbers 54 | ] 55 | 56 | # regex for parsing and grouping commits 57 | commit_parsers = [ 58 | { message = "^build", group = "Build System" }, 59 | # { message = "^chore|ci", group = "Miscellaneous Tasks" }, 60 | { message = "^doc", group = "Documentation" }, 61 | { message = "^feat", group = "Features" }, 62 | { message = "^fix", group = "Bug Fixes" }, 63 | { message = "^perf", group = "Performance" }, 64 | { message = "^refactor", group = "Refactor" }, 65 | # { message = "^release", group = "Release" }, 66 | { message = "^revert", group = "Reversions" }, 67 | { message = "^style", group = "Styling" }, 68 | { message = "^test", group = "Testing" }, 69 | ] 70 | # protect breaking changes from being skipped due to matching a skipping commit_parser 71 | protect_breaking_commits = false 72 | # filter out the commits that are not matched by commit parsers 73 | filter_commits = true 74 | # regex for matching git tags 75 | tag_pattern = "v[0-9].*" 76 | 77 | # regex for skipping tags 78 | skip_tags = "v0.1.0-beta.1" 79 | # regex for ignoring tags 80 | ignore_tags = "" 81 | # sort the tags topologically 82 | topo_order = false 83 | # sort the commits inside sections by oldest/newest order 84 | sort_commits = "oldest" 85 | # limit the number of commits included in the changelog. 86 | # limit_commits = 42 87 | -------------------------------------------------------------------------------- /feud/typing/pydantic.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Officially supported types from the ``pydantic`` package.""" 7 | 8 | from __future__ import annotations 9 | 10 | __all__: list[str] = [] 11 | 12 | import packaging.version 13 | import pydantic 14 | 15 | version: packaging.version.Version = packaging.version.parse( 16 | pydantic.__version__, 17 | ) 18 | 19 | if version >= packaging.version.parse("2.0.3"): 20 | __all__.extend( 21 | [ 22 | "UUID1", 23 | "UUID3", 24 | "UUID4", 25 | "UUID5", 26 | "AmqpDsn", 27 | "AnyHttpUrl", 28 | "AnyUrl", 29 | "AwareDatetime", 30 | "Base64Bytes", 31 | "Base64Str", 32 | "ByteSize", 33 | "CockroachDsn", 34 | "DirectoryPath", 35 | "EmailStr", 36 | "FilePath", 37 | "FileUrl", 38 | "FiniteFloat", 39 | "FutureDate", 40 | "FutureDatetime", 41 | "HttpUrl", 42 | "IPvAnyAddress", 43 | "IPvAnyInterface", 44 | "IPvAnyNetwork", 45 | "ImportString", 46 | "Json", 47 | "KafkaDsn", 48 | "MariaDBDsn", 49 | "MongoDsn", 50 | "MySQLDsn", 51 | "NaiveDatetime", 52 | "NameEmail", 53 | "NegativeFloat", 54 | "NegativeInt", 55 | "NewPath", 56 | "NonNegativeFloat", 57 | "NonNegativeInt", 58 | "NonPositiveFloat", 59 | "NonPositiveInt", 60 | "PastDate", 61 | "PastDatetime", 62 | "PositiveFloat", 63 | "PositiveInt", 64 | "PostgresDsn", 65 | "RedisDsn", 66 | "SecretBytes", 67 | "SecretStr", 68 | "SkipValidation", 69 | "StrictBool", 70 | "StrictBytes", 71 | "StrictFloat", 72 | "StrictInt", 73 | "StrictStr", 74 | "conbytes", 75 | "condate", 76 | "condecimal", 77 | "confloat", 78 | "confrozenset", 79 | "conint", 80 | "conlist", 81 | "conset", 82 | "constr", 83 | ] 84 | ) 85 | 86 | if version >= packaging.version.parse("2.4.0"): 87 | __all__.extend(["Base64UrlBytes", "Base64UrlStr"]) 88 | 89 | if version >= packaging.version.parse("2.5.0"): 90 | __all__.extend(["JsonValue"]) 91 | 92 | if version >= packaging.version.parse("2.6.0"): 93 | __all__.extend(["NatsDsn"]) 94 | 95 | if version >= packaging.version.parse("2.7.0"): 96 | __all__.extend(["ClickHouseDsn"]) 97 | 98 | if version >= packaging.version.parse("2.7.1"): 99 | __all__.extend(["AnyWebsocketUrl", "FtpUrl", "WebsocketUrl"]) 100 | 101 | if version >= packaging.version.parse("2.9.0"): 102 | __all__.extend(["SnowflakeDsn"]) 103 | 104 | if version >= packaging.version.parse("2.10.0"): 105 | __all__.extend(["SocketPath"]) 106 | 107 | globals().update({attr: getattr(pydantic, attr) for attr in __all__}) 108 | -------------------------------------------------------------------------------- /docs/source/sections/typing/index.rst: -------------------------------------------------------------------------------- 1 | Typing 2 | ====== 3 | 4 | Feud has a rich typing system based on `Pydantic `__, 5 | allowing for CLIs that support Python standard library types, as well as complex types 6 | such as emails, IP addresses, file/directory paths, database connection strings and more. 7 | 8 | All of the below types are made easily accessible through the :py:mod:`feud.typing` module. 9 | 10 | .. tip:: 11 | 12 | It is recommended to import the :py:mod:`feud.typing` module with an alias such as ``t`` for convenient short-hand use, e.g. 13 | 14 | .. code:: python 15 | 16 | from feud import typing as t 17 | 18 | ---- 19 | 20 | .. toctree:: 21 | :titlesonly: 22 | 23 | stdlib.rst 24 | pydantic.rst 25 | pydantic_extra_types.rst 26 | other.rst 27 | 28 | Usage 29 | ----- 30 | 31 | Feud relies on type hints to determine the type for command-line argument/option input values. 32 | 33 | .. dropdown:: Example 34 | :animate: fade-in 35 | 36 | Suppose we want a command for serving local files on a HTTP server with: 37 | 38 | - **Arguments**: 39 | - ``PORT``: Port to start the server on. 40 | - **Options**: 41 | - ``--watch/--no-watch``: Whether or not restart the server if source code changes. 42 | - ``--env``: Envionment mode to launch the server in (either ``dev`` or ``prod``). 43 | 44 | By default we should watch for changes and use the development environment. 45 | 46 | .. tab-set:: 47 | 48 | .. tab-item:: Code 49 | 50 | .. code:: python 51 | 52 | # serve.py 53 | 54 | import feud 55 | from feud import typing as t 56 | 57 | def serve(port: t.PositiveInt, *, watch: bool = True, env: t.Literal["dev", "prod"] = "dev"): 58 | """Start a local HTTP server. 59 | 60 | Parameters 61 | ---------- 62 | port: 63 | Server port. 64 | watch: 65 | Watch source code for changes. 66 | env: 67 | Environment mode. 68 | """ 69 | print(f"{port=!r} ({type(port)})") 70 | print(f"{watch=!r} ({type(watch)})") 71 | print(f"{env=!r} ({type(env)})") 72 | 73 | if __name__ == "__main__": 74 | feud.run(serve) 75 | 76 | .. tab-item:: Help screen 77 | 78 | .. code:: bash 79 | 80 | $ python serve.py --help 81 | 82 | .. image:: /_static/images/help/typing.png 83 | :alt: serve.py help screen 84 | 85 | .. tab-item:: Usage 86 | 87 | .. card:: Valid input 88 | 89 | .. code:: bash 90 | 91 | $ python serve.py 8080 --no-watch --env prod 92 | 93 | .. code:: 94 | 95 | port=8080 () 96 | watch=False () 97 | env='prod' () 98 | 99 | .. card:: Invalid input 100 | 101 | .. code:: bash 102 | 103 | $ python serve.py 8080 --env test 104 | 105 | .. image:: /_static/images/examples/typing.png 106 | :alt: serve.py error 107 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Configuration file for the Sphinx documentation builder. 7 | 8 | For the full list of built-in configuration values, see the documentation: 9 | https://www.sphinx-doc.org/en/master/usage/configuration.html 10 | 11 | Project information: 12 | https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 13 | """ 14 | 15 | import os 16 | import sys 17 | 18 | sys.path.insert(0, os.path.abspath("../..")) 19 | 20 | project = "feud" 21 | copyright = "2023, Feud Developers" # noqa: A001 22 | author = "Edwin Onuonga (eonu)" 23 | release = "1.0.1" 24 | 25 | # -- General configuration --------------------------------------------------- 26 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 27 | 28 | extensions = [ 29 | "sphinx.ext.autodoc", 30 | "sphinx.ext.napoleon", 31 | "sphinx.ext.autosummary", 32 | # "sphinx.ext.mathjax", 33 | # "sphinx.ext.viewcode", 34 | "sphinx.ext.intersphinx", 35 | "numpydoc", 36 | "sphinx_favicon", 37 | "sphinx_design", 38 | "sphinxcontrib.autodoc_pydantic", 39 | ] 40 | 41 | intersphinx_mapping = { 42 | "python": ("https://docs.python.org/3", None), 43 | "pydantic": ("https://docs.pydantic.dev/latest", None), 44 | "click": ("http://click.pocoo.org/latest", None), 45 | } 46 | 47 | napoleon_numpy_docstring = True 48 | napoleon_use_admonition_for_examples = True 49 | autodoc_members = True 50 | autodoc_member_order = "groupwise" # bysource, groupwise, alphabetical 51 | autodoc_typehints = "description" 52 | autodoc_class_signature = "separated" 53 | autosummary_generate = True 54 | numpydoc_show_class_members = True 55 | 56 | # Set master document 57 | master_doc = "index" 58 | 59 | # The suffix(es) of source filenames. 60 | # You can specify multiple suffix as a list of string: 61 | source_suffix = [".rst"] 62 | 63 | # Add any paths that contain templates here, relative to this directory. 64 | templates_path = ["source/_templates"] 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | # This pattern also affects html_static_path and html_extra_path. 69 | exclude_patterns = [] 70 | 71 | # -- Options for HTML output ------------------------------------------------- 72 | 73 | # The theme to use for HTML and HTML Help pages. See the documentation for 74 | # a list of builtin themes. 75 | html_theme = "furo" 76 | 77 | # Add any paths that contain custom static files (such as style sheets) here, 78 | # relative to this directory. They are copied after the builtin static files, 79 | # so a file named "default.css" will overwrite the builtin "default.css". 80 | html_static_path = ["_static"] 81 | 82 | # Logos and favicons 83 | html_logo = "_static/images/logo/logo.svg" 84 | favicons = [ 85 | {"href": "images/logo/logo.svg"}, 86 | {"href": "images/favicon/favicon/favicon.ico"}, 87 | {"href": "images/favicon/favicon/favicon-16x16.png"}, 88 | {"href": "images/favicon/favicon/favicon-32x32.png"}, 89 | { 90 | "rel": "apple-touch-icon", 91 | "href": "images/logo/favicon/apple-touch-icon-180x180.png", 92 | }, 93 | ] 94 | 95 | 96 | # Custom stylesheets 97 | def setup(app) -> None: # noqa: ANN001, D103 98 | app.add_css_file("css/heading.css") 99 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/test_literal.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | from collections import deque 9 | 10 | import click 11 | import pytest 12 | 13 | from feud import typing as t 14 | from feud.config import Config 15 | 16 | 17 | def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: 18 | return t.Annotated[hint, "annotation"] 19 | 20 | 21 | @pytest.mark.parametrize("annotated", [False, True]) 22 | @pytest.mark.parametrize( 23 | ("hint", "expected"), 24 | [ 25 | (bool, click.BOOL), 26 | (bool | None, click.BOOL), 27 | (annotate(bool) | None, click.BOOL), 28 | (int, click.INT), 29 | (int | None, click.INT), 30 | (annotate(int) | None, click.INT), 31 | (float, click.FLOAT), 32 | (float | None, click.FLOAT), 33 | (annotate(float) | None, click.FLOAT), 34 | (str, click.STRING), 35 | (str | None, click.STRING), 36 | (annotate(str) | None, click.STRING), 37 | (tuple, None), 38 | (tuple[int], (click.INT,)), 39 | (tuple[annotate(int)], (click.INT,)), 40 | (tuple[int, str], (click.INT, click.STRING)), 41 | (tuple[annotate(int), annotate(str)], (click.INT, click.STRING)), 42 | (tuple[int, ...], click.INT), 43 | (tuple[annotate(int), ...], click.INT), 44 | (tuple | None, None), 45 | (annotate(tuple) | None, None), 46 | (tuple[int] | None, (click.INT,)), 47 | (tuple[annotate(int)] | None, (click.INT,)), 48 | (tuple[int, str] | None, (click.INT, click.STRING)), 49 | ( 50 | tuple[annotate(int), annotate(str)] | None, 51 | (click.INT, click.STRING), 52 | ), 53 | (tuple[int, ...] | None, click.INT), 54 | (tuple[annotate(int), ...] | None, click.INT), 55 | (list, None), 56 | (list[int], click.INT), 57 | (list[annotate(int)], click.INT), 58 | (list | None, None), 59 | (annotate(list) | None, None), 60 | (list[int] | None, click.INT), 61 | (list[annotate(int)] | None, click.INT), 62 | (set, None), 63 | (set[int], click.INT), 64 | (set[annotate(int)], click.INT), 65 | (set | None, None), 66 | (annotate(set) | None, None), 67 | (set[int] | None, click.INT), 68 | (set[annotate(int)] | None, click.INT), 69 | (frozenset, None), 70 | (frozenset[int], click.INT), 71 | (frozenset[annotate(int)], click.INT), 72 | (frozenset | None, None), 73 | (annotate(frozenset) | None, None), 74 | (frozenset[int] | None, click.INT), 75 | (frozenset[annotate(int)] | None, click.INT), 76 | (deque, None), 77 | (deque[int], click.INT), 78 | (deque[annotate(int)], click.INT), 79 | (deque | None, None), 80 | (annotate(deque) | None, None), 81 | (deque[int] | None, click.INT), 82 | (deque[annotate(int)] | None, click.INT), 83 | (type(None), None), 84 | (annotate(type(None)) | None, None), 85 | ], 86 | ) 87 | def test_literal( 88 | helpers: type, 89 | *, 90 | config: Config, 91 | annotated: bool, 92 | hint: t.Any, 93 | expected: click.ParamType | None, 94 | ) -> None: 95 | helpers.check_get_click_type( 96 | config=config, 97 | annotated=annotated, 98 | hint=hint, 99 | expected=expected, 100 | ) 101 | -------------------------------------------------------------------------------- /docs/source/sections/typing/pydantic_extra_types.rst: -------------------------------------------------------------------------------- 1 | Pydantic extra types 2 | ==================== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | `Pydantic Extra Types `__ 11 | is a package that extends `Pydantic `__ 12 | with support for additional types. 13 | 14 | The following types can be used as type hints for Feud commands. 15 | 16 | .. important:: 17 | 18 | This page may only list a subset of types from the Pydantic Extra Types package. 19 | 20 | All `extra types `__ 21 | (including those not listed on this page) are compatible with Feud. 22 | 23 | .. tip:: 24 | 25 | All of the types listed on this page are easily accessible from the :py:mod:`feud.typing` module. 26 | 27 | It is recommended to import the :py:mod:`feud.typing` module with an alias such as ``t`` for convenient short-hand use, e.g. 28 | 29 | .. code:: python 30 | 31 | from feud import typing as t 32 | 33 | t.PhoneNumber # pydantic_extra_types.phone_number.PhoneNumbers 34 | t.PaymentCardNumber # pydantic_extra_types.payment.PaymentCardNumber 35 | t.Latitude # pydantic_extra_types.coordinate.Latitude 36 | t.Color # pydantic_extra_types.color.Color 37 | 38 | ---- 39 | 40 | The version number indicates the minimum ``pydantic-extra-types`` version required to use the type. 41 | 42 | If this version requirement is not met, the type is not imported by Feud. 43 | 44 | Color type 45 | ---------- 46 | 47 | - :py:obj:`pydantic_extra_types.color.Color` (``>= 2.1.0``) 48 | 49 | Coordinate types 50 | ---------------- 51 | 52 | - :py:obj:`pydantic_extra_types.coordinate.Coordinate` (``>= 2.1.0``) 53 | - :py:obj:`pydantic_extra_types.coordinate.Latitude` (``>= 2.1.0``) 54 | - :py:obj:`pydantic_extra_types.coordinate.Longitude` (``>= 2.1.0``) 55 | 56 | Country types 57 | ------------- 58 | 59 | - :py:obj:`pydantic_extra_types.country.CountryAlpha2` (``>= 2.1.0``) 60 | - :py:obj:`pydantic_extra_types.country.CountryAlpha3` (``>= 2.1.0``) 61 | - :py:obj:`pydantic_extra_types.country.CountryNumericCode` (``>= 2.1.0``) 62 | - :py:obj:`pydantic_extra_types.country.CountryOfficialName` (``>= 2.1.0, <2.4.0``) 63 | - :py:obj:`pydantic_extra_types.country.CountryShortName` (``>= 2.1.0``) 64 | 65 | ISBN type 66 | --------- 67 | 68 | - :py:obj:`pydantic_extra_types.isbn.ISBN` (``>= 2.4.0``) 69 | 70 | Language types 71 | -------------- 72 | 73 | - :py:obj:`pydantic_extra_types.language_code.LanguageAlpha2` (``>= 2.7.0``) 74 | - :py:obj:`pydantic_extra_types.language_code.LanguageName` (``>= 2.7.0``) 75 | 76 | MAC address type 77 | ---------------- 78 | 79 | - :py:obj:`pydantic_extra_types.mac_address.MacAddress` (``>= 2.1.0``) 80 | 81 | Phone number type 82 | ----------------- 83 | 84 | - :py:obj:`pydantic_extra_types.phone_numbers.PhoneNumber` (``>= 2.1.0``) 85 | 86 | Path types 87 | ---------- 88 | 89 | - :py:obj:`pydantic_extra_types.s3.S3Path` (``>= 2.10.0``) 90 | 91 | Payment types 92 | ------------- 93 | 94 | - :py:obj:`pydantic_extra_types.payment.PaymentCardBrand` (``>= 2.1.0``) 95 | - :py:obj:`pydantic_extra_types.payment.PaymentCardNumber` (``>= 2.1.0``) 96 | 97 | Routing number type 98 | ------------------- 99 | 100 | - :py:obj:`pydantic_extra_types.routing_number.ABARoutingNumber` (``>= 2.1.0``) 101 | 102 | Semantic version type 103 | --------------------- 104 | 105 | - :py:obj:`pydantic_extra_types.semantic_version.SemanticVersion` (``>= 2.9.0``) 106 | 107 | ULID type 108 | --------- 109 | 110 | - :py:obj:`pydantic_extra_types.ulid.ULID` (``>= 2.2.0``) 111 | -------------------------------------------------------------------------------- /feud/_internal/_decorators.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import functools as ft 9 | import inspect 10 | import re 11 | import typing as t 12 | 13 | import pydantic as pyd 14 | import pydantic_core as pydc 15 | 16 | from feud import click 17 | 18 | AnyCallableT = t.TypeVar("AnyCallableT", bound=t.Callable[..., t.Any]) 19 | 20 | 21 | def validate_call( 22 | func: t.Callable, 23 | /, 24 | *, 25 | name: str, 26 | param_renames: dict[str, str], 27 | meta_vars: dict[str, str], 28 | sensitive_vars: dict[str, bool], 29 | positional: list[str], 30 | var_positional: str | None, 31 | pydantic_kwargs: dict[str, t.Any], 32 | ) -> t.Callable[[AnyCallableT], AnyCallableT]: 33 | @ft.wraps(func) 34 | def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Callable: 35 | try: 36 | # move positional arguments 37 | for arg in positional: 38 | pos_arg = kwargs.pop(arg, inspect._empty) # noqa: SLF001 39 | if pos_arg is not inspect._empty: # noqa: SLF001 40 | args += (pos_arg,) 41 | 42 | # move *args to positional arguments 43 | if var_positional is not None: 44 | var_pos_args = kwargs.pop( 45 | var_positional, 46 | inspect._empty, # noqa: SLF001 47 | ) 48 | if var_pos_args is not inspect._empty: # noqa: SLF001 49 | args += var_pos_args 50 | 51 | # apply renaming for any options 52 | inv_mapping = {v: k for k, v in param_renames.items()} 53 | true_kwargs = {inv_mapping.get(k, k): v for k, v in kwargs.items()} 54 | 55 | # create Pydantic configuration 56 | config = pyd.ConfigDict( 57 | **pydantic_kwargs, # type: ignore[typeddict-item] 58 | ) 59 | 60 | # validate the function call 61 | return pyd.validate_call( # type: ignore[call-overload] 62 | func, 63 | config=config, 64 | )( 65 | *args, 66 | **true_kwargs, 67 | ) 68 | except pyd.ValidationError as e: 69 | msg = re.sub( 70 | r"validation error(s?) for (.*)\n", 71 | rf"validation error\1 for command {name!r}\n", 72 | str(e), 73 | ) 74 | for param, meta_var in meta_vars.items(): 75 | msg = re.sub( 76 | rf"\n({param})(\.(\d+))?", rf"\n{meta_var} [\3]", msg 77 | ) 78 | msg = re.sub(r"\s\[\].*\n", "\n", msg) 79 | msg = re.sub(r"\[type=.*, (input_value=.*)", r"[\1", msg) 80 | msg = re.sub(r"(.*), input_type=.*\]", r"\1]", msg) 81 | msg = re.sub( 82 | r"\n\s+For further information visit.*(\n?)", r"\1", msg 83 | ) 84 | if sensitive_vars[param]: 85 | msg = re.sub( 86 | rf"({meta_var}\s*\n\s.*\[input_value=).*(\])", 87 | r"\1hidden\2", 88 | msg, 89 | ) 90 | raise click.UsageError(msg) from None 91 | except pydc.SchemaError as e: 92 | msg = re.sub( 93 | r'^Error building "call" validator:', 94 | f"Error building command {name!r}", 95 | str(e), 96 | ) 97 | raise click.ClickException(msg) from None 98 | 99 | return wrapper # type: ignore[return-value] 100 | -------------------------------------------------------------------------------- /feud/_internal/_sections.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import dataclasses 9 | from collections import defaultdict 10 | 11 | from feud import click 12 | from feud._internal import _meta 13 | 14 | 15 | def add_command_sections(group: click.Group, context: list[str]) -> None: 16 | from rich_click.utils import CommandGroupDict 17 | 18 | if feud_group := getattr(group, "__group__", None): 19 | command_groups: dict[str, list[CommandGroupDict]] = { 20 | " ".join(context): [ 21 | CommandGroupDict( 22 | name=section.name, 23 | commands=[ 24 | item if isinstance(item, str) else item.name() 25 | for item in section.items 26 | ], 27 | ) 28 | for section in feud_group.__sections__() 29 | ] 30 | } 31 | 32 | for sub in group.commands.values(): 33 | if sub.name and isinstance(sub, click.Group): 34 | add_command_sections(sub, context=[*context, sub.name]) 35 | 36 | settings = group.context_settings 37 | if help_config := settings.get("rich_help_config"): 38 | settings["rich_help_config"] = dataclasses.replace( 39 | help_config, command_groups=command_groups 40 | ) 41 | else: 42 | settings["rich_help_config"] = click.RichHelpConfiguration( 43 | command_groups=command_groups 44 | ) 45 | 46 | 47 | def add_option_sections( 48 | obj: click.Command | click.Group, context: list[str] 49 | ) -> None: 50 | if isinstance(obj, click.Group): 51 | update_command(obj, context=context) 52 | for sub in obj.commands.values(): 53 | if sub.name is None: 54 | continue 55 | if isinstance(sub, click.Group): 56 | add_option_sections(sub, context=[*context, sub.name]) 57 | else: 58 | update_command(sub, context=[*context, sub.name]) 59 | else: 60 | update_command(obj, context=context) 61 | 62 | 63 | def get_opts(option: str, *, command: click.Command) -> list[str]: 64 | func = command.__func__ # type: ignore[attr-defined] 65 | name_map = lambda name: name # noqa: E731 66 | meta: _meta.FeudMeta | None = getattr(func, "__feud__", None) 67 | if meta and meta.names: 68 | names = meta.names 69 | name_map = lambda name: names["params"].get(name, name) # noqa: E731 70 | return next( 71 | param.opts 72 | for param in command.params 73 | if param.name == name_map(option) 74 | ) 75 | 76 | 77 | def update_command(command: click.Command, context: list[str]) -> None: 78 | from rich_click.utils import OptionGroupDict 79 | 80 | if func := getattr(command, "__func__", None): 81 | meta: _meta.FeudMeta | None = getattr(func, "__feud__", None) 82 | if meta and meta.sections: 83 | options = meta.sections 84 | sections = defaultdict(list) 85 | for option, section_name in options.items(): 86 | opts: list[str] = get_opts(option, command=command) 87 | sections[section_name].append(opts[0]) 88 | option_groups: dict[str, list[OptionGroupDict]] = { 89 | " ".join(context): [ 90 | OptionGroupDict(name=name, options=options) 91 | for name, options in sections.items() 92 | ] 93 | } 94 | 95 | settings = command.context_settings 96 | if help_config := settings.get("rich_help_config"): 97 | settings["rich_help_config"] = dataclasses.replace( 98 | help_config, option_groups=option_groups 99 | ) 100 | else: 101 | settings["rich_help_config"] = click.RichHelpConfiguration( 102 | option_groups=option_groups 103 | ) 104 | -------------------------------------------------------------------------------- /feud/core/command.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Generation of :py:class:`click.Command`s with automatically 7 | defined :py:class:`click.Argument`s and 8 | :py:class:`click.Option`s based on type hints, and help documentation 9 | based on docstrings. 10 | """ 11 | 12 | from __future__ import annotations 13 | 14 | import typing 15 | 16 | import pydantic as pyd 17 | 18 | from feud import click 19 | from feud._internal import _command 20 | from feud.config import Config 21 | 22 | __all__ = ["command"] 23 | 24 | 25 | @pyd.validate_call 26 | def command( 27 | func: typing.Callable | None = None, 28 | /, 29 | *, 30 | negate_flags: bool | None = None, 31 | show_help_defaults: bool | None = None, 32 | show_help_datetime_formats: bool | None = None, 33 | show_help_envvars: bool | None = None, 34 | pydantic_kwargs: dict[str, typing.Any] | None = None, 35 | rich_click_kwargs: dict[str, typing.Any] | None = None, 36 | config: Config | None = None, 37 | **click_kwargs: typing.Any, 38 | ) -> click.Command: 39 | """Decorate a function and convert it into a 40 | :py:class:`click.Command` with automatically defined arguments, 41 | options and help documentation. 42 | 43 | Parameters 44 | ---------- 45 | func: 46 | Function used to generate a command. 47 | 48 | negate_flags: 49 | Whether to automatically add a negated variant for boolean flags. 50 | 51 | show_help_defaults: 52 | Whether to display default parameter values in command help. 53 | 54 | show_help_datetime_formats: 55 | Whether to display datetime parameter formats in command help. 56 | 57 | show_help_envvars: 58 | Whether to display environment variable names in command help. 59 | 60 | pydantic_kwargs: 61 | Validation settings for 62 | :py:func:`pydantic.validate_call_decorator.validate_call`. 63 | 64 | rich_click_kwargs: 65 | Styling settings for ``rich-click``. 66 | 67 | See all available options 68 | `here `__ 69 | (as of ``rich-click`` v1.7.2). 70 | 71 | config: 72 | Configuration for the command. 73 | 74 | This argument may be used either in place or in conjunction with the 75 | other arguments in this function. If a value is provided as both 76 | an argument to this function, as well as in the provided ``config``, 77 | the function argument value will take precedence. 78 | 79 | **click_kwargs: 80 | Keyword arguments to forward :py:func:`click.command`. 81 | 82 | Returns 83 | ------- 84 | click.Command 85 | The generated command. 86 | 87 | Examples 88 | -------- 89 | >>> import feud 90 | >>> @feud.command(name="my-command", negate_flags=False) 91 | ... def f(arg: int, *, opt: int) -> tuple[int, int]: 92 | ... return arg, opt 93 | >>> feud.run(f, ["3", "--opt", "-1"], standalone_mode=False) 94 | (3, -1) 95 | 96 | See Also 97 | -------- 98 | .run: 99 | Run a command or group. 100 | 101 | .Config 102 | Configuration defaults. 103 | """ 104 | 105 | def decorate(__func: typing.Callable, /) -> typing.Callable: 106 | # sanitize click kwargs 107 | _command.sanitize_click_kwargs(click_kwargs, name=__func.__name__) 108 | # create configuration 109 | cfg = Config._create( # noqa: SLF001 110 | base=config, 111 | negate_flags=negate_flags, 112 | show_help_defaults=show_help_defaults, 113 | show_help_datetime_formats=show_help_datetime_formats, 114 | show_help_envvars=show_help_envvars, 115 | pydantic_kwargs=pydantic_kwargs, 116 | rich_click_kwargs=rich_click_kwargs, 117 | ) 118 | # decorate function 119 | return _command.get_command( 120 | __func, config=cfg, click_kwargs=click_kwargs 121 | ) 122 | 123 | return decorate(func) if func else decorate # type: ignore[return-value] 124 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_metaclass.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import feud 7 | from feud import click 8 | 9 | 10 | def test_metaclass_default() -> None: 11 | class Test(feud.Group): 12 | def command(*, opt: bool) -> None: 13 | pass 14 | 15 | assert Test.__feud_config__ == feud.config() 16 | assert Test.__feud_click_kwargs__ == {"name": "test"} 17 | 18 | command = Test.command 19 | assert isinstance(command, click.Command) 20 | 21 | arg = command.params[0] 22 | assert arg.opts == ["--opt"] 23 | assert arg.secondary_opts == ["--no-opt"] 24 | 25 | 26 | def test_metaclass_base_config() -> None: 27 | config = feud.config(negate_flags=False) 28 | 29 | class Test(feud.Group, config=config): 30 | def command(*, opt: bool) -> None: 31 | pass 32 | 33 | assert Test.__feud_config__ == config 34 | assert Test.__feud_click_kwargs__ == {"name": "test"} 35 | 36 | command = Test.command 37 | assert isinstance(command, click.Command) 38 | 39 | arg = command.params[0] 40 | assert arg.opts == ["--opt"] 41 | assert arg.secondary_opts == [] 42 | 43 | 44 | def test_metaclass_feud_kwargs() -> None: 45 | class Test(feud.Group, negate_flags=False): 46 | def command(*, opt: bool) -> None: 47 | pass 48 | 49 | assert Test.__feud_config__ == feud.config(negate_flags=False) 50 | assert Test.__feud_click_kwargs__ == {"name": "test"} 51 | 52 | command = Test.command 53 | assert isinstance(command, click.Command) 54 | 55 | arg = command.params[0] 56 | assert arg.opts == ["--opt"] 57 | assert arg.secondary_opts == [] 58 | 59 | 60 | def test_metaclass_config_with_feud_kwargs() -> None: 61 | config = feud.config(negate_flags=False) 62 | 63 | class Test(feud.Group, config=config, negate_flags=True): 64 | def command(*, opt: bool) -> None: 65 | pass 66 | 67 | assert Test.__feud_config__ == feud.config(negate_flags=True) 68 | assert Test.__feud_click_kwargs__ == {"name": "test"} 69 | 70 | command = Test.command 71 | assert isinstance(command, click.Command) 72 | 73 | arg = command.params[0] 74 | assert arg.opts == ["--opt"] 75 | assert arg.secondary_opts == ["--no-opt"] 76 | 77 | 78 | def test_metaclass_click_kwargs() -> None: 79 | name = "custom" 80 | epilog = "Visit https://www.com for more information." 81 | 82 | class Test(feud.Group, name=name, epilog=epilog): 83 | def command(*, opt: bool) -> None: 84 | pass 85 | 86 | assert Test.__feud_config__ == feud.config() 87 | assert Test.__feud_click_kwargs__ == {"name": name, "epilog": epilog} 88 | 89 | group = Test.compile() 90 | assert group.name == name 91 | assert group.epilog == epilog 92 | 93 | # group-level click kwargs should not be propagated to commands 94 | command = Test.command 95 | assert isinstance(command, click.Command) 96 | assert command.name == "command" 97 | assert command.epilog is None 98 | 99 | arg = command.params[0] 100 | assert arg.opts == ["--opt"] 101 | assert arg.secondary_opts == ["--no-opt"] 102 | 103 | 104 | def test_metaclass_config_with_feud_kwargs_and_click_kwargs() -> None: 105 | name = "custom" 106 | epilog = "Visit https://www.com for more information." 107 | config = feud.config(negate_flags=False) 108 | 109 | class Test( 110 | feud.Group, name=name, epilog=epilog, config=config, negate_flags=True 111 | ): 112 | def command(*, opt: bool) -> None: 113 | pass 114 | 115 | assert Test.__feud_config__ == feud.config(negate_flags=True) 116 | assert Test.__feud_click_kwargs__ == {"name": name, "epilog": epilog} 117 | 118 | group = Test.compile() 119 | assert group.name == name 120 | assert group.epilog == epilog 121 | 122 | # group-level click kwargs should not be propagated to commands 123 | command = Test.command 124 | assert isinstance(command, click.Command) 125 | assert command.name == "command" 126 | assert command.epilog is None 127 | 128 | arg = command.params[0] 129 | assert arg.opts == ["--opt"] 130 | assert arg.secondary_opts == ["--no-opt"] 131 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_command.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import inspect 9 | 10 | import pytest 11 | 12 | from feud import click 13 | from feud import typing as t 14 | from feud._internal import _command 15 | 16 | 17 | def test_get_context_ctx_no_annotation() -> None: 18 | def f(ctx, a: t.Any, b: t.Any, c: t.Any) -> None: # noqa: ANN001 19 | pass 20 | 21 | sig = inspect.signature(f) 22 | assert _command.pass_context(sig) 23 | 24 | 25 | def test_get_context_ctx_with_annotation() -> None: 26 | def f(ctx: click.Context, a: t.Any, b: t.Any, c: t.Any) -> None: 27 | pass 28 | 29 | sig = inspect.signature(f) 30 | assert _command.pass_context(sig) 31 | 32 | 33 | def test_get_context_wrong_name_no_annotation() -> None: 34 | def f(context, a: t.Any, b: t.Any, c: t.Any) -> None: # noqa: ANN001 35 | pass 36 | 37 | sig = inspect.signature(f) 38 | assert not _command.pass_context(sig) 39 | 40 | 41 | def test_get_context_wrong_name_with_annotation() -> None: 42 | def f(context: click.Context, a: t.Any, b: t.Any, c: t.Any) -> None: 43 | pass 44 | 45 | sig = inspect.signature(f) 46 | assert not _command.pass_context(sig) 47 | 48 | 49 | def test_get_context_wrong_position() -> None: 50 | def f(a: t.Any, b: t.Any, c: t.Any, ctx: click.Context) -> None: 51 | pass 52 | 53 | sig = inspect.signature(f) 54 | assert not _command.pass_context(sig) 55 | 56 | 57 | def test_get_context_positional_only() -> None: 58 | def f(ctx: click.Context, /, a: t.Any, b: t.Any, c: t.Any) -> None: 59 | pass 60 | 61 | sig = inspect.signature(f) 62 | assert _command.pass_context(sig) 63 | 64 | 65 | def test_get_context_keyword_only() -> None: 66 | def f(*, ctx: click.Context, a: t.Any, b: t.Any, c: t.Any) -> None: 67 | pass 68 | 69 | sig = inspect.signature(f) 70 | assert _command.pass_context(sig) 71 | 72 | 73 | @pytest.mark.parametrize( 74 | ("hint", "negate_flags", "expected"), 75 | [ 76 | (str, False, "--opt"), 77 | (str, True, "--opt"), 78 | (bool, False, "--opt"), 79 | (bool, True, "--opt/--no-opt"), 80 | ], 81 | ) 82 | def test_get_option(hint: type, *, negate_flags: bool, expected: str) -> None: 83 | assert ( 84 | _command.get_option("opt", hint=hint, negate_flags=negate_flags) 85 | == expected 86 | ) 87 | 88 | 89 | @pytest.mark.parametrize( 90 | ("hint", "negate_flags", "expected"), 91 | [ 92 | (str, False, "-o"), 93 | (str, True, "-o"), 94 | (bool, False, "-o"), 95 | (bool, True, "-o/--no-o"), 96 | ], 97 | ) 98 | def test_get_alias(hint: type, *, negate_flags: bool, expected: str) -> None: 99 | assert ( 100 | _command.get_alias("-o", hint=hint, negate_flags=negate_flags) 101 | == expected 102 | ) 103 | 104 | 105 | @pytest.mark.parametrize( 106 | ("click_kwargs", "name", "expected"), 107 | [ 108 | ( 109 | {"commands": ["a", "b", "c"], "key": "value"}, 110 | "test", 111 | {"key": "value", "name": "test"}, 112 | ), 113 | ( 114 | {"key": "value"}, 115 | "a_name", 116 | {"key": "value", "name": "a_name"}, 117 | ), 118 | ( 119 | {"key": "value"}, 120 | "_a_name", 121 | {"key": "value", "name": "_a_name"}, 122 | ), 123 | ( 124 | {"key": "value"}, 125 | "-a-name", 126 | {"key": "value", "name": "a-name"}, 127 | ), 128 | ( 129 | {"key": "value"}, 130 | "--a-name", 131 | {"key": "value", "name": "a-name"}, 132 | ), 133 | ( 134 | {"key": "value"}, 135 | "AName", 136 | {"key": "value", "name": "aname"}, 137 | ), 138 | ( 139 | {"key": "value"}, 140 | "ClassName", 141 | {"key": "value", "name": "classname"}, 142 | ), 143 | ], 144 | ) 145 | def test_sanitize_click_kwargs( 146 | click_kwargs: dict[str, str], name: str, expected: dict[str, str] 147 | ) -> None: 148 | _command.sanitize_click_kwargs(click_kwargs, name=name) 149 | assert click_kwargs == expected 150 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | As Feud is an open source library, any contributions from the community are greatly appreciated. 4 | This document details the guidelines for making contributions to Feud. 5 | 6 | ## Reporting issues 7 | 8 | Prior to reporting an issue, please ensure: 9 | 10 | - [x] You have used the search utility provided on GitHub issues to look for similar issues. 11 | - [x] You have checked the documentation (for the version of Feud you are using). 12 | - [x] You are using the latest stable version of Feud (if possible). 13 | 14 | ## Making changes to Feud 15 | 16 | - **Add tests**: 17 | Your pull request won't be accepted if it doesn't have any tests (if necessary). 18 | 19 | - **Document any change in behaviour**: 20 | Make sure all relevant documentation is kept up-to-date. 21 | 22 | - **Create topic branches**: 23 | Will not pull from your master branch! 24 | 25 | - **One pull request per feature**: 26 | If you wish to add more than one new feature, make multiple pull requests. 27 | 28 | - **Meaningful commit messages**: 29 | Each commit in your pull request should have a meaningful message. 30 | 31 | - **De-clutter commit history**: 32 | If you had to make multiple intermediate commits while developing, please squash them before making your pull request. 33 | Or add a note on the PR specifying to squash and merge your changes when ready to be merged. 34 | 35 | ### Making pull requests 36 | 37 | Please make new branches based on the current `dev` branch, and merge your PR back into `dev` (making sure you have fetched the latest changes). 38 | 39 | ### Installing dependencies 40 | 41 | To install all dependencies and pre-commit hooks for development, ensure you have [Poetry](https://python-poetry.org/) (1.6.1+) installed and run: 42 | 43 | ```console 44 | make 45 | ``` 46 | 47 | ### Running tests 48 | 49 | This repository relies on the use of [Tox](https://tox.wiki/en/4.11.3/) for running tests in virtual environments. 50 | 51 | - Run **ALL tests** in a virtual environment: 52 | ```console 53 | # a.k.a. poetry run invoke tests.install tests.doctest tests.unit 54 | poetry run tox -e tests 55 | ``` 56 | - Run **ONLY unit tests** in a virtual environment: 57 | ```console 58 | # a.k.a. poetry run invoke tests.install tests.unit 59 | poetry run tox -e tests.unit 60 | ``` 61 | - Run **ONLY doctests** in a virtual environment: 62 | ```console 63 | # a.k.a. poetry run invoke tests.install tests.doctest 64 | poetry run tox -e tests.doctest 65 | ``` 66 | 67 | ### Linting and formatting 68 | 69 | This repository relies on the use of: 70 | 71 | - [Ruff](https://github.com/astral-sh/ruff) for linting and formatting Python source code, 72 | - [Pydoclint](https://jsh9.github.io/pydoclint/) for linting docstrings, 73 | - [Tox](https://tox.wiki/en/4.11.3/) for running linting and formatting in a virtual environment. 74 | 75 | To lint the source code using Ruff and Pydoclint with Tox: 76 | 77 | ```console 78 | # a.k.a poetry run invoke lint.install lint.check 79 | poetry run tox -e lint 80 | ``` 81 | 82 | To format the source code and attempt to auto-fix any linting issues using Ruff with Tox: 83 | 84 | ```console 85 | # a.k.a. poetry run invoke lint.install lint.format 86 | poetry run tox -e format 87 | ``` 88 | 89 | Pre-commit hooks will prevent you from making a commit if linting fails or your code is not formatted correctly. 90 | 91 | ### Documentation 92 | 93 | Package documentation is automatically produced from docstrings using [Sphinx](https://www.sphinx-doc.org/en/master/). 94 | The package also uses [Tox](https://tox.wiki/en/4.11.3/) for building documentation inside a virtual environment. 95 | 96 | To build package documentation and automatically serve the files as a HTTP server while watching for source code changes, run: 97 | 98 | ```console 99 | # a.k.a. poetry run invoke docs.install docs.build 100 | poetry run tox -e docs 101 | ``` 102 | 103 | This will start a server running on `localhost:8000` by default. 104 | 105 | To only build the static documentation HTML files without serving them or watching for changes, run: 106 | 107 | ```console 108 | # a.k.a. poetry run invoke docs.install docs.build --no-watch 109 | poetry run tox -e docs -- --no-watch 110 | ``` 111 | 112 | ## License 113 | 114 | By contributing, you agree that your contributions will be licensed under the repository's [MIT License](/LICENSE). 115 | 116 | --- 117 | 118 |

119 | Feud © 2023, Edwin Onuonga - Released under the MIT license.
120 | Authored and maintained by Edwin Onuonga. 121 |

122 | -------------------------------------------------------------------------------- /docs/source/sections/decorators/rename.rst: -------------------------------------------------------------------------------- 1 | Renaming commands/parameters 2 | ============================ 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | In certain cases, it may be desirable or even necessary for the names of the 11 | commands or parameters generated by Feud to be different to the names of the 12 | Python functions (and their parameters) that were used to generate the 13 | commands. 14 | 15 | The :py:func:`.rename` operator can be used in these scenarios to rename commands 16 | or parameters. 17 | 18 | Examples 19 | -------- 20 | 21 | Defining commands or parameters with reserved keywords 22 | ****************************************************** 23 | 24 | Suppose we have the following command, ``sum``, 25 | which takes a starting number ``from``, and an ending number ``to``, 26 | and sums all numbers between and including the starting and ending number. 27 | 28 | This might be called in the following way: 29 | 30 | .. code:: bash 31 | 32 | $ sum --from 1 --to 10 33 | 34 | With generating code that might look like: 35 | 36 | .. code:: python 37 | 38 | # sum.py 39 | 40 | import feud 41 | 42 | def sum(*, from: int, to: int): 43 | """Sums the numbers between and including a start and end number. 44 | 45 | Parameters 46 | ---------- 47 | from: 48 | Starting number. 49 | to: 50 | Ending number. 51 | """ 52 | print(sum(range(from, to + 1))) 53 | 54 | if __name__ == "__main__": 55 | feud.run(sum) 56 | 57 | There are two problems here: 58 | 59 | 1. By naming the function ``sum``, we are shadowing the in-built Python 60 | function ``sum``. This is also an issue as our function actually relies 61 | on the in-built Python ``sum`` function to actually do the addition. 62 | 2. ``from`` is also a reserved Python keyword which is used in module imports, 63 | and cannot be used as a function parameter. 64 | 65 | We can use the :py:func:`.rename` decorator to rename both the command and parameter. 66 | 67 | .. code:: python 68 | 69 | # sum.py 70 | 71 | import feud 72 | 73 | @feud.rename("sum", from_="from") 74 | def sum_(*, from_: int, to: int): 75 | """Sums the numbers between and including a start and end number. 76 | 77 | Parameters 78 | ---------- 79 | from_: 80 | Starting number. 81 | to: 82 | Ending number. 83 | """ 84 | print(sum(range(from_, to + 1))) 85 | 86 | if __name__ == "__main__": 87 | feud.run(sum_) 88 | 89 | This gives us valid Python code, and also our expected CLI behaviour. 90 | 91 | Defining hyphenated commands or parameters 92 | ****************************************** 93 | 94 | Suppose we have a command that should be called in the following way: 95 | 96 | .. code:: bash 97 | 98 | $ say-hi --welcome-message "Hello World!" 99 | 100 | As Feud uses the parameter names present in the Python function signature as 101 | the parameter names for the generated CLI, this means that defining parameters 102 | with hyphens is *usually* not possible, as Python identifiers cannot have hyphens. 103 | Similarly, a function name cannot have a hyphen: 104 | 105 | .. code:: python 106 | 107 | # hyphen.py 108 | 109 | import feud 110 | 111 | def say-hi(*, welcome-message: str): 112 | print(welcome-message) 113 | 114 | if __name__ == "__main__": 115 | feud.run(say-hi) 116 | 117 | We can use the :py:func:`.rename` decorator to rename both the command and parameter. 118 | 119 | .. code:: python 120 | 121 | # hyphen.py 122 | 123 | import feud 124 | 125 | @feud.rename("say-hi", welcome_message="welcome-message") 126 | def say_hi(*, welcome_message: str): 127 | print(welcome_message) 128 | 129 | if __name__ == "__main__": 130 | feud.run(say_hi) 131 | 132 | This gives us valid Python code, and also our expected CLI behaviour. 133 | 134 | Special use case for maintaining group-level configurations 135 | *********************************************************** 136 | 137 | Although :py:func:`.command` accepts a ``name`` argument (passed to Click) that can be 138 | used to rename a command, this can sometimes be undesirable in the case of :doc:`../core/group`. 139 | 140 | In the following example, although ``show_help_defaults`` has been set to 141 | ``False`` at the group level (which would usually mean that all commands 142 | defined within the group will not have their parameter defaults shown in 143 | ``--help``), this has been overridden by the ``feud.command`` call which 144 | has ``show_help_defaults=True`` by default. 145 | 146 | .. code:: python 147 | 148 | class CLI(feud.Group, show_help_defaults=False): 149 | @feud.command(name="my-func") 150 | def my_func(*, opt: int = 1): 151 | pass 152 | 153 | Using ``@feud.rename("my-func")`` instead of ``@feud.command(name="my-func")`` 154 | would allow for the group-level configuration to be used, while still renaming 155 | the function. 156 | 157 | ---- 158 | 159 | API reference 160 | ------------- 161 | 162 | .. autofunction:: feud.decorators.rename 163 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import pytest 7 | 8 | from feud.config import Config 9 | 10 | 11 | def test_create_no_base_no_kwargs() -> None: 12 | """No keyword arguments set.""" 13 | config = Config._create() # noqa: SLF001 14 | assert config.negate_flags is True 15 | assert config.show_help_defaults is True 16 | assert config.show_help_datetime_formats is False 17 | assert config.show_help_envvars is True 18 | assert config.pydantic_kwargs == {} 19 | assert config.rich_click_kwargs == {"show_arguments": True} 20 | 21 | 22 | def test_create_no_base_none_kwargs() -> None: 23 | """Keyword arguments set to None.""" 24 | config = Config._create( # noqa: SLF001 25 | negate_flags=None, 26 | show_help_defaults=None, 27 | show_help_datetime_formats=None, 28 | show_help_envvars=None, 29 | pydantic_kwargs=None, 30 | ) 31 | assert config.negate_flags is True 32 | assert config.show_help_defaults is True 33 | assert config.show_help_datetime_formats is False 34 | assert config.show_help_envvars is True 35 | assert config.pydantic_kwargs == {} 36 | assert config.rich_click_kwargs == {"show_arguments": True} 37 | 38 | 39 | def test_create_no_base_with_kwargs_default() -> None: 40 | """Keyword arguments set to default values.""" 41 | config = Config._create( # noqa: SLF001 42 | negate_flags=True, 43 | show_help_defaults=True, 44 | ) 45 | assert config.negate_flags is True 46 | assert config.show_help_defaults is True 47 | 48 | 49 | def test_create_no_base_with_kwargs_non_default() -> None: 50 | """Keyword arguments set to non-default values.""" 51 | config = Config._create( # noqa: SLF001 52 | negate_flags=False, 53 | show_help_defaults=False, 54 | ) 55 | assert config.negate_flags is False 56 | assert config.show_help_defaults is False 57 | 58 | 59 | def test_create_base_with_no_base_kwargs_no_override_kwargs() -> None: 60 | """Base configuration with no base keyword arguments set 61 | and no override keyword arguments set. 62 | """ 63 | base = Config._create() # noqa: SLF001 64 | config = Config._create(base=base) # noqa: SLF001 65 | assert config == base 66 | 67 | 68 | def test_create_base_with_no_base_kwargs_override_kwargs() -> None: 69 | """Base configuration with no base keyword arguments set 70 | and with override keyword arguments set. 71 | """ 72 | base = Config._create() # noqa: SLF001 73 | config = Config._create( # noqa: SLF001 74 | base=base, 75 | negate_flags=False, 76 | show_help_defaults=False, 77 | ) 78 | assert config.negate_flags is False 79 | assert config.show_help_defaults is False 80 | 81 | 82 | def test_create_base_with_base_kwargs_default_no_override_kwargs() -> None: 83 | """Base configuration with base keyword arguments set to default values 84 | and no override keyword arguments set. 85 | """ 86 | base = Config._create( # noqa: SLF001 87 | negate_flags=True, 88 | show_help_defaults=True, 89 | ) 90 | config = Config._create(base=base) # noqa: SLF001 91 | assert config == base 92 | 93 | 94 | def test_create_base_with_base_kwargs_default_override_kwargs() -> None: 95 | """Base configuration with base keyword arguments set to default values 96 | and override keyword arguments set. 97 | """ 98 | base = Config._create( # noqa: SLF001 99 | negate_flags=True, 100 | show_help_defaults=True, 101 | ) 102 | config = Config._create( # noqa: SLF001 103 | base=base, 104 | negate_flags=False, 105 | show_help_defaults=False, 106 | ) 107 | assert config.negate_flags is False 108 | assert config.show_help_defaults is False 109 | 110 | 111 | def test_create_base_with_base_kwargs_non_default_no_override_kwargs() -> None: 112 | """Base configuration with base keyword arguments set to non-default values 113 | and no override keyword arguments set. 114 | """ 115 | base = Config._create( # noqa: SLF001 116 | negate_flags=False, 117 | show_help_defaults=False, 118 | ) 119 | config = Config._create(base=base) # noqa: SLF001 120 | assert config == base 121 | 122 | 123 | def test_create_base_with_base_kwargs_non_default_override_kwargs() -> None: 124 | """Base configuration with base keyword arguments set to non-default values 125 | and override keyword arguments set. 126 | """ 127 | base = Config._create( # noqa: SLF001 128 | negate_flags=False, 129 | show_help_defaults=False, 130 | ) 131 | config = Config._create( # noqa: SLF001 132 | base=base, 133 | negate_flags=False, 134 | show_help_defaults=False, 135 | ) 136 | assert config.negate_flags is False 137 | assert config.show_help_defaults is False 138 | 139 | 140 | # test instantiate 141 | def test_init_create() -> None: 142 | """Should not be able to instantiate feud.Config.""" 143 | with pytest.raises(RuntimeError): 144 | Config() 145 | -------------------------------------------------------------------------------- /feud/_internal/_inflect.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import re 7 | import unicodedata 8 | 9 | __all__ = ["negate_alias", "negate_option", "optionize", "sanitize"] 10 | 11 | 12 | def transliterate(string: str) -> str: 13 | """Replace non-ASCII characters with an ASCII approximation. If no 14 | approximation exists, the non-ASCII character is ignored. The string must 15 | be ``unicode``. 16 | 17 | Example 18 | ------- 19 | >>> transliterate("älämölö") 20 | "alamolo" 21 | >>> transliterate("Ærøskøbing") 22 | "rskbing" 23 | 24 | Source code from inflection package 25 | (https://github.com/jpvanhal/inflection). 26 | 27 | Copyright (C) 2012-2020 Janne Vanhala 28 | 29 | Permission is hereby granted, free of charge, to any person obtaining 30 | a copy of this software and associated documentation files 31 | (the "Software"), to deal in the Software without restriction, 32 | including without limitation the rights to use, copy, modify, merge, 33 | publish, distribute, sublicense, and/or sell copies of the Software, 34 | and to permit persons to whom the Software is furnished to do so, 35 | subject to the following conditions: 36 | 37 | The above copyright notice and this permission notice shall be 38 | included in all copies or substantial portions of the Software. 39 | 40 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 41 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 42 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 43 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 44 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 45 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 46 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. 48 | """ 49 | normalized = unicodedata.normalize("NFKD", string) 50 | return normalized.encode("ascii", "ignore").decode("ascii") 51 | 52 | 53 | def parameterize(string: str, separator: str = "-") -> str: 54 | """Replace special characters in a string so that it may be used as part 55 | of a 'pretty' URL. 56 | 57 | Example 58 | ------- 59 | >>> parameterize(u"Donald E. Knuth") 60 | "donald-e-knuth" 61 | 62 | Source code from inflection package 63 | (https://github.com/jpvanhal/inflection). 64 | 65 | Copyright (C) 2012-2020 Janne Vanhala 66 | 67 | Permission is hereby granted, free of charge, to any person obtaining 68 | a copy of this software and associated documentation files 69 | (the "Software"), to deal in the Software without restriction, 70 | including without limitation the rights to use, copy, modify, merge, 71 | publish, distribute, sublicense, and/or sell copies of the Software, 72 | and to permit persons to whom the Software is furnished to do so, 73 | subject to the following conditions: 74 | 75 | The above copyright notice and this permission notice shall be 76 | included in all copies or substantial portions of the Software. 77 | 78 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 79 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 80 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 81 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 82 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 83 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 84 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 85 | SOFTWARE. 86 | """ 87 | string = transliterate(string) 88 | # Turn unwanted chars into the separator 89 | string = re.sub(r"(?i)[^a-z0-9\-_]+", separator, string) 90 | if separator: 91 | re_sep = re.escape(separator) 92 | # No more than one of the separator in a row. 93 | string = re.sub(rf"{re_sep}{{2,}}", separator, string) 94 | # Remove leading/trailing separator. 95 | string = re.sub(rf"(?i)^{re_sep}|{re_sep}$", "", string) 96 | 97 | return string.lower() 98 | 99 | 100 | def sanitize(name: str) -> str: 101 | """Sanitizes a string for preparation of usage as a command-line option. 102 | 103 | Example 104 | ------- 105 | >>> sanitize("---a@b_c--") 106 | "a-b_c" 107 | """ 108 | name = parameterize(name) 109 | return re.sub(r"^-*(.*)", r"\1", name) 110 | 111 | 112 | def optionize(name: str) -> str: 113 | """Sanitizes a string and converts it into a command-line option. 114 | 115 | Example 116 | ------- 117 | >>> optionize("opt_name") 118 | "--opt-name" 119 | """ 120 | return "--" + sanitize(name) 121 | 122 | 123 | def negate_option(option: str) -> str: 124 | """Negates a command-line option (for boolean flags). 125 | 126 | Example 127 | ------- 128 | >>> negate_option("--opt") 129 | "--no-opt" 130 | """ 131 | return "--no" + option.removeprefix("-") 132 | 133 | 134 | def negate_alias(alias: str) -> str: 135 | """Negates an alias for a boolean flag. 136 | 137 | Example 138 | ------- 139 | >>> negate_alias("-a") 140 | "--no-a" 141 | """ 142 | return "--no" + alias 143 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_is_collection_type.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import collections 9 | import typing as t 10 | 11 | import pytest 12 | 13 | from feud._internal import _types 14 | 15 | 16 | def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: 17 | return t.Annotated[hint, "annotation"] 18 | 19 | 20 | @pytest.fixture(scope="module") 21 | def helpers(helpers: type) -> type: # type[Helpers] 22 | def check_is_collection_type( 23 | *, annotated: bool, hint: t.Any, expected: tuple[bool, t.Any] 24 | ) -> None: 25 | if annotated: 26 | hint = helpers.annotate(hint) 27 | assert _types.click.is_collection_type(hint) == expected 28 | 29 | helpers.check_is_collection_type = check_is_collection_type 30 | 31 | return helpers 32 | 33 | 34 | @pytest.mark.parametrize("annotated", [False, True]) 35 | @pytest.mark.parametrize( 36 | ("hint", "expected"), 37 | [ 38 | (tuple, (True, None)), 39 | (t.Tuple, (True, None)), 40 | (t.Tuple[t.Any], (False, None)), 41 | (t.Tuple[int, str], (False, None)), 42 | (t.Tuple[t.Any, ...], (True, t.Any)), 43 | (t.Tuple[annotate(t.Any), ...], (True, annotate(t.Any))), 44 | ], 45 | ) 46 | def test_tuple( 47 | helpers: type, 48 | *, 49 | annotated: bool, 50 | hint: t.Any, 51 | expected: tuple[bool, t.Any], 52 | ) -> None: 53 | helpers.check_is_collection_type( 54 | annotated=annotated, 55 | hint=hint, 56 | expected=expected, 57 | ) 58 | 59 | 60 | @pytest.mark.parametrize("annotated", [False, True]) 61 | @pytest.mark.parametrize( 62 | ("hint", "expected"), 63 | [ 64 | (list, (True, None)), 65 | (t.List, (True, None)), 66 | (t.List[t.Any], (True, t.Any)), 67 | (t.List[annotate(t.Any)], (True, annotate(t.Any))), 68 | ], 69 | ) 70 | def test_list( 71 | helpers: type, 72 | *, 73 | annotated: bool, 74 | hint: t.Any, 75 | expected: tuple[bool, t.Any], 76 | ) -> None: 77 | helpers.check_is_collection_type( 78 | annotated=annotated, 79 | hint=hint, 80 | expected=expected, 81 | ) 82 | 83 | 84 | @pytest.mark.parametrize("annotated", [False, True]) 85 | @pytest.mark.parametrize( 86 | ("hint", "expected"), 87 | [ 88 | (set, (True, None)), 89 | (t.Set, (True, None)), 90 | (t.Set[t.Any], (True, t.Any)), 91 | (t.Set[annotate(t.Any)], (True, annotate(t.Any))), 92 | ], 93 | ) 94 | def test_set( 95 | helpers: type, 96 | *, 97 | annotated: bool, 98 | hint: t.Any, 99 | expected: tuple[bool, t.Any], 100 | ) -> None: 101 | helpers.check_is_collection_type( 102 | annotated=annotated, 103 | hint=hint, 104 | expected=expected, 105 | ) 106 | 107 | 108 | @pytest.mark.parametrize("annotated", [False, True]) 109 | @pytest.mark.parametrize( 110 | ("hint", "expected"), 111 | [ 112 | (frozenset, (True, None)), 113 | (t.FrozenSet, (True, None)), 114 | (t.FrozenSet[t.Any], (True, t.Any)), 115 | (t.FrozenSet[annotate(t.Any)], (True, annotate(t.Any))), 116 | ], 117 | ) 118 | def test_frozenset( 119 | helpers: type, 120 | *, 121 | annotated: bool, 122 | hint: t.Any, 123 | expected: tuple[bool, t.Any], 124 | ) -> None: 125 | helpers.check_is_collection_type( 126 | annotated=annotated, 127 | hint=hint, 128 | expected=expected, 129 | ) 130 | 131 | 132 | @pytest.mark.parametrize("annotated", [False, True]) 133 | @pytest.mark.parametrize( 134 | ("hint", "expected"), 135 | [ 136 | (collections.deque, (True, None)), 137 | (t.Deque, (True, None)), 138 | (t.Deque[t.Any], (True, t.Any)), 139 | (t.Deque[annotate(t.Any)], (True, annotate(t.Any))), 140 | ], 141 | ) 142 | def test_deque( 143 | helpers: type, 144 | *, 145 | annotated: bool, 146 | hint: t.Any, 147 | expected: tuple[bool, t.Any], 148 | ) -> None: 149 | helpers.check_is_collection_type( 150 | annotated=annotated, 151 | hint=hint, 152 | expected=expected, 153 | ) 154 | 155 | 156 | @pytest.mark.parametrize("annotated", [False, True]) 157 | @pytest.mark.parametrize( 158 | ("hint", "expected"), 159 | [ 160 | (t.NamedTuple("Point", x=int, y=str), (False, None)), 161 | ( 162 | t.NamedTuple("Point", x=annotate(int), y=annotate(str)), 163 | (False, None), 164 | ), 165 | ], 166 | ) 167 | def test_namedtuple( 168 | helpers: type, 169 | *, 170 | annotated: bool, 171 | hint: t.Any, 172 | expected: tuple[bool, t.Any], 173 | ) -> None: 174 | helpers.check_is_collection_type( 175 | annotated=annotated, 176 | hint=hint, 177 | expected=expected, 178 | ) 179 | 180 | 181 | @pytest.mark.parametrize("annotated", [False, True]) 182 | @pytest.mark.parametrize( 183 | ("hint", "expected"), 184 | [(t.Any, (False, None))], 185 | ) 186 | def test_other( 187 | helpers: type, 188 | *, 189 | annotated: bool, 190 | hint: t.Any, 191 | expected: tuple[bool, t.Any], 192 | ) -> None: 193 | helpers.check_is_collection_type( 194 | annotated=annotated, 195 | hint=hint, 196 | expected=expected, 197 | ) 198 | -------------------------------------------------------------------------------- /feud/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Configuration for defining commands using :py:func:`.command` or 7 | groups using :py:class:`.Group`. 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import inspect 13 | import typing as t 14 | 15 | import pydantic as pyd 16 | 17 | __all__ = ["Config", "config"] 18 | 19 | 20 | class Config(pyd.BaseModel): 21 | """Class representing a reusable configuration for 22 | :py:func:`.command` and :py:class:`.Group` objects. 23 | 24 | .. warning:: 25 | 26 | This class should **NOT** be instantiated directly --- 27 | :py:func:`.config` should be used to create a :py:class:`.Config` 28 | instead. 29 | """ 30 | 31 | #: Whether to automatically add a negated variant for boolean flags. 32 | negate_flags: bool = True 33 | 34 | #: Whether to display default parameter values in command help. 35 | show_help_defaults: bool = True 36 | 37 | #: Whether to display datetime parameter formats in command help. 38 | show_help_datetime_formats: bool = False 39 | 40 | #: Whether to display environment variable names in command help. 41 | show_help_envvars: bool = True 42 | 43 | #: Validation settings for 44 | #: :py:func:`pydantic.validate_call_decorator.validate_call`. 45 | pydantic_kwargs: dict[str, t.Any] = {} 46 | 47 | #: Styling settings for ``rich-click``. 48 | #: 49 | #: See all available options 50 | #: `here `__ 51 | #: (as of ``rich-click`` v1.7.2). 52 | rich_click_kwargs: dict[str, t.Any] = {"show_arguments": True} 53 | 54 | def __init__(self, **kwargs: t.Any) -> None: 55 | caller: str | None = None 56 | frame = inspect.currentframe() 57 | if frame and frame.f_back: 58 | caller = frame.f_back.f_code.co_name 59 | if caller != Config._create.__name__: 60 | msg = ( 61 | "The feud.Config class should not be instantiated directly, " 62 | "the feud.config function should be used instead." 63 | ) 64 | raise RuntimeError(msg) 65 | super().__init__(**kwargs) 66 | 67 | @classmethod 68 | def _create(cls, base: Config | None = None, **kwargs: t.Any) -> Config: 69 | config_kwargs = base.model_dump(exclude_unset=True) if base else {} 70 | for field in cls.model_fields: 71 | value: t.Any | None = kwargs.get(field) 72 | if value is not None: 73 | config_kwargs[field] = value 74 | return cls(**config_kwargs) 75 | 76 | 77 | def config( 78 | *, 79 | negate_flags: bool | None = None, 80 | show_help_defaults: bool | None = None, 81 | show_help_datetime_formats: bool | None = None, 82 | show_help_envvars: bool | None = None, 83 | pydantic_kwargs: dict[str, t.Any] | None = None, 84 | rich_click_kwargs: dict[str, t.Any] | None = None, 85 | ) -> Config: 86 | """Create a reusable configuration for :py:func:`.command` or 87 | :py:class:`.Group` objects. 88 | 89 | See :py:class:`.Config` for the underlying configuration class. 90 | 91 | Parameters 92 | ---------- 93 | negate_flags: 94 | Whether to automatically add a negated variant for boolean flags. 95 | 96 | show_help_defaults: 97 | Whether to display default parameter values in command help. 98 | 99 | show_help_datetime_formats: 100 | Whether to display datetime parameter formats in command help. 101 | 102 | show_help_envvars: 103 | Whether to display environment variable names in command help. 104 | 105 | pydantic_kwargs: 106 | Validation settings for 107 | :py:func:`pydantic.validate_call_decorator.validate_call`. 108 | 109 | rich_click_kwargs: 110 | Styling settings for ``rich-click``. 111 | 112 | See all available options 113 | `here `__ 114 | (as of ``rich-click`` v1.7.2). 115 | 116 | Returns 117 | ------- 118 | Config 119 | The reusable configuration. 120 | 121 | Examples 122 | -------- 123 | Providing a configuration to :py:func:`.command`. 124 | 125 | >>> import feud 126 | >>> config = feud.config(show_help_defaults=False) 127 | >>> @feud.command(config=config) 128 | ... def func(*, opt1: int, opt2: bool = True): 129 | ... pass 130 | >>> all(not param.show_default for param in func.params) 131 | True 132 | 133 | Providing a configuration to :py:class:`.Group`. 134 | 135 | Note that the configuration is internally forwarded to the commands 136 | defined within the group. 137 | 138 | >>> import feud 139 | >>> config = feud.config(show_help_defaults=False) 140 | >>> class CLI(feud.Group, config=config): 141 | ... def func(*, opt1: int, opt2: bool = True): 142 | ... pass 143 | >>> all(not param.show_default for param in CLI.func.params) 144 | True 145 | """ 146 | return Config._create( # noqa: SLF001 147 | negate_flags=negate_flags, 148 | show_help_defaults=show_help_defaults, 149 | show_help_datetime_formats=show_help_datetime_formats, 150 | show_help_envvars=show_help_envvars, 151 | pydantic_kwargs=pydantic_kwargs, 152 | rich_click_kwargs=rich_click_kwargs, 153 | ) 154 | -------------------------------------------------------------------------------- /feud/_internal/_metaclass.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import abc 9 | import typing as t 10 | 11 | from feud import click 12 | from feud._internal import _command 13 | from feud.config import Config 14 | from feud.core.command import command 15 | 16 | if t.TYPE_CHECKING: 17 | from feud.core.group import Group 18 | 19 | 20 | class GroupBase(abc.ABCMeta): 21 | def __new__( 22 | __cls: type[GroupBase], # noqa: N804 23 | cls_name: str, 24 | bases: tuple[type[Group], ...], 25 | namespace: dict[str, t.Any], 26 | **kwargs: t.Any, 27 | ) -> type[Group]: 28 | """Metaclass for creating groups. 29 | 30 | Parameters 31 | ---------- 32 | cls_name: 33 | The name of the class to be created. 34 | 35 | bases: 36 | The base classes of the class to be created. 37 | 38 | namespace: 39 | The attribute dictionary of the class to be created. 40 | 41 | **kwargs: 42 | Catch-all for any other keyword arguments. 43 | 44 | This can be a combination of: 45 | - click command/group key-word arguments, 46 | - feud configuration key-word arguments, 47 | - a feud configuration object. 48 | 49 | Returns 50 | ------- 51 | The new class created by the metaclass. 52 | """ 53 | if bases: 54 | base_config: Config | None = None 55 | click_kwargs: dict[str, t.Any] = {} 56 | subgroups: list[type] = [] # type[Group], but circular import 57 | commands: list[str] = [] 58 | 59 | # extend/inherit information from parent group if subclassed 60 | help_: str | None = None 61 | for base in bases: 62 | if config := getattr(base, "__feud_config__", None): 63 | # NOTE: may want **dict(config) depending on behaviour 64 | base_config = Config._create( # noqa: SLF001 65 | base=base_config, 66 | **config.model_dump(exclude_unset=True), 67 | ) 68 | click_kwargs = { 69 | **click_kwargs, 70 | **base.__feud_click_kwargs__, 71 | } 72 | subgroups += [ 73 | subgroup 74 | for subgroup in base.__feud_subgroups__ 75 | if subgroup not in subgroups 76 | ] 77 | commands += [ 78 | cmd 79 | for cmd in base.__feud_commands__ 80 | if cmd not in commands 81 | ] 82 | help_ = base.__feud_click_kwargs__.get("help") 83 | 84 | # deconstruct base config, override config kwargs and click kwargs 85 | config_kwargs: dict[str, t.Any] = {} 86 | for k, v in kwargs.items(): 87 | if k == "config": 88 | # NOTE: may want base_config = v depending on behaviour 89 | base_config = Config._create( # noqa: SLF001 90 | base=base_config, **v.model_dump(exclude_unset=True) 91 | ) 92 | else: 93 | d = ( 94 | config_kwargs 95 | if k in Config.model_fields 96 | else click_kwargs 97 | ) 98 | d[k] = v 99 | 100 | # sanitize click kwargs 101 | _command.sanitize_click_kwargs( 102 | click_kwargs, name=cls_name, help_=help_ 103 | ) 104 | 105 | # members to consider as commands 106 | funcs = { 107 | name: attr 108 | for name, attr in namespace.items() 109 | if callable(attr) and not name.startswith("_") 110 | } 111 | 112 | # set config and click kwargs 113 | # (override feud.command decorator settings) 114 | namespace["__feud_config__"] = Config._create( # noqa: SLF001 115 | base=base_config, **config_kwargs 116 | ) 117 | namespace["__feud_click_kwargs__"] = click_kwargs 118 | namespace["__feud_subgroups__"] = subgroups 119 | namespace["__feud_commands__"] = commands + [ 120 | func for func in funcs if func not in commands 121 | ] 122 | 123 | # auto-generate commands 124 | for name, func in funcs.items(): 125 | if not isinstance(func, click.Command): 126 | namespace[name] = command( 127 | func, config=namespace["__feud_config__"] 128 | ) 129 | 130 | group: type[Group] = super().__new__( # type: ignore[assignment] 131 | __cls, 132 | cls_name, 133 | bases, 134 | namespace, 135 | ) 136 | 137 | if bases: 138 | # use class-level docstring as help if provided 139 | if doc := group.__doc__: 140 | click_kwargs["help"] = doc 141 | # use __main__ function-level docstring as help if provided 142 | if doc := group.__main__.__doc__: 143 | click_kwargs["help"] = doc 144 | # use class-level click kwargs help if provided 145 | if doc := kwargs.get("help"): 146 | click_kwargs["help"] = doc 147 | 148 | return group 149 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This code of conduct outlines our expectations for participants within the Feud community, as well as steps to reporting unacceptable behavior. We are committed to providing a welcoming and inspiring community for all and expect our code of conduct to be honored. Anyone who violates this code of conduct may be banned from the community. 4 | 5 | Our open source community strives to: 6 | 7 | - **Be friendly and patient.** 8 | - **Be welcoming**: We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. 9 | - **Be considerate**: Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. 10 | - **Be respectful**: Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. 11 | - **Be careful in the words that you choose**: we are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to: 12 | - Violent threats or language directed against another person. 13 | - Discriminatory jokes and language. 14 | - Posting sexually explicit or violent material. 15 | - Posting (or threatening to post) other people's personally identifying information ("doxing"). 16 | - Personal insults, especially those using racist or sexist terms. 17 | - Unwelcome sexual attention. 18 | - Advocating for, or encouraging, any of the above behavior. 19 | - Repeated harassment of others. In general, if someone asks you to stop, then stop. 20 | - **When we disagree, try to understand why**: Disagreements, both social and technical, happen all the time. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of our community comes from its diversity, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. 21 | 22 | This code is not exhaustive or complete. It serves to distill our common understanding of a collaborative, shared environment, and goals. We expect it to be followed in spirit as much as in the letter. 23 | 24 | ### Diversity Statement 25 | 26 | We encourage everyone to participate and are committed to building a community for all. Although we may not be able to satisfy everyone, we all agree that everyone is equal. Whenever a participant has made a mistake, we expect them to take responsibility for it. If someone has been harmed or offended, it is our responsibility to listen carefully and respectfully, and do our best to right the wrong. 27 | 28 | Although this list cannot be exhaustive, we explicitly honor diversity in age, gender, gender identity or expression, culture, ethnicity, language, national origin, political beliefs, profession, race, religion, sexual orientation, socioeconomic status, and technical ability. We will not tolerate discrimination based on any of the protected 29 | characteristics above, including participants with disabilities. 30 | 31 | ### Reporting Issues 32 | 33 | If you experience or witness unacceptable behavior—or have any other concerns—please report it by contacting Edwin Onuonga via [ed@eonu.net](mailto:ed@eonu.net). All reports will be handled with discretion. In your report please include: 34 | 35 | - Your contact information. 36 | - Names (real, nicknames, or pseudonyms) of any individuals involved. If there are additional witnesses, please 37 | include them as well. Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly available record (e.g. a mailing list archive or a public IRC logger), please include a link. 38 | - Any additional information that may be helpful. 39 | 40 | After filing a report, a representative will contact you personally. If the person who is harassing you is part of the response team, they will recuse themselves from handling your incident. A representative will then review the incident, follow up with any additional questions, and make a decision as to how to respond. We will respect confidentiality requests for the purpose of protecting victims of abuse. 41 | 42 | Anyone asked to stop unacceptable behavior is expected to comply immediately. If an individual engages in unacceptable behavior, the representative may take any action they deem appropriate, up to and including a permanent ban from our community without warning. 43 | 44 | ## Thanks 45 | 46 | This code of conduct is based on the [Open Code of Conduct](https://github.com/todogroup/opencodeofconduct) from the [TODOGroup](http://todogroup.org). 47 | 48 | We are thankful for their work and all the communities who have paved the way with code of conducts. 49 | 50 | --- 51 | 52 |

53 | Feud © 2023, Edwin Onuonga - Released under the MIT license.
54 | Authored and maintained by Edwin Onuonga. 55 |

56 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_decorators.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | """Unit tests for ``feud.decorators``.""" 7 | 8 | import enum 9 | 10 | import pytest 11 | 12 | from feud import click 13 | from feud import typing as t 14 | from feud._internal import _decorators 15 | 16 | 17 | def test_validate_call_single_invalid() -> None: 18 | """Check output when ``validate_call`` receives a single invalid input 19 | value. 20 | """ 21 | name = "func" 22 | param_renames = {} 23 | meta_vars = {"arg2": "--arg2"} 24 | sensitive_vars = {"arg2": False} 25 | positional = [] 26 | var_positional = None 27 | pydantic_kwargs = {} 28 | 29 | def f(*, arg2: t.Literal["a", "b", "c"]) -> None: 30 | pass 31 | 32 | with pytest.raises(click.UsageError) as e: 33 | _decorators.validate_call( 34 | f, 35 | name=name, 36 | param_renames=param_renames, 37 | meta_vars=meta_vars, 38 | sensitive_vars=sensitive_vars, 39 | positional=positional, 40 | var_positional=var_positional, 41 | pydantic_kwargs=pydantic_kwargs, 42 | )(arg2="invalid") 43 | 44 | assert ( 45 | str(e.value) 46 | == """ 47 | 1 validation error for command 'func' 48 | --arg2 49 | Input should be 'a', 'b' or 'c' [input_value='invalid'] 50 | """.strip() 51 | ) 52 | 53 | 54 | def test_validate_call_multiple_invalid() -> None: 55 | """Check output when ``validate_call`` receives multiple invalid 56 | input values. 57 | """ 58 | name = "func" 59 | param_renames = {} 60 | meta_vars = {"0": "ARG1", "arg2": "--arg2"} 61 | sensitive_vars = {"0": False, "arg2": False} 62 | positional = [] 63 | var_positional = None 64 | pydantic_kwargs = {} 65 | 66 | def f(arg1: int, *, arg2: t.Literal["a", "b", "c"]) -> None: 67 | pass 68 | 69 | with pytest.raises(click.UsageError) as e: 70 | _decorators.validate_call( 71 | f, 72 | name=name, 73 | param_renames=param_renames, 74 | meta_vars=meta_vars, 75 | sensitive_vars=sensitive_vars, 76 | positional=positional, 77 | var_positional=var_positional, 78 | pydantic_kwargs=pydantic_kwargs, 79 | )("invalid", arg2="invalid") 80 | 81 | assert ( 82 | str(e.value) 83 | == """ 84 | 2 validation errors for command 'func' 85 | ARG1 86 | Input should be a valid integer, unable to parse string as an integer [input_value='invalid'] 87 | --arg2 88 | Input should be 'a', 'b' or 'c' [input_value='invalid'] 89 | """.strip() # noqa: E501 90 | ) 91 | 92 | 93 | def test_validate_call_list() -> None: 94 | """Check output when ``validate_call`` receives an invalid input value for 95 | a list argument. 96 | """ 97 | name = "func" 98 | param_renames = {} 99 | meta_vars = {"0": "[ARG1]..."} 100 | sensitive_vars = {"0": False} 101 | positional = [] 102 | var_positional = None 103 | pydantic_kwargs = {} 104 | 105 | def f(arg1: list[t.conint(multiple_of=2)]) -> None: 106 | pass 107 | 108 | with pytest.raises(click.UsageError) as e: 109 | _decorators.validate_call( 110 | f, 111 | name=name, 112 | param_renames=param_renames, 113 | meta_vars=meta_vars, 114 | sensitive_vars=sensitive_vars, 115 | positional=positional, 116 | var_positional=var_positional, 117 | pydantic_kwargs=pydantic_kwargs, 118 | )([1, 2, 3]) 119 | 120 | assert ( 121 | str(e.value) 122 | == """ 123 | 2 validation errors for command 'func' 124 | [ARG1]... [0] 125 | Input should be a multiple of 2 [input_value=1] 126 | [ARG1]... [2] 127 | Input should be a multiple of 2 [input_value=3] 128 | """.strip() 129 | ) 130 | 131 | 132 | def test_validate_call_enum() -> None: 133 | """Check output when ``validate_call`` receives an invalid input value 134 | for an enum parameter. 135 | """ 136 | name = "func" 137 | param_renames = {} 138 | meta_vars = {"arg2": "--arg2"} 139 | sensitive_vars = {"arg2": False} 140 | positional = [] 141 | var_positional = None 142 | pydantic_kwargs = {} 143 | 144 | class Choice(enum.Enum): 145 | A = "a" 146 | B = "b" 147 | C = "c" 148 | 149 | def f(*, arg2: Choice) -> None: 150 | pass 151 | 152 | with pytest.raises(click.UsageError) as e: 153 | _decorators.validate_call( 154 | f, 155 | name=name, 156 | param_renames=param_renames, 157 | meta_vars=meta_vars, 158 | sensitive_vars=sensitive_vars, 159 | positional=positional, 160 | var_positional=var_positional, 161 | pydantic_kwargs=pydantic_kwargs, 162 | )(arg2="invalid") 163 | 164 | assert ( 165 | str(e.value) 166 | == """ 167 | 1 validation error for command 'func' 168 | --arg2 169 | Input should be 'a', 'b' or 'c' [input_value='invalid'] 170 | """.strip() 171 | ) 172 | 173 | 174 | def test_validate_call_datetime() -> None: 175 | """Check output when ``validate_call`` receives an invalid input value 176 | for a datetime parameter. 177 | """ 178 | name = "func" 179 | param_renames = {} 180 | meta_vars = {"time": "--time"} 181 | sensitive_vars = {"time": False} 182 | positional = [] 183 | var_positional = None 184 | pydantic_kwargs = {} 185 | 186 | def f(*, time: t.FutureDatetime) -> None: 187 | pass 188 | 189 | with pytest.raises(click.UsageError) as e: 190 | _decorators.validate_call( 191 | f, 192 | name=name, 193 | param_renames=param_renames, 194 | meta_vars=meta_vars, 195 | sensitive_vars=sensitive_vars, 196 | positional=positional, 197 | var_positional=var_positional, 198 | pydantic_kwargs=pydantic_kwargs, 199 | )(time=t.datetime.now()) 200 | 201 | assert str(e.value).startswith( 202 | """ 203 | 1 validation error for command 'func' 204 | --time 205 | Input should be in the future 206 | """.strip() 207 | ) 208 | -------------------------------------------------------------------------------- /docs/source/sections/typing/pydantic.rst: -------------------------------------------------------------------------------- 1 | Pydantic types 2 | ============== 3 | 4 | .. contents:: Table of Contents 5 | :class: this-will-duplicate-information-and-it-is-still-useful-here 6 | :local: 7 | :backlinks: none 8 | :depth: 3 9 | 10 | `Pydantic `__ is a validation library that provides 11 | a rich selection of useful types for command-line inputs. 12 | 13 | The following commonly used Pydantic types can be used as type hints for Feud commands. 14 | 15 | .. important:: 16 | 17 | This page only lists a subset of Pydantic types, i.e. those which would commonly 18 | be used as command-line inputs. 19 | 20 | All `Pydantic types `__ 21 | (including those not listed on this page) are compatible with Feud. 22 | 23 | .. tip:: 24 | 25 | All of the types listed on this page are easily accessible from the :py:mod:`feud.typing` module. 26 | 27 | It is recommended to import the :py:mod:`feud.typing` module with an alias such as ``t`` for convenient short-hand use, e.g. 28 | 29 | .. code:: python 30 | 31 | from feud import typing as t 32 | 33 | t.PositiveInt # pydantic.types.PositiveInt 34 | t.FutureDatetime # pydantic.types.FutureDatetime 35 | t.conint # pydantic.types.conint 36 | t.IPvAnyAddress # pydantic.networks.IPvAnyAddress 37 | 38 | ---- 39 | 40 | The version number indicates the minimum ``pydantic`` version required to use the type. 41 | 42 | If this version requirement is not met, the type is not imported by Feud. 43 | 44 | String types 45 | ------------ 46 | 47 | - :py:obj:`pydantic.types.ImportString` (``>= 2.0.3``) 48 | - :py:obj:`pydantic.types.SecretStr` (``>= 2.0.3``) 49 | - :py:obj:`pydantic.types.StrictStr` (``>= 2.0.3``) 50 | - :py:obj:`pydantic.types.constr` (``>= 2.0.3``) 51 | 52 | Integer types 53 | ------------- 54 | 55 | - :py:obj:`pydantic.types.NegativeInt` (``>= 2.0.3``) 56 | - :py:obj:`pydantic.types.NonNegativeInt` (``>= 2.0.3``) 57 | - :py:obj:`pydantic.types.NonPositiveInt` (``>= 2.0.3``) 58 | - :py:obj:`pydantic.types.PositiveInt` (``>= 2.0.3``) 59 | - :py:obj:`pydantic.types.StrictInt` (``>= 2.0.3``) 60 | - :py:obj:`pydantic.types.conint` (``>= 2.0.3``) 61 | 62 | Float types 63 | ----------- 64 | 65 | - :py:obj:`pydantic.types.FiniteFloat` (``>= 2.0.3``) 66 | - :py:obj:`pydantic.types.NegativeFloat` (``>= 2.0.3``) 67 | - :py:obj:`pydantic.types.NonNegativeFloat` (``>= 2.0.3``) 68 | - :py:obj:`pydantic.types.NonPositiveFloat` (``>= 2.0.3``) 69 | - :py:obj:`pydantic.types.PositiveFloat` (``>= 2.0.3``) 70 | - :py:obj:`pydantic.types.StrictFloat` (``>= 2.0.3``) 71 | - :py:obj:`pydantic.types.confloat` (``>= 2.0.3``) 72 | 73 | Sequence types 74 | -------------- 75 | 76 | - :py:obj:`pydantic.types.confrozenset` (``>= 2.0.3``) 77 | - :py:obj:`pydantic.types.conlist` (``>= 2.0.3``) 78 | - :py:obj:`pydantic.types.conset` (``>= 2.0.3``) 79 | 80 | Datetime types 81 | -------------- 82 | 83 | - :py:obj:`pydantic.types.AwareDatetime` (``>= 2.0.3``) 84 | - :py:obj:`pydantic.types.FutureDate` (``>= 2.0.3``) 85 | - :py:obj:`pydantic.types.FutureDatetime` (``>= 2.0.3``) 86 | - :py:obj:`pydantic.types.NaiveDatetime` (``>= 2.0.3``) 87 | - :py:obj:`pydantic.types.PastDate` (``>= 2.0.3``) 88 | - :py:obj:`pydantic.types.PastDatetime` (``>= 2.0.3``) 89 | - :py:obj:`pydantic.types.condate` (``>= 2.0.3``) 90 | 91 | Path types 92 | ---------- 93 | 94 | - :py:obj:`pydantic.types.DirectoryPath` (``>= 2.0.3``) 95 | - :py:obj:`pydantic.types.FilePath` (``>= 2.0.3``) 96 | - :py:obj:`pydantic.types.NewPath` (``>= 2.0.3``) 97 | - :py:obj:`pydantic.types.SocketPath` (``>= 2.10.0``) 98 | 99 | Decimal type 100 | ------------ 101 | 102 | - :py:obj:`pydantic.types.condecimal` (``>= 2.0.3``) 103 | 104 | URL types 105 | --------- 106 | 107 | - :py:obj:`pydantic.networks.AnyHttpUrl` (``>= 2.0.3``) 108 | - :py:obj:`pydantic.networks.AnyUrl` (``>= 2.0.3``) 109 | - :py:obj:`pydantic.networks.FileUrl` (``>= 2.0.3``) 110 | - :py:obj:`pydantic.networks.HttpUrl` (``>= 2.0.3``) 111 | 112 | Email types 113 | ----------- 114 | 115 | .. important:: 116 | 117 | In order to use email types, you must install Feud with the optional 118 | ``email-validator`` dependency (see `here `__). 119 | 120 | .. code:: console 121 | 122 | $ pip install feud[email] 123 | 124 | - :py:obj:`pydantic.networks.EmailStr` (``>= 2.0.3``) 125 | - :py:obj:`pydantic.networks.NameEmail` (``>= 2.0.3``) 126 | 127 | Base-64 types 128 | ------------- 129 | 130 | - :py:obj:`pydantic.types.Base64Bytes` (``>= 2.0.3``) 131 | - :py:obj:`pydantic.types.Base64Str` (``>= 2.0.3``) 132 | - :py:obj:`pydantic.types.Base64UrlBytes` (``>= 2.4.0``) 133 | - :py:obj:`pydantic.types.Base64UrlStr` (``>= 2.4.0``) 134 | 135 | Byte types 136 | ---------- 137 | 138 | - :py:obj:`pydantic.types.ByteSize` (``>= 2.0.3``) 139 | - :py:obj:`pydantic.types.SecretBytes` (``>= 2.0.3``) 140 | - :py:obj:`pydantic.types.StrictBytes` (``>= 2.0.3``) 141 | - :py:obj:`pydantic.types.conbytes` (``>= 2.0.3``) 142 | 143 | JSON type 144 | --------- 145 | 146 | - :py:obj:`pydantic.types.Json` (``>= 2.0.3``) 147 | - :py:obj:`pydantic.types.JsonValue` (``>= 2.5.0``) 148 | 149 | IP address types 150 | ---------------- 151 | 152 | - :py:obj:`pydantic.networks.IPvAnyAddress` (``>= 2.0.3``) 153 | - :py:obj:`pydantic.networks.IPvAnyInterface` (``>= 2.0.3``) 154 | - :py:obj:`pydantic.networks.IPvAnyNetwork` (``>= 2.0.3``) 155 | 156 | Database connection types 157 | ------------------------- 158 | 159 | - :py:obj:`pydantic.networks.AmqpDsn` (``>= 2.0.3``) 160 | - :py:obj:`pydantic.networks.CockroachDsn` (``>= 2.0.3``) 161 | - :py:obj:`pydantic.networks.KafkaDsn` (``>= 2.0.3``) 162 | - :py:obj:`pydantic.networks.MariaDBDsn` (``>= 2.0.3``) 163 | - :py:obj:`pydantic.networks.MongoDsn` (``>= 2.0.3``) 164 | - :py:obj:`pydantic.networks.MySQLDsn` (``>= 2.0.3``) 165 | - :py:obj:`pydantic.networks.PostgresDsn` (``>= 2.0.3``) 166 | - :py:obj:`pydantic.networks.RedisDsn` (``>= 2.0.3``) 167 | 168 | UUID types 169 | ---------- 170 | 171 | - :py:obj:`pydantic.types.UUID1` (``>= 2.0.3``) 172 | - :py:obj:`pydantic.types.UUID3` (``>= 2.0.3``) 173 | - :py:obj:`pydantic.types.UUID4` (``>= 2.0.3``) 174 | - :py:obj:`pydantic.types.UUID5` (``>= 2.0.3``) 175 | 176 | Boolean type 177 | ------------ 178 | 179 | - :py:obj:`pydantic.types.StrictBool` (``>= 2.0.3``) 180 | 181 | Other types 182 | ----------- 183 | 184 | - :py:obj:`pydantic.functional_validators.SkipValidation` (``>= 2.0.3``) 185 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_docstring.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | import enum 7 | 8 | import pytest 9 | 10 | import feud 11 | from feud._internal import _docstring 12 | 13 | 14 | class Mode(enum.Enum): 15 | FUNCTION = "function" 16 | COMMAND = "command" 17 | COMMAND_WITH_HELP = "command_with_help" 18 | STRING = "string" 19 | 20 | 21 | @pytest.mark.parametrize("mode", list(Mode)) 22 | def test_get_description_function_no_doc(mode: Mode) -> None: 23 | def f() -> None: 24 | pass 25 | 26 | if mode == Mode.COMMAND: 27 | f = feud.command()(f) 28 | assert f.help == _docstring.get_description(f) 29 | elif mode == Mode.COMMAND_WITH_HELP: 30 | f = feud.command(help="Override.")(f) 31 | assert f.help == "Override." 32 | elif mode == Mode.STRING: 33 | f = f.__doc__ 34 | 35 | assert _docstring.get_description(f) is None 36 | 37 | 38 | @pytest.mark.parametrize("mode", list(Mode)) 39 | def test_get_description_function_single_line_doc(mode: Mode) -> None: 40 | def f() -> None: 41 | """Line 1.""" 42 | 43 | if mode == Mode.COMMAND: 44 | f = feud.command()(f) 45 | assert f.help == _docstring.get_description(f) 46 | elif mode == Mode.COMMAND_WITH_HELP: 47 | f = feud.command(help="Override.")(f) 48 | assert f.help == "Override." 49 | elif mode == Mode.STRING: 50 | f = f.__doc__ 51 | 52 | assert _docstring.get_description(f) == "Line 1." 53 | 54 | 55 | @pytest.mark.parametrize("mode", list(Mode)) 56 | def test_get_description_function_multi_line_doc(mode: Mode) -> None: 57 | def f() -> None: 58 | """Line 1. 59 | 60 | Line 2. 61 | """ 62 | 63 | if mode == Mode.COMMAND: 64 | f = feud.command()(f) 65 | assert f.help == _docstring.get_description(f) 66 | elif mode == Mode.COMMAND_WITH_HELP: 67 | f = feud.command(help="Override.")(f) 68 | assert f.help == "Override." 69 | elif mode == Mode.STRING: 70 | f = f.__doc__ 71 | 72 | assert _docstring.get_description(f) == "Line 1.\n\nLine 2." 73 | 74 | 75 | @pytest.mark.parametrize("mode", list(Mode)) 76 | def test_get_description_function_multi_line_doc_with_f(mode: Mode) -> None: 77 | def f() -> None: 78 | """Line 1. 79 | 80 | Line 2.\f 81 | """ 82 | 83 | if mode == Mode.COMMAND: 84 | f = feud.command()(f) 85 | assert f.help == _docstring.get_description(f) 86 | elif mode == Mode.COMMAND_WITH_HELP: 87 | f = feud.command(help="Override.")(f) 88 | assert f.help == "Override." 89 | elif mode == Mode.STRING: 90 | f = f.__doc__ 91 | 92 | assert _docstring.get_description(f) == "Line 1.\n\nLine 2." 93 | 94 | 95 | @pytest.mark.parametrize("mode", list(Mode)) 96 | def test_get_description_function_single_line_doc_with_params( 97 | mode: Mode, 98 | ) -> None: 99 | def f(*, opt: int) -> None: 100 | """Line 1. 101 | 102 | Parameters 103 | ---------- 104 | opt: 105 | An option. 106 | """ 107 | 108 | if mode == Mode.COMMAND: 109 | f = feud.command()(f) 110 | assert f.help == _docstring.get_description(f) 111 | assert f.params[0].help == "An option." 112 | elif mode == Mode.COMMAND_WITH_HELP: 113 | f = feud.command(help="Override.")(f) 114 | assert f.help == "Override." 115 | elif mode == Mode.STRING: 116 | f = f.__doc__ 117 | 118 | assert _docstring.get_description(f) == "Line 1." 119 | 120 | 121 | @pytest.mark.parametrize("mode", list(Mode)) 122 | def test_get_description_function_multi_line_doc_with_params( 123 | mode: Mode, 124 | ) -> None: 125 | def f(*, opt: int) -> None: 126 | """Line 1. 127 | 128 | Line 2. 129 | 130 | Parameters 131 | ---------- 132 | opt: 133 | An option. 134 | """ 135 | 136 | if mode == Mode.COMMAND: 137 | f = feud.command()(f) 138 | assert f.help == _docstring.get_description(f) 139 | assert f.params[0].help == "An option." 140 | elif mode == Mode.COMMAND_WITH_HELP: 141 | f = feud.command(help="Override.")(f) 142 | assert f.help == "Override." 143 | elif mode == Mode.STRING: 144 | f = f.__doc__ 145 | 146 | assert _docstring.get_description(f) == "Line 1.\n\nLine 2." 147 | 148 | 149 | @pytest.mark.parametrize("mode", list(Mode)) 150 | def test_get_description_function_multi_line_doc_with_params_and_f( 151 | mode: Mode, 152 | ) -> None: 153 | def f(*, opt: int) -> None: 154 | """Line 1. 155 | 156 | Line 2.\f 157 | 158 | Parameters 159 | ---------- 160 | opt 161 | An option. 162 | """ 163 | 164 | if mode == Mode.COMMAND: 165 | f = feud.command()(f) 166 | assert f.help == _docstring.get_description(f) 167 | assert f.params[0].help == "An option." 168 | elif mode == Mode.COMMAND_WITH_HELP: 169 | f = feud.command(help="Override.")(f) 170 | assert f.help == "Override." 171 | elif mode == Mode.STRING: 172 | f = f.__doc__ 173 | 174 | assert _docstring.get_description(f) == "Line 1.\n\nLine 2." 175 | 176 | 177 | def test_get_description_class_single_line_no_doc() -> None: 178 | class Group(feud.Group): 179 | pass 180 | 181 | assert Group.compile().help is None 182 | 183 | 184 | def test_get_description_class_single_line_doc() -> None: 185 | class Group(feud.Group): 186 | """Line 1.""" 187 | 188 | assert Group.compile().help == "Line 1." 189 | 190 | 191 | def test_get_description_class_single_line_doc_with_examples() -> None: 192 | class Group(feud.Group): 193 | """Line 1. 194 | 195 | Examples 196 | -------- 197 | >>> # Hello World! 198 | """ 199 | 200 | assert Group.compile().help == "Line 1." 201 | 202 | 203 | def test_get_description_class_multi_line_doc() -> None: 204 | class Group(feud.Group): 205 | """Line 1. 206 | 207 | Line 2. 208 | """ 209 | 210 | assert Group.compile().help == "Line 1.\n\nLine 2." 211 | 212 | 213 | def test_get_description_class_multi_line_doc_with_examples() -> None: 214 | class Group(feud.Group): 215 | """Line 1. 216 | 217 | Line 2. 218 | 219 | Examples 220 | -------- 221 | >>> # Hello World! 222 | """ 223 | 224 | assert Group.compile().help == "Line 1.\n\nLine 2." 225 | 226 | 227 | def test_get_description_class_multi_line_doc_with_f() -> None: 228 | class Group(feud.Group): 229 | """Line 1. 230 | 231 | Line 2.\f 232 | """ 233 | 234 | assert Group.compile().help == "Line 1.\n\nLine 2." 235 | 236 | 237 | def test_get_description_class_multi_line_doc_with_examples_and_f() -> None: 238 | class Group(feud.Group): 239 | """Line 1. 240 | 241 | Line 2.\f 242 | 243 | Examples 244 | -------- 245 | >>> # Hello World! 246 | """ 247 | 248 | assert Group.compile().help == "Line 1.\n\nLine 2." 249 | 250 | 251 | def test_get_description_class_main() -> None: 252 | class Group(feud.Group): 253 | def __main__() -> None: 254 | """Line 1. 255 | 256 | Line 2.\f 257 | 258 | Examples 259 | -------- 260 | >>> # Hello World! 261 | """ 262 | 263 | assert Group.compile().help == "Line 1.\n\nLine 2." 264 | 265 | 266 | def test_get_description_class_override() -> None: 267 | class Group(feud.Group, help="Override."): 268 | """Line 1. 269 | 270 | Line 2.\f 271 | 272 | Examples 273 | -------- 274 | >>> # Hello World! 275 | """ 276 | 277 | assert Group.compile().help == "Override." 278 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "feud" 3 | version = "1.0.1" 4 | license = "MIT" 5 | authors = ["Edwin Onuonga "] 6 | maintainers = ["Edwin Onuonga "] 7 | description = "Build powerful CLIs with simple idiomatic Python, driven by type hints. Not all arguments are bad." 8 | readme = "README.md" 9 | homepage = "https://github.com/eonu/feud" 10 | repository = "https://github.com/eonu/feud" 11 | documentation = "https://feud.readthedocs.io" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Environment :: Console", 15 | "Framework :: Pydantic :: 2", 16 | "Intended Audience :: Developers", 17 | "Intended Audience :: Information Technology", 18 | "Intended Audience :: System Administrators", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Topic :: Software Development :: Libraries :: Application Frameworks", 28 | "Topic :: Software Development :: Libraries :: Python Modules", 29 | "Topic :: Software Development :: User Interfaces", 30 | "Topic :: Terminals", 31 | "Typing :: Typed", 32 | 33 | ] 34 | keywords = [ 35 | "python", 36 | "cli", 37 | "terminal", 38 | "command-line", 39 | "typed", 40 | "docstrings", 41 | "typehints", 42 | "pydantic", 43 | "click", 44 | ] 45 | packages = [{ include = "feud" }] 46 | include = [ 47 | "feud", 48 | "make", 49 | "tests", 50 | "CHANGELOG.md", 51 | "LICENSE", 52 | "Makefile", 53 | "pyproject.toml", 54 | "README.md", 55 | "tasks.py", 56 | "tox.ini", 57 | ] 58 | 59 | [build-system] 60 | requires = ['poetry-core~=1.0'] 61 | build-backend = 'poetry.core.masonry.api' 62 | 63 | [tool.poetry.dependencies] 64 | python = "^3.11" 65 | packaging = "^24.0" 66 | pydantic = "^2.0.3" 67 | click = "^8.1.0" 68 | docstring-parser = "^0.15" 69 | Pydantic = { version = "^2.0.3", optional = true, extras = ["email"] } 70 | rich-click = { version = "^1.6.1", optional = true } 71 | # TODO: change to extras=["all"] (https://github.com/pydantic/pydantic-extra-types/issues/239) 72 | pydantic-extra-types = { version = "^2.1.0", optional = true, extras = ["phonenumbers", "pycountry", "semver", "python_ulid"] } 73 | 74 | [tool.poetry.extras] 75 | rich = ["rich-click"] 76 | email = ["email"] 77 | extra-types = ["pydantic-extra-types"] 78 | all = ["rich-click", "email", "pydantic-extra-types"] 79 | 80 | [tool.poetry.group.base.dependencies] 81 | invoke = "2.2.0" 82 | tox = "4.11.3" 83 | 84 | [tool.poetry.group.dev.dependencies] 85 | pre-commit = ">=3" 86 | 87 | [tool.poetry.group.lint.dependencies] 88 | ruff = "0.8.4" 89 | pydoclint = "0.3.8" 90 | 91 | [tool.poetry.group.docs.dependencies] 92 | sphinx = "^7.2.4,<=7.2.6" 93 | sphinx-autobuild = "^2021.3.14" 94 | furo = "^2023.9.10" 95 | numpydoc = "^1.6.0" 96 | sphinx-favicon = "^1.0.1" 97 | sphinx_design = "^0.5.0" 98 | autodoc-pydantic = ">=2.0.0" 99 | 100 | [tool.poetry.group.tests.dependencies] 101 | pytest = "^7.4.0" 102 | pytest-cov = "^4.1.0" 103 | 104 | [tool.poetry.group.types.dependencies] 105 | mypy = "1.14.0" 106 | 107 | [tool.ruff] 108 | required-version = "0.8.4" 109 | lint.select = [ 110 | "F", # pyflakes: https://pypi.org/project/pyflakes/ 111 | "E", # pycodestyle (error): https://pypi.org/project/pycodestyle/ 112 | "W", # pycodestyle (warning): https://pypi.org/project/pycodestyle/ 113 | "I", # isort: https://pypi.org/project/isort/ 114 | "N", # pep8-naming: https://pypi.org/project/pep8-naming/ 115 | "D", # pydocstyle: https://pypi.org/project/pydocstyle/ 116 | "UP", # pyupgrade: https://pypi.org/project/pyupgrade/ 117 | "YTT", # flake8-2020: https://pypi.org/project/flake8-2020/ 118 | "ANN", # flake8-annotations: https://pypi.org/project/flake8-annotations/ 119 | "S", # flake8-bandit: https://pypi.org/project/flake8-bandit/ 120 | "BLE", # flake8-blind-except: https://pypi.org/project/flake8-blind-except/ 121 | "FBT", # flake8-boolean-trap: https://pypi.org/project/flake8-boolean-trap/ 122 | "B", # flake8-bugbear: https://pypi.org/project/flake8-bugbear/ 123 | "A", # flake8-builtins: https://pypi.org/project/flake8-builtins/ 124 | "COM", # flake8-commas: https://pypi.org/project/flake8-commas/ 125 | "C4", # flake8-comprehensions: https://pypi.org/project/flake8-comprehensions/ 126 | "T10", # flake8-debugger: https://pypi.org/project/flake8-debugger/ 127 | "EM", # flake8-errmsg: https://pypi.org/project/flake8-errmsg/ 128 | "FA", # flake8-future-annotations: https://pypi.org/project/flake8-future-annotations/ 129 | "ISC", # flake8-implicit-str-concat: https://pypi.org/project/flake8-implicit-str-concat/ 130 | "ICN", # flake8-import-conventions: https://github.com/joaopalmeiro/flake8-import-conventions/ 131 | "G", # flake8-logging-format: https://pypi.org/project/flake8-logging-format/ 132 | "INP", # flake8-no-pep420: https://pypi.org/project/flake8-no-pep420/ 133 | "PIE", # flake8-pie: https://pypi.org/project/flake8-pie/ 134 | "T20", # flake8-print: https://pypi.org/project/flake8-print/ 135 | "PT", # flake8-pytest-style: https://pypi.org/project/flake8-pytest-style/ 136 | "Q", # flake8-quotes: https://pypi.org/project/flake8-quotes/ 137 | "RSE", # flake8-raise: https://pypi.org/project/flake8-raise/ 138 | "RET", # flake8-return: https://pypi.org/project/flake8-return/ 139 | "SLF", # flake8-self: https://pypi.org/project/flake8-self/ 140 | "SIM", # flake8-simplify: https://pypi.org/project/flake8-simplify/ 141 | "TID", # flake8-tidy-imports: https://pypi.org/project/flake8-tidy-imports/ 142 | "ARG", # flake8-unused-arguments: https://pypi.org/project/flake8-unused-arguments/ 143 | "TD", # flake8-todos: https://github.com/orsinium-labs/flake8-todos/ 144 | "ERA", # eradicate: https://pypi.org/project/eradicate/ 145 | "PGH", # pygrep-hooks: https://github.com/pre-commit/pygrep-hooks/ 146 | "PL", # pylint: https://pypi.org/project/pylint/ 147 | "TRY", # tryceratops: https://pypi.org/project/tryceratops/ 148 | "FLY", # flynt: https://pypi.org/project/flynt/ 149 | "PERF", # perflint: https://pypi.org/project/perflint/ 150 | "RUF", # ruff 151 | ] 152 | lint.ignore = [ 153 | "ANN401", # https://beta.ruff.rs/docs/rules/any-type/ 154 | "B905", # https://beta.ruff.rs/docs/rules/zip-without-explicit-strict/ 155 | "TD003", # https://beta.ruff.rs/docs/rules/missing-todo-link/ 156 | "PLR0913", # https://docs.astral.sh/ruff/rules/too-many-arguments/ 157 | "PLR0912", # https://docs.astral.sh/ruff/rules/too-many-branches/ 158 | "D205", # 1 blank line required between summary line and description 159 | "PLR0911", # Too many return statements 160 | "PLR2004", # Magic value used in comparison, consider replacing * with a constant variable" 161 | "COM812", # ruff format conflict 162 | "ISC001", # ruff format conflict 163 | "ERA001", # Found commented-out code 164 | "UP006", # Use deque/frozenset/set/list/tuple instead of Deque/FrozenSet/Set/List/Tuple 165 | ] 166 | line-length = 79 167 | lint.typing-modules = ["feud.typing"] 168 | 169 | [tool.ruff.lint.pydocstyle] 170 | convention = "numpy" 171 | 172 | [tool.ruff.lint.flake8-annotations] 173 | allow-star-arg-any = true 174 | 175 | [tool.ruff.lint.extend-per-file-ignores] 176 | "__init__.py" = ["PLC0414", "F403", "F401", "F405"] 177 | "feud/typing/*.py" = ["PLC0414", "F403", "F401"] 178 | "tests/**/*.py" = ["D100", "D100", "D101", "D102", "D103", "D104"] # temporary 179 | "tests/**/test_*.py" = ["ARG001", "S101", "D", "FA100", "FA102", "PLR0915"] 180 | 181 | [tool.pydoclint] 182 | style = "numpy" 183 | exclude = ".git|.tox|feud/_internal|tests" # temporary 184 | check-return-types = false 185 | arg-type-hints-in-docstring = false 186 | quiet = true 187 | 188 | [tool.pytest.ini_options] 189 | addopts = ["--import-mode=importlib"] 190 | -------------------------------------------------------------------------------- /tests/unit/test_internal/test_types/test_click/test_get_click_type/test_pydantic.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Feud Developers. 2 | # Distributed under the terms of the MIT License (see the LICENSE file). 3 | # SPDX-License-Identifier: MIT 4 | # This source code is part of the Feud project (https://feud.wiki). 5 | 6 | from __future__ import annotations 7 | 8 | import click 9 | import pydantic as pyd 10 | import pytest 11 | 12 | from feud import typing as t 13 | from feud._internal._types.click import DateTime 14 | from feud.config import Config 15 | 16 | 17 | @pytest.mark.parametrize("annotated", [False, True]) 18 | @pytest.mark.parametrize( 19 | ("hint", "expected"), 20 | [ 21 | # pydantic types 22 | (t.AmqpDsn, None), 23 | (t.AnyHttpUrl, None), 24 | (t.AnyUrl, None), 25 | ( 26 | t.AwareDatetime, 27 | lambda x: ( 28 | isinstance(x, DateTime) and x.name == t.datetime.__name__ 29 | ), 30 | ), 31 | (t.Base64Bytes, None), 32 | (t.Base64Str, click.STRING), 33 | (t.ByteSize, None), 34 | (t.CockroachDsn, None), 35 | ( 36 | t.DirectoryPath, 37 | lambda x: isinstance(x, click.Path) and x.exists is True, 38 | ), 39 | (t.EmailStr, None), 40 | ( 41 | t.FilePath, 42 | lambda x: isinstance(x, click.Path) and x.exists is True, 43 | ), 44 | (t.FileUrl, None), 45 | (t.FiniteFloat, click.FLOAT), 46 | ( 47 | t.FutureDate, 48 | lambda x: isinstance(x, DateTime) and x.name == t.date.__name__, 49 | ), 50 | ( 51 | t.FutureDatetime, 52 | lambda x: ( 53 | isinstance(x, DateTime) and x.name == t.datetime.__name__ 54 | ), 55 | ), 56 | (t.HttpUrl, None), 57 | (t.IPvAnyAddress, None), 58 | (t.IPvAnyInterface, None), 59 | (t.IPvAnyNetwork, None), 60 | (t.ImportString, None), 61 | (t.Json, None), 62 | (t.KafkaDsn, None), 63 | (t.MariaDBDsn, None), 64 | (t.MongoDsn, None), 65 | (t.MySQLDsn, None), 66 | ( 67 | t.NaiveDatetime, 68 | lambda x: ( 69 | isinstance(x, DateTime) and x.name == t.datetime.__name__ 70 | ), 71 | ), 72 | (t.NameEmail, None), 73 | ( 74 | t.NegativeFloat, 75 | lambda x: isinstance(x, click.FloatRange) 76 | and x.min is None 77 | and x.min_open is False 78 | and x.max == 0 79 | and x.max_open is True, 80 | ), 81 | ( 82 | t.NegativeInt, 83 | lambda x: isinstance(x, click.IntRange) 84 | and x.min is None 85 | and x.min_open is False 86 | and x.max == 0 87 | and x.max_open is True, 88 | ), 89 | ( 90 | t.NewPath, 91 | lambda x: isinstance(x, click.Path) and x.exists is False, 92 | ), 93 | ( 94 | t.NonNegativeFloat, 95 | lambda x: isinstance(x, click.FloatRange) 96 | and x.min == 0 97 | and x.min_open is False 98 | and x.max is None 99 | and x.max_open is False, 100 | ), 101 | ( 102 | t.NonNegativeInt, 103 | lambda x: isinstance(x, click.IntRange) 104 | and x.min == 0 105 | and x.min_open is False 106 | and x.max is None 107 | and x.max_open is False, 108 | ), 109 | ( 110 | t.NonPositiveFloat, 111 | lambda x: isinstance(x, click.FloatRange) 112 | and x.min is None 113 | and x.min_open is False 114 | and x.max == 0 115 | and x.max_open is False, 116 | ), 117 | ( 118 | t.NonPositiveInt, 119 | lambda x: isinstance(x, click.IntRange) 120 | and x.min is None 121 | and x.min_open is False 122 | and x.max == 0 123 | and x.max_open is False, 124 | ), 125 | ( 126 | t.PastDate, 127 | lambda x: isinstance(x, DateTime) and x.name == t.date.__name__, 128 | ), 129 | ( 130 | t.PastDatetime, 131 | lambda x: ( 132 | isinstance(x, DateTime) and x.name == t.datetime.__name__ 133 | ), 134 | ), 135 | ( 136 | t.PositiveFloat, 137 | lambda x: isinstance(x, click.FloatRange) 138 | and x.min == 0 139 | and x.min_open is True 140 | and x.max is None 141 | and x.max_open is False, 142 | ), 143 | ( 144 | t.PositiveInt, 145 | lambda x: isinstance(x, click.IntRange) 146 | and x.min == 0 147 | and x.min_open is True 148 | and x.max is None 149 | and x.max_open is False, 150 | ), 151 | (t.PostgresDsn, None), 152 | (t.RedisDsn, None), 153 | (t.SecretBytes, None), 154 | (t.SecretStr, None), 155 | (t.SkipValidation, None), 156 | (t.StrictBool, click.BOOL), 157 | (t.StrictBytes, None), 158 | (t.StrictFloat, click.FLOAT), 159 | (t.StrictInt, click.INT), 160 | (t.StrictStr, click.STRING), 161 | (t.UUID1, click.UUID), 162 | (t.UUID3, click.UUID), 163 | (t.UUID4, click.UUID), 164 | (t.UUID5, click.UUID), 165 | (t.conbytes(max_length=1), None), 166 | ( 167 | t.condate(lt=t.date.today()), 168 | lambda x: isinstance(x, DateTime) and x.name == t.date.__name__, 169 | ), 170 | ( 171 | t.condecimal(lt=t.Decimal("3.14"), ge=t.Decimal("0.01")), 172 | lambda x: isinstance(x, click.FloatRange) 173 | and x.min == t.Decimal("0.01") 174 | and x.min_open is False 175 | and x.max == t.Decimal("3.14") 176 | and x.max_open is True, 177 | ), 178 | ( 179 | t.Annotated[ 180 | t.Decimal, 181 | pyd.Field(lt=t.Decimal("3.14"), ge=t.Decimal("0.01")), 182 | ], 183 | lambda x: isinstance(x, click.FloatRange) 184 | and x.min == t.Decimal("0.01") 185 | and x.min_open is False 186 | and x.max == t.Decimal("3.14") 187 | and x.max_open is True, 188 | ), 189 | ( 190 | t.confloat(lt=3.14, ge=0.01), 191 | lambda x: isinstance(x, click.FloatRange) 192 | and x.min == 0.01 193 | and x.min_open is False 194 | and x.max == 3.14 195 | and x.max_open is True, 196 | ), 197 | ( 198 | t.Annotated[float, pyd.Field(lt=3.14, ge=0.01)], 199 | lambda x: isinstance(x, click.FloatRange) 200 | and x.min == 0.01 201 | and x.min_open is False 202 | and x.max == 3.14 203 | and x.max_open is True, 204 | ), 205 | (t.confrozenset(int, max_length=1), click.INT), 206 | ( 207 | t.conint(lt=3, ge=0), 208 | lambda x: isinstance(x, click.IntRange) 209 | and x.min == 0 210 | and x.min_open is False 211 | and x.max == 3 212 | and x.max_open is True, 213 | ), 214 | ( 215 | t.Annotated[int, pyd.Field(lt=3, ge=0)], 216 | lambda x: isinstance(x, click.IntRange) 217 | and x.min == 0 218 | and x.min_open is False 219 | and x.max == 3 220 | and x.max_open is True, 221 | ), 222 | (t.conlist(int, max_length=1), click.INT), 223 | (t.conset(int, max_length=1), click.INT), 224 | (t.constr(max_length=1), click.STRING), 225 | (t.Annotated[int, pyd.AfterValidator(lambda x: x + 1)], click.INT), 226 | (t.Base64UrlBytes, None), 227 | (t.Base64UrlStr, click.STRING), 228 | (t.JsonValue, None), 229 | (t.NatsDsn, None), 230 | (t.ClickHouseDsn, None), 231 | (t.FtpUrl, None), 232 | (t.WebsocketUrl, None), 233 | (t.AnyWebsocketUrl, None), 234 | (t.SnowflakeDsn, None), 235 | (t.SocketPath, lambda x: isinstance(x, click.Path)), 236 | ], 237 | ) 238 | def test_pydantic( 239 | helpers: type, 240 | *, 241 | config: Config, 242 | annotated: bool, 243 | hint: t.Any, 244 | expected: click.ParamType | None, 245 | ) -> None: 246 | helpers.check_get_click_type( 247 | config=config, 248 | annotated=annotated, 249 | hint=hint, 250 | expected=expected, 251 | ) 252 | --------------------------------------------------------------------------------