├── 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 |
--------------------------------------------------------------------------------