From 33997f1dadb82a65217826d1020f31286c6727b6 Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Fri, 8 Dec 2023 02:56:46 +0000 Subject: [PATCH 01/12] feat: add `email` extra, issue/PR templates, `version` module (#84) --- .github/issue_templates/bug.yml | 82 ++++++++++++++++++++++++ .github/issue_templates/config.yml | 5 ++ .github/issue_templates/feature.yml | 21 ++++++ .github/pull_request_template.md | 13 ++++ .readthedocs.yaml | 2 +- README.md | 6 +- docs/source/sections/typing/pydantic.rst | 9 +++ feud/__init__.py | 4 +- feud/version.py | 78 ++++++++++++++++++++++ make/cov.py | 2 +- make/docs.py | 2 +- make/lint.py | 2 +- make/tests.py | 4 +- pyproject.toml | 5 +- tests/unit/test_version.py | 27 ++++++++ 15 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 .github/issue_templates/bug.yml create mode 100644 .github/issue_templates/config.yml create mode 100644 .github/issue_templates/feature.yml create mode 100644 .github/pull_request_template.md create mode 100644 feud/version.py create mode 100644 tests/unit/test_version.py diff --git a/.github/issue_templates/bug.yml b/.github/issue_templates/bug.yml new file mode 100644 index 0000000..cf8362d --- /dev/null +++ b/.github/issue_templates/bug.yml @@ -0,0 +1,82 @@ +name: Report unexpected behaviour +description: If you came across something unexpected, let us know here! +labels: [bug, pending] + +body: + - type: checkboxes + id: exists + attributes: + label: Has this already been reported? + description: If you haven't already, please look other existing issues to see if this bug has already been reported. + options: + - label: This is a new bug! + required: true + + - type: textarea + id: expected-behaviour + attributes: + label: Expected behaviour + description: | + Please describe the behaviour that you expected to see. + + If appropriate, provide any links to official Feud documentation that indicate this is the behaviour that is expected. + validations: + required: true + + - type: textarea + id: observed-behaviour + attributes: + label: Observed behaviour + description: | + Please describe the unexpected behaviour that you observed. + + Make sure to provide as much information as possible, so that we can investigate as thoroughly as we can! + validations: + required: true + + - type: textarea + id: example + attributes: + label: Code to reproduce + description: > + Please provide a snippet of code that shows how to reproduce the bug, + making sure that it is [minimal and reproducible](https://stackoverflow.com/help/minimal-reproducible-example). + + placeholder: | + """To reproduce my bug, run the following script with: + + python command.py --bug + """ + + # command.py + + import feud + + def command(*, bug: bool = False): + """Command that demonstrates the bug.""" + if bug: + raise ValueError("Woops, this is buggy!") + + if __name__ == "__main__": + feud.run(command) + render: Python + + - type: textarea + id: version + attributes: + label: Version details + description: | + To help us get to the root of the problem as fast as possible, + please run the following command to display version information about: + + - Python + - Feud + - Operating system + + ```bash + python -c "import feud; print(feud.version.version_info())" + ``` + + render: Text + validations: + required: true diff --git a/.github/issue_templates/config.yml b/.github/issue_templates/config.yml new file mode 100644 index 0000000..33ab47a --- /dev/null +++ b/.github/issue_templates/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Got a question? + url: "https://github.com/eonu/feud/discussions/new?category=question" + about: Start a discussion on GitHub discussions where Feud developers and users can respond. diff --git a/.github/issue_templates/feature.yml b/.github/issue_templates/feature.yml new file mode 100644 index 0000000..77fe0e4 --- /dev/null +++ b/.github/issue_templates/feature.yml @@ -0,0 +1,21 @@ +name: Request a new feature or improvement +description: If you have a suggestion for something that might improve feud, let us know here! +labels: [feature, pending] + +body: + - type: checkboxes + id: exists + attributes: + label: Does this suggestion already exist? + description: If you haven't already, please look through the documentation and other existing issues to see if this feature is already implemented. + options: + - label: This is a new feature! + required: true + + - type: textarea + id: feature-description + attributes: + label: Feature description + description: Please describe the new feature or improvement that you would like. + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..5697917 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Description + + + + + + +## Checklist + +- [ ] I have added new tests (if necessary). +- [ ] I have ensured that tests and coverage are passing on CI. +- [ ] I have updated any relevant documentation (if necessary). +- [ ] I have used a descriptive pull request title. diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a1fcbe8..9b14b2c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ build: post_install: - pip install poetry - poetry config virtualenvs.create false - - poetry install --only base,main,docs -E extra-types + - poetry install --only base,main,docs sphinx: configuration: docs/source/conf.py diff --git a/README.md b/README.md index 9283040..90ade49 100644 --- a/README.md +++ b/README.md @@ -747,10 +747,12 @@ pip install feud[all] This installs Feud with the optional dependencies: -- [**`rich-click`**](https://github.com/ewels/rich-click) (can install individually with `pip install feud[rich]`)
+- [`rich-click`](https://github.com/ewels/rich-click) (can install individually with `pip install feud[rich]`)
_Provides improved formatting for CLIs produced by Feud._ -- [**`pydantic-extra-types`**](https://github.com/pydantic/pydantic-extra-types) (can install individually with `pip install feud[extra-types]`)
+- [`pydantic-extra-types`](https://github.com/pydantic/pydantic-extra-types) (can install individually with `pip install feud[extra-types]`)
_Provides additional types that can be used as type hints for Feud commands._ +- [`email-validator`](https://github.com/JoshData/python-email-validator) (can install individually with `pip install feud[email]`)
+ _Provides Pydantic support for email validation._ To install Feud without any optional dependencies, simply run `pip install feud`. diff --git a/docs/source/sections/typing/pydantic.rst b/docs/source/sections/typing/pydantic.rst index d4ecfde..ddebd09 100644 --- a/docs/source/sections/typing/pydantic.rst +++ b/docs/source/sections/typing/pydantic.rst @@ -107,6 +107,15 @@ URL types Email types ----------- +.. important:: + + In order to use email types, you must install Feud with the optional + ``email-validator`` dependency (see `here `__). + + .. code:: console + + $ pip install feud[email] + - :py:obj:`pydantic.networks.EmailStr` - :py:obj:`pydantic.networks.NameEmail` diff --git a/feud/__init__.py b/feud/__init__.py index 8a60440..30a79cd 100644 --- a/feud/__init__.py +++ b/feud/__init__.py @@ -7,7 +7,9 @@ Not all arguments are bad. """ -__version__ = "0.1.0" +import feud.version + +__version__ = feud.version.VERSION from feud import click as click from feud import exceptions as exceptions diff --git a/feud/version.py b/feud/version.py new file mode 100644 index 0000000..809b97e --- /dev/null +++ b/feud/version.py @@ -0,0 +1,78 @@ +# Copyright (c) 2023-2025 Feud Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Feud project (https://feud.wiki). + +"""Version information for Feud. + +Source code modified from pydantic (https://github.com/pydantic/pydantic). + + The MIT License (MIT) + + Copyright (c) 2017 to present Pydantic Services Inc. and individual + contributors. + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +""" + +__all__ = ["VERSION", "version_info"] + +VERSION = "0.1.0" + + +def version_info() -> str: + """Return complete version information for Feud and its dependencies.""" + import importlib.metadata as importlib_metadata + import platform + import sys + from pathlib import Path + + # get data about packages that: + # - are closely related to feud, + # - use feud, + # - often conflict with feud. + package_names = { + "click", + "pydantic", + "docstring-parser", + "rich-click", + "rich", + "email-validator", + "pydantic-extra-types", + "phonenumbers", + "pycountry", + } + related_packages = [] + + for dist in importlib_metadata.distributions(): + name = dist.metadata["Name"] + if name in package_names: + related_packages.append(f"{name}-{dist.version}") + + info = { + "feud version": VERSION, + "install path": Path(__file__).resolve().parent, + "python version": sys.version, + "platform": platform.platform(), + "related packages": " ".join(related_packages), + } + return "\n".join( + "{:>30} {}".format(k + ":", str(v).replace("\n", " ")) + for k, v in info.items() + ) diff --git a/make/cov.py b/make/cov.py index d7b1bc6..efda912 100644 --- a/make/cov.py +++ b/make/cov.py @@ -12,4 +12,4 @@ @task def install(c: Config) -> None: """Install package with core and coverage dependencies.""" - c.run("poetry install --sync --only base,main,cov -E extra-types") + c.run("poetry install --sync --only base,main,cov -E extra-types -E email") diff --git a/make/docs.py b/make/docs.py index 26574c8..b3c2883 100644 --- a/make/docs.py +++ b/make/docs.py @@ -12,7 +12,7 @@ @task def install(c: Config) -> None: """Install package with core and docs dependencies.""" - c.run("poetry install --sync --only base,main,docs -E extra-types") + c.run("poetry install --sync --only base,main,docs") @task diff --git a/make/lint.py b/make/lint.py index ab8b3d6..a8025be 100644 --- a/make/lint.py +++ b/make/lint.py @@ -14,7 +14,7 @@ @task def install(c: Config) -> None: """Install package with core and dev dependencies.""" - c.run("poetry install --sync --only base,main,lint -E extra-types") + c.run("poetry install --sync --only base,main,lint") @task diff --git a/make/tests.py b/make/tests.py index 6d07b8a..a639801 100644 --- a/make/tests.py +++ b/make/tests.py @@ -14,7 +14,9 @@ @task def install(c: Config) -> None: """Install package with core and test dependencies.""" - c.run("poetry install --sync --only base,main,tests -E extra-types") + c.run( + "poetry install --sync --only base,main,tests -E extra-types -E email" + ) @task diff --git a/pyproject.toml b/pyproject.toml index 48b892f..13ad1ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: User Interfaces", @@ -63,13 +64,15 @@ python = "^3.11" pydantic = "^2.0.0" click = "^8.1.7" docstring-parser = "^0.15" +Pydantic = { version = "^2.0.0", optional = true, extras = ["email"] } rich-click = { version = "^1.6.1", optional = true } pydantic-extra-types = { version = "^2.1.0", optional = true, extras = ["all"] } [tool.poetry.extras] rich = ["rich-click"] +email = ["email"] extra-types = ["pydantic-extra-types"] -all = ["rich-click", "pydantic-extra-types"] +all = ["rich-click", "email", "pydantic-extra-types"] [tool.poetry.group.base.dependencies] invoke = "2.2.0" diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000..428f665 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,27 @@ +# Copyright (c) 2023-2025 Feud Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Feud project (https://feud.wiki). + +import re + +import feud +from feud.version import VERSION, version_info + + +def test_version() -> None: + """Check that the version is a valid SemVer version.""" + assert re.match(r"\d+\.\d+\.\d+[a-z0-9]*", VERSION) + + +def test_version_info() -> None: + """Check that the version appears in the version info. + + FIXME: Not a thorough check of version_info() details. + """ + assert VERSION in version_info() + + +def test_dunder() -> None: + """Check that VERSION is the same as __version__.""" + assert feud.__version__ == VERSION From c3e13875ee9056911dad68274aa29b2b33eda6f0 Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Fri, 8 Dec 2023 03:08:20 +0000 Subject: [PATCH 02/12] feat(pkg): add `typing.Pattern` to `feud.typing` (#85) --- feud/typing/typing.py | 1 + .../test_types/test_click/test_get_click_type/test_typing.py | 1 + 2 files changed, 2 insertions(+) diff --git a/feud/typing/typing.py b/feud/typing/typing.py index 82075a1..ace0451 100644 --- a/feud/typing/typing.py +++ b/feud/typing/typing.py @@ -14,6 +14,7 @@ Literal, NamedTuple, Optional, + Pattern, Set, Text, Tuple, diff --git a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py index adfc500..07ffee9 100644 --- a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py +++ b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py @@ -20,6 +20,7 @@ [ (t.Any, None), (t.Text, click.STRING), + (t.Pattern, None), (t.Optional[int], click.INT), (t.Optional[annotate(int)], click.INT), ( From 94f693fc4e6d329baf018fe73687ca5234acdf50 Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Fri, 8 Dec 2023 03:16:08 +0000 Subject: [PATCH 03/12] fix: change `feud.config` from package to module (#86) --- feud/{config/__init__.py => config.py} | 0 make/tests.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename feud/{config/__init__.py => config.py} (100%) diff --git a/feud/config/__init__.py b/feud/config.py similarity index 100% rename from feud/config/__init__.py rename to feud/config.py diff --git a/make/tests.py b/make/tests.py index a639801..f7cd945 100644 --- a/make/tests.py +++ b/make/tests.py @@ -26,7 +26,7 @@ def doctest(c: Config) -> None: # - feud/click/context.py # - feud/decorators.py files: list[str] = [ - "feud/config/__init__.py", + "feud/config.py", "feud/core/__init__.py", "feud/core/command.py", "feud/core/group.py", From 59e2def9c8380b418b52faba3cc4ee9a320b9211 Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Fri, 8 Dec 2023 19:33:05 +0000 Subject: [PATCH 04/12] tests: add test for inheritance command override (#87) --- tests/unit/test_core/test_group.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/test_core/test_group.py b/tests/unit/test_core/test_group.py index 75a98a7..d5a33b6 100644 --- a/tests/unit/test_core/test_group.py +++ b/tests/unit/test_core/test_group.py @@ -769,6 +769,18 @@ def h(*, arg: int) -> int: } +def test_inheritance_overwrite_command() -> None: + class Parent(feud.Group): + def f(*, arg: str = "From the parent!") -> int: + return arg + + class Child(Parent): + def f(*, arg: str = "From the child!") -> str: + return arg + + assert Child.f([], standalone_mode=False) == "From the child!" + + def test_register_deregister_compile() -> None: class Parent( feud.Group, From 153ba1c8a54c586c278a46b020ed3d8dace8882a Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Fri, 8 Dec 2023 20:44:35 +0000 Subject: [PATCH 05/12] fix: use `==` instead of `is` for `typing.Annotated` comparison (#88) --- feud/_internal/_types/click.py | 20 +- make/tests.py | 6 +- pyproject.toml | 3 - .../test_get_click_type/test_pydantic.py | 206 +++++++++--------- 4 files changed, 119 insertions(+), 116 deletions(-) diff --git a/feud/_internal/_types/click.py b/feud/_internal/_types/click.py index 0f72ae8..e610d58 100644 --- a/feud/_internal/_types/click.py +++ b/feud/_internal/_types/click.py @@ -355,22 +355,22 @@ def resolve_annotated( arg_list = list(parent_args.values()) # noqa: F841 two_field_subtype = t.Annotated[*arg_list[:2]] # integer types - if two_field_subtype is pyd.PositiveInt: + if two_field_subtype == pyd.PositiveInt: return click.IntRange(min=0, min_open=True) - if two_field_subtype is pyd.NonNegativeInt: + if two_field_subtype == pyd.NonNegativeInt: return click.IntRange(min=0, min_open=False) - if two_field_subtype is pyd.NegativeInt: + if two_field_subtype == pyd.NegativeInt: return click.IntRange(max=0, max_open=True) - if two_field_subtype is pyd.NonPositiveInt: + if two_field_subtype == pyd.NonPositiveInt: return click.IntRange(max=0, max_open=False) # float types - if two_field_subtype is pyd.PositiveFloat: + if two_field_subtype == pyd.PositiveFloat: return click.FloatRange(min=0, min_open=True) - if two_field_subtype is pyd.NonNegativeFloat: + if two_field_subtype == pyd.NonNegativeFloat: return click.FloatRange(min=0, min_open=False) - if two_field_subtype is pyd.NegativeFloat: + if two_field_subtype == pyd.NegativeFloat: return click.FloatRange(max=0, max_open=True) - if two_field_subtype is pyd.NonPositiveFloat: + if two_field_subtype == pyd.NonPositiveFloat: return click.FloatRange(max=0, max_open=False) # int / float range types if is_pyd_conint(base_type, parent_args): @@ -380,9 +380,9 @@ def resolve_annotated( if is_pyd_condecimal(base_type, parent_args): return get_click_range_type(parent_args, range_type=click.FloatRange) # file / directory types - if two_field_subtype is pyd.FilePath: + if two_field_subtype == pyd.FilePath: return click.Path(exists=True, dir_okay=False) - if two_field_subtype is pyd.DirectoryPath: + if two_field_subtype == pyd.DirectoryPath: return click.Path(exists=True, file_okay=False) if base_type in PATH_TYPES: return click.Path() diff --git a/make/tests.py b/make/tests.py index f7cd945..a0b4942 100644 --- a/make/tests.py +++ b/make/tests.py @@ -37,11 +37,7 @@ def doctest(c: Config) -> None: @task def unit(c: Config, *, cov: bool = False) -> None: """Run unit tests.""" - command: str = ( - "poetry run pytest tests/ " - "--ignore tests/unit/test_internal/test_types/test_click/" - "test_get_click_type/test_pydantic.py" - ) + command: str = "poetry run pytest tests/" if cov: command = f"{command} --cov feud --cov-report xml" diff --git a/pyproject.toml b/pyproject.toml index 13ad1ab..43b995a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,9 +170,6 @@ allow-star-arg-any = true "feud/typing/*.py" = ["PLC0414", "F403", "F401"] "tests/**/*.py" = ["D100", "D100", "D101", "D102", "D103", "D104"] # temporary "tests/**/test_*.py" = ["ARG001", "S101"] -"tests/unit/test_internal/test_types/test_click/test_get_click_type/test_pydantic.py" = [ - "ERA001", -] # temporary [tool.pydoclint] style = "numpy" diff --git a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_pydantic.py b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_pydantic.py index 2be309b..6289006 100644 --- a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_pydantic.py +++ b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_pydantic.py @@ -24,8 +24,9 @@ (t.AnyUrl, None), ( t.AwareDatetime, - lambda x: isinstance(x, DateTime) - and x.name == t.datetime.__name__, + lambda x: ( + isinstance(x, DateTime) and x.name == t.datetime.__name__ + ), ), (t.Base64Bytes, None), (t.Base64Str, click.STRING), @@ -36,7 +37,10 @@ lambda x: isinstance(x, click.Path) and x.exists is True, ), (t.EmailStr, None), - (t.FilePath, lambda x: isinstance(x, click.Path) and x.exists is True), + ( + t.FilePath, + lambda x: isinstance(x, click.Path) and x.exists is True, + ), (t.FileUrl, None), (t.FiniteFloat, click.FLOAT), ( @@ -45,8 +49,9 @@ ), ( t.FutureDatetime, - lambda x: isinstance(x, DateTime) - and x.name == t.datetime.__name__, + lambda x: ( + isinstance(x, DateTime) and x.name == t.datetime.__name__ + ), ), (t.HttpUrl, None), (t.IPvAnyAddress, None), @@ -60,84 +65,89 @@ (t.MySQLDsn, None), ( t.NaiveDatetime, - lambda x: isinstance(x, DateTime) - and x.name == t.datetime.__name__, + lambda x: ( + isinstance(x, DateTime) and x.name == t.datetime.__name__ + ), ), (t.NameEmail, None), - # ( - # t.NegativeFloat, - # lambda x: isinstance(x, click.FloatRange) - # and x.min is None - # and x.min_open is False - # and x.max == 0 - # and x.max_open is True, - # ), - # ( - # t.NegativeInt, - # lambda x: isinstance(x, click.IntRange) - # and x.min is None - # and x.min_open is False - # and x.max == 0 - # and x.max_open is True, - # ), - (t.NewPath, lambda x: isinstance(x, click.Path) and x.exists is False), - # ( - # t.NonNegativeFloat, - # lambda x: isinstance(x, click.FloatRange) - # and x.min == 0 - # and x.min_open is False - # and x.max is None - # and x.max_open is False, - # ), - # ( - # t.NonNegativeInt, - # lambda x: isinstance(x, click.IntRange) - # and x.min == 0 - # and x.min_open is False - # and x.max is None - # and x.max_open is False, - # ), - # ( - # t.NonPositiveFloat, - # lambda x: isinstance(x, click.FloatRange) - # and x.min is None - # and x.min_open is False - # and x.max == 0 - # and x.max_open is False, - # ), - # ( - # t.NonPositiveInt, - # lambda x: isinstance(x, click.IntRange) - # and x.min is None - # and x.min_open is False - # and x.max == 0 - # and x.max_open is False, - # ), + ( + t.NegativeFloat, + lambda x: isinstance(x, click.FloatRange) + and x.min is None + and x.min_open is False + and x.max == 0 + and x.max_open is True, + ), + ( + t.NegativeInt, + lambda x: isinstance(x, click.IntRange) + and x.min is None + and x.min_open is False + and x.max == 0 + and x.max_open is True, + ), + ( + t.NewPath, + lambda x: isinstance(x, click.Path) and x.exists is False, + ), + ( + t.NonNegativeFloat, + lambda x: isinstance(x, click.FloatRange) + and x.min == 0 + and x.min_open is False + and x.max is None + and x.max_open is False, + ), + ( + t.NonNegativeInt, + lambda x: isinstance(x, click.IntRange) + and x.min == 0 + and x.min_open is False + and x.max is None + and x.max_open is False, + ), + ( + t.NonPositiveFloat, + lambda x: isinstance(x, click.FloatRange) + and x.min is None + and x.min_open is False + and x.max == 0 + and x.max_open is False, + ), + ( + t.NonPositiveInt, + lambda x: isinstance(x, click.IntRange) + and x.min is None + and x.min_open is False + and x.max == 0 + and x.max_open is False, + ), ( t.PastDate, lambda x: isinstance(x, DateTime) and x.name == t.date.__name__, ), ( t.PastDatetime, - lambda x: isinstance(x, DateTime) - and x.name == t.datetime.__name__, - ), - # ( - # t.PositiveFloat, - # lambda x: isinstance(x, click.FloatRange) - # and x.min == 0 - # and x.min_open is True - # and x.max is None - # and x.max_open is False, - # ), - # ( - # t.PositiveInt, - # lambda x: isinstance(x, click.IntRange) - # and x.min == 0 - # and x.min_open is True - # and x.max is None - # and x.max_open is False, - # ), + lambda x: ( + isinstance(x, DateTime) and x.name == t.datetime.__name__ + ), + ), + ( + t.PositiveFloat, + lambda x: isinstance(x, click.FloatRange) + and x.min == 0 + and x.min_open is True + and x.max is None + and x.max_open is False, + ), + ( + t.PositiveInt, + lambda x: isinstance(x, click.IntRange) + and x.min == 0 + and x.min_open is True + and x.max is None + and x.max_open is False, + ), (t.PostgresDsn, None), (t.RedisDsn, None), (t.SecretBytes, None), @@ -153,10 +163,10 @@ (t.UUID4, click.UUID), (t.UUID5, click.UUID), (t.conbytes(max_length=1), None), - # ( - # t.condate(lt=t.date.today()), - # lambda x: isinstance(x, DateTime) and x.name == t.date.__name__, - # ), + ( + t.condate(lt=t.date.today()), + lambda x: isinstance(x, DateTime) and x.name == t.date.__name__, + ), ( t.condecimal(lt=t.Decimal("3.14"), ge=t.Decimal("0.01")), lambda x: isinstance(x, click.FloatRange) @@ -165,25 +175,25 @@ and x.max == t.Decimal("3.14") and x.max_open is True, ), - # ( - # t.confloat(lt=3.14, ge=0.01), - # lambda x: isinstance(x, click.FloatRange) - # and x.min == 0.01 - # and x.min_open is False - # and x.max == 3.14 - # and x.max_open is True, - # ), - # (t.confrozenset(int, max_length=1), click.INT), - # ( - # t.conint(lt=3, ge=0), - # lambda x: isinstance(x, click.IntRange) - # and x.min == 0 - # and x.min_open is False - # and x.max == 3 - # and x.max_open is True, - # ), - # (t.conlist(int, max_length=1), click.INT), - # (t.conset(int, max_length=1), click.INT), + ( + t.confloat(lt=3.14, ge=0.01), + lambda x: isinstance(x, click.FloatRange) + and x.min == 0.01 + and x.min_open is False + and x.max == 3.14 + and x.max_open is True, + ), + (t.confrozenset(int, max_length=1), click.INT), + ( + t.conint(lt=3, ge=0), + lambda x: isinstance(x, click.IntRange) + and x.min == 0 + and x.min_open is False + and x.max == 3 + and x.max_open is True, + ), + (t.conlist(int, max_length=1), click.INT), + (t.conset(int, max_length=1), click.INT), (t.constr(max_length=1), click.STRING), (t.Annotated[int, pyd.AfterValidator(lambda x: x + 1)], click.INT), ], From cf901d0cb5ea2505cad02587789145dd49fca31f Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Fri, 8 Dec 2023 22:11:18 +0000 Subject: [PATCH 06/12] feat: add metavars for `typing.Union` and literal `|` union types (#89) --- feud/_internal/_types/click.py | 37 +++++++++++++++ .../test_get_click_type/test_typing.py | 7 ++- .../test_types/test_click/test_metavars.py | 47 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_internal/test_types/test_click/test_metavars.py diff --git a/feud/_internal/_types/click.py b/feud/_internal/_types/click.py index e610d58..d7f4b52 100644 --- a/feud/_internal/_types/click.py +++ b/feud/_internal/_types/click.py @@ -148,6 +148,35 @@ def _try_to_convert_date( return None +class Union(click.ParamType): + def __init__( + self: DateTime, + *args: t.Any, + types: list[click.ParamType], + **kwargs: t.Any, + ) -> DateTime: + self.types = types + super().__init__(*args, **kwargs) + + @staticmethod + def _get_metavar( + click_type: click.ParamType | None, + param: click.Parameter, + ) -> str: + if click_type: + return click_type.get_metavar(param) or click_type.name.upper() + return None + + def get_metavar(self: DateTime, param: click.Parameter) -> str: + metavars = [ + metavar + for click_type in self.types + if (metavar := self._get_metavar(click_type, param)) + ] + unique_metavars = list(dict.fromkeys(metavars)) + return " | ".join(unique_metavars) + + def get_click_type(hint: t.Any, *, config: Config) -> ClickType | None: base_type, base_args, _, _ = get_base_type(hint) origin_type = t.get_origin(base_type) @@ -178,6 +207,14 @@ def resolve_type(hint: t.Any, *, config: Config) -> ClickType: if len(base_args) == 2 and type(None) in arg_values: non_none = next(arg for arg in arg_values if arg is not type(None)) return get_click_type(non_none, config=config) + # t.Union with more than one non-None argument + base_types = list( + map( + ft.partial(get_click_type, config=config), + base_args.values(), + ) + ) + return Union(types=base_types) if inspect.isclass(base_type): if issubclass(base_type, enum.Enum): return click.Choice([str(e.value) for e in base_type]) diff --git a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py index 07ffee9..34dec2c 100644 --- a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py +++ b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py @@ -9,6 +9,7 @@ import pytest from feud import typing as t +from feud._internal._types.click import Union from feud.config import Config from ..utils import annotate # noqa: TID252 @@ -51,7 +52,11 @@ t.NamedTuple("Point", x=annotate(int), y=annotate(str)), (click.INT, click.STRING), ), - (t.Union[int, str], None), + ( + t.Union[int, str], + lambda x: isinstance(x, Union) + and x.types == [click.INT, click.STRING], + ), ], ) def test_typing( diff --git a/tests/unit/test_internal/test_types/test_click/test_metavars.py b/tests/unit/test_internal/test_types/test_click/test_metavars.py new file mode 100644 index 0000000..5257db3 --- /dev/null +++ b/tests/unit/test_internal/test_types/test_click/test_metavars.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023-2025 Feud Developers. +# Distributed under the terms of the MIT License (see the LICENSE file). +# SPDX-License-Identifier: MIT +# This source code is part of the Feud project (https://feud.wiki). + +import typing as t + +import pytest + +import feud + + +def test_union(capsys: pytest.CaptureFixture) -> None: + @feud.command + def f( + *, + opt1: int | float, # noqa: FA102 + opt2: t.Union[int, float], # noqa: FA100 + opt3: str | int | None, # noqa: FA102 + opt4: t.Optional[t.Union[str, int]], # noqa: FA100 + opt5: t.Union[int, t.Union[float, str]], # noqa: FA100 + opt6: int | None, # noqa: FA102 + opt7: str | t.Annotated[str, "annotated"], # noqa: FA102 + ) -> None: + pass + + with pytest.raises(SystemExit): + f(["--help"]) + + out, _ = capsys.readouterr() + + assert ( + out.strip() + == """ +Usage: pytest [OPTIONS] + +Options: + --opt1 INTEGER | FLOAT [required] + --opt2 INTEGER | FLOAT [required] + --opt3 TEXT | INTEGER [required] + --opt4 TEXT | INTEGER [required] + --opt5 INTEGER | FLOAT | TEXT [required] + --opt6 INTEGER [required] + --opt7 TEXT [required] + --help Show this message and exit. + """.strip() + ) From fb361088ccd36b4dbf2abf5b26e07387a646d6f6 Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Sat, 9 Dec 2023 19:07:34 +0000 Subject: [PATCH 07/12] feat: add `Group.__main__()` support (#90) --- feud/_internal/_command.py | 81 +-- feud/_internal/_metaclass.py | 25 +- feud/core/command.py | 60 ++- feud/core/group.py | 28 +- feud/decorators.py | 2 +- pyproject.toml | 4 +- tests/unit/test_core/test_command.py | 6 +- tests/unit/test_core/test_group.py | 465 ++++++++++++++++-- tests/unit/test_internal/test_decorators.py | 2 +- .../test_types/test_click/test_metavars.py | 14 +- 10 files changed, 547 insertions(+), 140 deletions(-) diff --git a/feud/_internal/_command.py b/feud/_internal/_command.py index 8081c25..f5ef2be 100644 --- a/feud/_internal/_command.py +++ b/feud/_internal/_command.py @@ -38,8 +38,8 @@ class ParameterSpec: class CommandState: config: Config click_kwargs: dict[str, t.Any] - context: bool is_group: bool + pass_context: bool = False # below keys are parameter name arguments: dict[str, ParameterSpec] = dataclasses.field( default_factory=dict @@ -55,45 +55,47 @@ def decorate(self: CommandState, func: t.Callable) -> click.Command: sensitive_vars: dict[str, bool] = {} params: list[click.Parameter] = [] - if self.is_group: - for param in self.overrides.values(): - params.append(param) # noqa: PERF402 - - command = func - else: - for i, param_name in enumerate(inspect.signature(func).parameters): - sensitive: bool = False - if self.context and i == 0: - continue - if param_name in self.overrides: - param: click.Parameter = self.overrides[param_name] - sensitive = param.hide_input - elif param_name in self.arguments: - spec = self.arguments[param_name] - spec.kwargs["type"] = _types.click.get_click_type( - spec.hint, config=self.config - ) - param = click.Argument(spec.args, **spec.kwargs) - elif param_name in self.options: - spec = self.options[param_name] - spec.kwargs["type"] = _types.click.get_click_type( - spec.hint, config=self.config - ) - param = click.Option(spec.args, **spec.kwargs) - meta_vars[param_name] = self.get_meta_var(param) - sensitive_vars[param_name] = sensitive + sig: inspect.signature = inspect.signature(func) + + for i, param_name in enumerate(sig.parameters): + sensitive: bool = False + if self.pass_context and i == 0: + continue + if param_name in self.overrides: + param: click.Parameter = self.overrides[param_name] + sensitive = param.hide_input + elif param_name in self.arguments: + spec = self.arguments[param_name] + spec.kwargs["type"] = _types.click.get_click_type( + spec.hint, config=self.config + ) + param = click.Argument(spec.args, **spec.kwargs) + elif param_name in self.options: + spec = self.options[param_name] + spec.kwargs["type"] = _types.click.get_click_type( + spec.hint, config=self.config + ) + param = click.Option(spec.args, **spec.kwargs) + meta_vars[param_name] = self.get_meta_var(param) + sensitive_vars[param_name] = sensitive + params.append(param) + + # add any overrides that don't appear in function signature + # e.g. version_option or anything else + for param_name, param in self.overrides.items(): + if param_name not in sig.parameters: params.append(param) - command = _decorators.validate_call( - func, - name=self.click_kwargs["name"], - meta_vars=meta_vars, - sensitive_vars=sensitive_vars, - pydantic_kwargs=self.config.pydantic_kwargs, - ) + command = _decorators.validate_call( + func, + name=self.click_kwargs["name"], + meta_vars=meta_vars, + sensitive_vars=sensitive_vars, + pydantic_kwargs=self.config.pydantic_kwargs, + ) - if self.context: - command = click.pass_context(command) + if self.pass_context: + command = click.pass_context(command) constructor = click.group if self.is_group else click.command command = constructor(**self.click_kwargs)(command) @@ -159,7 +161,7 @@ def get_alias(alias: str, *, hint: type, negate_flags: bool) -> str: def sanitize_click_kwargs( - click_kwargs: dict[str, t.Any], *, name: str + click_kwargs: dict[str, t.Any], *, name: str, help_: str | None = None ) -> None: """Sanitize click command/group arguments. @@ -170,3 +172,6 @@ def sanitize_click_kwargs( # sanitize the provided name # (only necessary for auto-naming a Group by class name) click_kwargs["name"] = click_kwargs.get("name", _inflect.sanitize(name)) + # set help if provided + if help_: + click_kwargs["help"] = help_ diff --git a/feud/_internal/_metaclass.py b/feud/_internal/_metaclass.py index 0952218..2e0bccb 100644 --- a/feud/_internal/_metaclass.py +++ b/feud/_internal/_metaclass.py @@ -20,7 +20,7 @@ class GroupBase(abc.ABCMeta): def __new__( - cls: type[GroupBase], + __cls: type[GroupBase], # noqa: N804 cls_name: str, bases: tuple[type, ...], namespace: dict[str, t.Any], @@ -57,7 +57,8 @@ def __new__( subgroups: list[type] = [] # type[Group], but circular import commands: list[str] = [] - # extend/inherit from parent group if subclassed + # extend/inherit information from parent group if subclassed + help_: str | None = None for base in bases: if config := getattr(base, "__feud_config__", None): # NOTE: may want **dict(config) depending on behaviour @@ -79,6 +80,7 @@ def __new__( for cmd in base.__feud_commands__ if cmd not in commands ] + help_ = base.__feud_click_kwargs__.get("help") # deconstruct base config, override config kwargs and click kwargs config_kwargs: dict[str, t.Any] = {} @@ -97,7 +99,9 @@ def __new__( d[k] = v # sanitize click kwargs - _command.sanitize_click_kwargs(click_kwargs, name=cls_name) + _command.sanitize_click_kwargs( + click_kwargs, name=cls_name, help_=help_ + ) # members to consider as commands funcs = { @@ -124,4 +128,17 @@ def __new__( func, config=namespace["__feud_config__"] ) - return super().__new__(cls, cls_name, bases, namespace) + group = super().__new__(__cls, cls_name, bases, namespace) + + if bases: + # use class-level docstring as help if provided + if doc := group.__doc__: + click_kwargs["help"] = doc + # use __main__ function-level docstring as help if provided + if doc := group.__main__.__doc__: + click_kwargs["help"] = doc + # use class-level click kwargs help if provided + if doc := kwargs.get("help"): + click_kwargs["help"] = doc + + return group diff --git a/feud/core/command.py b/feud/core/command.py index 7ec4225..987ff9c 100644 --- a/feud/core/command.py +++ b/feud/core/command.py @@ -114,39 +114,24 @@ def decorate(__func: typing.Callable, /) -> typing.Callable: return decorate(func) if func else decorate -def get_command( - func: typing.Callable, - /, - *, - config: Config, - click_kwargs: dict[str, typing.Any], -) -> click.Command: - if isinstance(func, staticmethod): - func = func.__func__ +def build_command_state( + state: _command.CommandState, *, func: callable, config: Config +) -> None: + doc: docstring_parser.Docstring + if state.is_group: + doc = docstring_parser.parse(state.click_kwargs.get("help", "")) + else: + doc = docstring_parser.parse_from_object(func) - doc: docstring_parser.Docstring = docstring_parser.parse_from_object(func) sig: inspect.Signature = inspect.signature(func) - pass_context: bool = _command.pass_context(sig) - - state = _command.CommandState( - config=config, - click_kwargs=click_kwargs, - context=pass_context, - is_group=False, - aliases=getattr(func, "__feud_aliases__", {}), - overrides={ - override.name: override - for override in getattr(func, "__click_params__", []) - }, - ) for param, spec in sig.parameters.items(): meta = _command.ParameterSpec() meta.hint: type = spec.annotation - if pass_context and param == _command.CONTEXT_PARAM: + if _command.pass_context(sig) and param == _command.CONTEXT_PARAM: # skip handling for click.Context argument - continue + state.pass_context = True if spec.kind in (spec.POSITIONAL_ONLY, spec.POSITIONAL_OR_KEYWORD): # function positional arguments correspond to CLI arguments @@ -231,6 +216,31 @@ def get_command( elif meta.type == _command.ParameterType.OPTION: state.options[param] = meta + +def get_command( + func: typing.Callable, + /, + *, + config: Config, + click_kwargs: dict[str, typing.Any], +) -> click.Command: + if isinstance(func, staticmethod): + func = func.__func__ + + state = _command.CommandState( + config=config, + click_kwargs=click_kwargs, + is_group=False, + aliases=getattr(func, "__feud_aliases__", {}), + overrides={ + override.name: override + for override in getattr(func, "__click_params__", []) + }, + ) + + # construct command state from signature + build_command_state(state, func=func, config=config) + # generate click.Command and attach original function reference command = state.decorate(func) command.__func__ = func diff --git a/feud/core/group.py b/feud/core/group.py index 7052824..57fe5b0 100644 --- a/feud/core/group.py +++ b/feud/core/group.py @@ -16,14 +16,11 @@ import pydantic as pyd -try: - import rich_click as click -except ImportError: - import click - import feud.exceptions +from feud import click from feud._internal import _command, _metaclass from feud.config import Config +from feud.core.command import build_command_state __all__ = ["Group", "compile"] @@ -421,6 +418,9 @@ def deregister( # deregister all subgroups cls.__feud_subgroups__ = [] + def __main__() -> None: # noqa: D105 + pass + @pyd.validate_call(config=pyd.ConfigDict(arbitrary_types_allowed=True)) def compile(group: type[Group], /) -> click.Group: # noqa: A001 @@ -448,21 +448,23 @@ def compile(group: type[Group], /) -> click.Group: # noqa: A001 def get_group(__cls: type[Group], /) -> click.Group: + func: callable = __cls.__main__ + state = _command.CommandState( config=__cls.__feud_config__, click_kwargs=__cls.__feud_click_kwargs__, - context=False, is_group=True, - aliases=getattr(__cls, "__feud_aliases__", {}), + aliases=getattr(func, "__feud_aliases__", {}), overrides={ override.name: override - for override in getattr(__cls, "__click_params__", []) + for override in getattr(func, "__click_params__", []) }, ) - def wrapper() -> None: - pass - - wrapper.__doc__ = __cls.__doc__ + # construct command state from signature + build_command_state(state, func=func, config=__cls.__feud_config__) - return state.decorate(wrapper) + # generate click.Group and attach original function reference + command = state.decorate(func) + command.__func__ = func + return command diff --git a/feud/decorators.py b/feud/decorators.py index 342d8a5..29a0603 100644 --- a/feud/decorators.py +++ b/feud/decorators.py @@ -99,7 +99,7 @@ def decorator( received = { p.name for p in sig.parameters.values() if p.kind == p.KEYWORD_ONLY } - if specified > received: + if len(specified - received) > 0: msg = ( f"Arguments provided to 'alias' decorator must " f"also be keyword parameters for function {f.__name__!r}. " diff --git a/pyproject.toml b/pyproject.toml index 43b995a..66ca4b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,11 +169,11 @@ allow-star-arg-any = true "__init__.py" = ["PLC0414", "F403", "F401", "F405"] "feud/typing/*.py" = ["PLC0414", "F403", "F401"] "tests/**/*.py" = ["D100", "D100", "D101", "D102", "D103", "D104"] # temporary -"tests/**/test_*.py" = ["ARG001", "S101"] +"tests/**/test_*.py" = ["ARG001", "S101", "D", "FA100", "FA102"] [tool.pydoclint] style = "numpy" -exclude = ".git|.tox|feud/_internal" # temporary +exclude = ".git|.tox|feud/_internal|tests" # temporary check-return-types = false arg-type-hints-in-docstring = false quiet = true diff --git a/tests/unit/test_core/test_command.py b/tests/unit/test_core/test_command.py index d3fb3b9..47abbfd 100644 --- a/tests/unit/test_core/test_command.py +++ b/tests/unit/test_core/test_command.py @@ -72,7 +72,7 @@ def f(arg1: int, *, arg2: bool) -> None: arg2: Changes something. - """ # noqa: D301, D400 + """ with pytest.raises(SystemExit): f(["--help"]) @@ -196,7 +196,7 @@ def f(ctx: click.Context, *, arg1: bool) -> bool: def test_run_undecorated() -> None: - def f(arg1: int, *, arg2: bool) -> tuple[int, bool]: # noqa: FA102 + def f(arg1: int, *, arg2: bool) -> tuple[int, bool]: return arg1, arg2 assert feud.run(f, ["1", "--no-arg2"], standalone_mode=False) == (1, False) @@ -204,7 +204,7 @@ def f(arg1: int, *, arg2: bool) -> tuple[int, bool]: # noqa: FA102 def test_run_decorated() -> None: @feud.command - def f(arg1: int, *, arg2: bool) -> tuple[int, bool]: # noqa: FA102 + def f(arg1: int, *, arg2: bool) -> tuple[int, bool]: return arg1, arg2 assert feud.run(f, ["1", "--no-arg2"], standalone_mode=False) == (1, False) diff --git a/tests/unit/test_core/test_group.py b/tests/unit/test_core/test_group.py index d5a33b6..16c3b63 100644 --- a/tests/unit/test_core/test_group.py +++ b/tests/unit/test_core/test_group.py @@ -3,11 +3,10 @@ # SPDX-License-Identifier: MIT # This source code is part of the Feud project (https://feud.wiki). -from __future__ import annotations - import typing as t from collections import OrderedDict from operator import itemgetter +from pathlib import Path import pytest @@ -16,7 +15,12 @@ def assert_help( - __obj: feud.Group | click.Group | click.Command | t.Callable, + __obj: t.Union[ + feud.Group, + click.Group, + click.Command, + t.Callable, + ], /, *, capsys: pytest.CaptureFixture, @@ -157,18 +161,6 @@ def g(*, arg1: int) -> None: ) -def test_click_version(capsys: pytest.CaptureFixture) -> None: - @click.version_option(version="0.1.0") - class Test(feud.Group): - pass - - with pytest.raises(SystemExit): - Test(["--version"]) - - out, _ = capsys.readouterr() - assert out.strip() == "pytest, version 0.1.0" - - def test_config_kwarg_propagation() -> None: class Test(feud.Group, show_help_defaults=False): def f(*, arg1: int = 1) -> None: @@ -235,17 +227,17 @@ def f(*, arg1: int = 1) -> None: def test_subgroups_parent_single_child() -> None: class Parent(feud.Group): - """This is the parent group.""" # noqa: D404 + """This is the parent group.""" def f(*, arg: int) -> None: - """This is a command in the parent group.""" # noqa: D401, D404 + """This is a command in the parent group.""" return arg class Child(feud.Group): - """This is a subgroup.""" # noqa: D404 + """This is a subgroup.""" def g(*, arg: int) -> None: - """This is a command in the subgroup.""" # noqa: D401, D404 + """This is a command in the subgroup.""" return arg Parent.register(Child) @@ -276,24 +268,24 @@ def test_subgroups_parent_multi_children() -> None: """ class Parent(feud.Group): - """This is the parent group.""" # noqa: D404 + """This is the parent group.""" def f(*, arg: int) -> int: - """This is a command in the parent group.""" # noqa: D401, D404 + """This is a command in the parent group.""" return arg class Child1(feud.Group): - """This is the first subgroup.""" # noqa: D404 + """This is the first subgroup.""" def g(*, arg: int) -> int: - """This is a command in the first subgroup.""" # noqa: D401, D404 + """This is a command in the first subgroup.""" return arg class Child2(feud.Group): - """This is the second subgroup.""" # noqa: D404 + """This is the second subgroup.""" def h(*, arg: int) -> int: - """This is a command in the second subgroup.""" # noqa: D401, D404 + """This is a command in the second subgroup.""" return arg Parent.register([Child1, Child2]) @@ -339,38 +331,38 @@ def test_subgroups_nested() -> None: # noqa: PLR0915 """ class Parent(feud.Group): - """This is the parent group.""" # noqa: D404 + """This is the parent group.""" def f(*, arg: int) -> int: - """This is a command in the parent group.""" # noqa: D401, D404 + """This is a command in the parent group.""" return arg class Child1(feud.Group): - """This is the first subgroup.""" # noqa: D404 + """This is the first subgroup.""" def g(*, arg: int) -> int: - """This is a command in the first subgroup.""" # noqa: D401, D404 + """This is a command in the first subgroup.""" return arg class Child2(feud.Group): - """This is the second subgroup.""" # noqa: D404 + """This is the second subgroup.""" def h(*, arg: int) -> int: - """This is a command in the second subgroup.""" # noqa: D401, D404 + """This is a command in the second subgroup.""" return arg class Child3(feud.Group): - """This is the third subgroup.""" # noqa: D404 + """This is the third subgroup.""" def i(*, arg: int) -> int: - """This is a command in the third subgroup.""" # noqa: D401, D404 + """This is a command in the third subgroup.""" return arg class Child4(feud.Group): - """This is the fourth subgroup.""" # noqa: D404 + """This is the fourth subgroup.""" def j(*, arg: int) -> int: - """This is a command in the fourth subgroup.""" # noqa: D401, D404 + """This is a command in the fourth subgroup.""" return arg Parent.register(Child1) @@ -492,31 +484,31 @@ def j(*, arg: int) -> int: def test_deregister_nested() -> None: # noqa: PLR0915 class Parent(feud.Group): - """This is the parent group.""" # noqa: D404 + """This is the parent group.""" def f(*, arg: int) -> int: - """This is a command in the parent group.""" # noqa: D401, D404 + """This is a command in the parent group.""" return arg class Child1(feud.Group): - """This is the first subgroup.""" # noqa: D404 + """This is the first subgroup.""" def g(*, arg: int) -> int: - """This is a command in the first subgroup.""" # noqa: D401, D404 + """This is a command in the first subgroup.""" return arg class Child2(feud.Group): - """This is the second subgroup.""" # noqa: D404 + """This is the second subgroup.""" def h(*, arg: int) -> int: - """This is a command in the second subgroup.""" # noqa: D401, D404 + """This is a command in the second subgroup.""" return arg class Child3(feud.Group): - """This is the third subgroup.""" # noqa: D404 + """This is the third subgroup.""" def i(*, arg: int) -> int: - """This is a command in the third subgroup.""" # noqa: D401, D404 + """This is a command in the third subgroup.""" return arg Parent.register([Child1, Child2]) @@ -788,15 +780,15 @@ class Parent( show_help_defaults=False, epilog="Visit https://www.com for more information.", ): - """This is the parent group.""" # noqa: D404 + """This is the parent group.""" def f(*, arg: int) -> int: - """This is a command in the parent group.""" # noqa: D401, D404 + """This is a command in the parent group.""" return arg class Subgroup(feud.Group): def g(*, arg: int) -> int: - """This is a command in a subgroup.""" # noqa: D401, D404 + """This is a command in a subgroup.""" return arg Parent.register(Subgroup) @@ -1002,3 +994,384 @@ class F(feud.Group): (D, D.descendants()), ] ) + + +def test_help_no_docstring(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + pass + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + +Options: + --help Show this message and exit. + """, + ) + + +def test_help_simple_docstring(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + """This is a group.""" + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This is a group. + +Options: + --help Show this message and exit. + """, + ) + + +def test_help_param_docstring(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + """This is a group.\f + + Parameters + ---------- + param: + Help for a parameter. + """ + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This is a group. + +Options: + --help Show this message and exit. + """, + ) + + +def test_help_override(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group, help="Overridden."): + """This is a group.""" + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + Overridden. + +Options: + --help Show this message and exit. + """, + ) + + +def test_main_help_no_docstrings(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + def __main__() -> None: + pass + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + +Options: + --help Show this message and exit. + """, + ) + + +def test_main_help_class_docstring(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + """This is a class-level docstring.""" + + def __main__() -> None: + pass + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This is a class-level docstring. + +Options: + --help Show this message and exit. + """, + ) + + +def test_main_help_function_docstring(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + def __main__() -> None: + """This is a function-level docstring.""" + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This is a function-level docstring. + +Options: + --help Show this message and exit. + """, + ) + + +def test_main_help_both_docstrings(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + """This is a class-level docstring.""" + + def __main__() -> None: + """This is a function-level docstring.""" + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This is a function-level docstring. + +Options: + --help Show this message and exit. + """, + ) + + +def test_main_help_both_docstrings_with_override( + capsys: pytest.CaptureFixture +) -> None: + class Test(feud.Group, help="Overridden."): + """This is a class-level docstring.""" + + def __main__() -> None: + """This is a function-level docstring.""" + + assert_help( + Test, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + Overridden. + +Options: + --help Show this message and exit. + """, + ) + + +def test_main(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group, invoke_without_command=True): + """This group does something relative to a root directory.\f + + Parameters + ---------- + root: + Root directory + """ + + @staticmethod + @click.version_option("0.1.0") + @feud.alias(root="-r") + def __main__(ctx: click.Context, *, root: Path = Path(".")) -> None: + ctx.obj = {"root": root} + return root + + @staticmethod + def command(ctx: click.Context, path: Path) -> Path: + """Returns a full path.\f + + Parameters + ---------- + path: + Relative path. + """ + return ctx.obj["root"] / path + + group = feud.compile(Test) + + assert_help( + group, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This group does something relative to a root directory. + +Options: + -r, --root PATH Root directory [default: .] + --version Show the version and exit. + --help Show this message and exit. + +Commands: + command Returns a full path. + """, + ) + + # check version + with pytest.raises(SystemExit): + group(["--version"]) + out, _ = capsys.readouterr() + assert out.strip() == "pytest, version 0.1.0" + + # test invoke without command + assert group(["-r", "/usr"], standalone_mode=False) == Path("/usr") + + # test command context + assert group( + ["-r", "/usr", "command", "bin/sh"], + standalone_mode=False, + ) == Path("/usr/bin/sh") + + +def test_main_inheritance_no_docstring(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group, invoke_without_command=True): + """This group does something relative to a root directory.\f + + Parameters + ---------- + root: + Root directory + """ + + @staticmethod + @click.version_option("0.1.0") + @feud.alias(root="-r") + def __main__(ctx: click.Context, *, root: Path = Path(".")) -> None: + ctx.obj = {"root": root} + return root + + @staticmethod + def command(ctx: click.Context, path: Path) -> Path: + """Returns a full path.\f + + Parameters + ---------- + path: + Relative path. + """ + return ctx.obj["root"] / path + + class Child(Test): + pass + + group = feud.compile(Child) + + assert_help( + group, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This group does something relative to a root directory. + +Options: + -r, --root PATH Root directory [default: .] + --version Show the version and exit. + --help Show this message and exit. + +Commands: + command Returns a full path. + """, + ) + + # check version + with pytest.raises(SystemExit): + group(["--version"]) + out, _ = capsys.readouterr() + assert out.strip() == "pytest, version 0.1.0" + + # test invoke without command + assert group(["-r", "/usr"], standalone_mode=False) == Path("/usr") + + # test command context + assert group( + ["-r", "/usr", "command", "bin/sh"], + standalone_mode=False, + ) == Path("/usr/bin/sh") + + +def test_main_inheritance_docstring(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group, invoke_without_command=True): + """This group does something relative to a root directory.\f + + Parameters + ---------- + root: + Root directory + """ + + @staticmethod + @click.version_option("0.1.0") + @feud.alias(root="-r") + def __main__(ctx: click.Context, *, root: Path = Path(".")) -> None: + ctx.obj = {"root": root} + return root + + @staticmethod + def command(ctx: click.Context, path: Path) -> Path: + """Returns a full path.\f + + Parameters + ---------- + path: + Relative path. + """ + return ctx.obj["root"] / path + + class Child(Test): + """This is a new docstring.\f + + Parameters + ---------- + root: + Root directory + """ + + group = feud.compile(Child) + + assert_help( + group, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] COMMAND [ARGS]... + + This is a new docstring. + +Options: + -r, --root PATH Root directory [default: .] + --version Show the version and exit. + --help Show this message and exit. + +Commands: + command Returns a full path. + """, + ) + + # check version + with pytest.raises(SystemExit): + group(["--version"]) + out, _ = capsys.readouterr() + assert out.strip() == "pytest, version 0.1.0" + + # test invoke without command + assert group(["-r", "/usr"], standalone_mode=False) == Path("/usr") + + # test command context + assert group( + ["-r", "/usr", "command", "bin/sh"], + standalone_mode=False, + ) == Path("/usr/bin/sh") diff --git a/tests/unit/test_internal/test_decorators.py b/tests/unit/test_internal/test_decorators.py index 0c3654f..7f701f1 100644 --- a/tests/unit/test_internal/test_decorators.py +++ b/tests/unit/test_internal/test_decorators.py @@ -87,7 +87,7 @@ def test_validate_call_list() -> None: sensitive_vars = {"0": False} pydantic_kwargs = {} - def f(arg1: list[t.conint(multiple_of=2)]) -> None: # noqa: FA102 + def f(arg1: list[t.conint(multiple_of=2)]) -> None: pass with pytest.raises(click.UsageError) as e: diff --git a/tests/unit/test_internal/test_types/test_click/test_metavars.py b/tests/unit/test_internal/test_types/test_click/test_metavars.py index 5257db3..837c26e 100644 --- a/tests/unit/test_internal/test_types/test_click/test_metavars.py +++ b/tests/unit/test_internal/test_types/test_click/test_metavars.py @@ -14,13 +14,13 @@ def test_union(capsys: pytest.CaptureFixture) -> None: @feud.command def f( *, - opt1: int | float, # noqa: FA102 - opt2: t.Union[int, float], # noqa: FA100 - opt3: str | int | None, # noqa: FA102 - opt4: t.Optional[t.Union[str, int]], # noqa: FA100 - opt5: t.Union[int, t.Union[float, str]], # noqa: FA100 - opt6: int | None, # noqa: FA102 - opt7: str | t.Annotated[str, "annotated"], # noqa: FA102 + opt1: int | float, + opt2: t.Union[int, float], + opt3: str | int | None, + opt4: t.Optional[t.Union[str, int]], + opt5: t.Union[int, t.Union[float, str]], + opt6: int | None, + opt7: str | t.Annotated[str, "annotated"], ) -> None: pass From a7a8557b2f6d7314329096d2106cb1cb1e562cba Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Mon, 11 Dec 2023 01:36:22 +0000 Subject: [PATCH 08/12] feat: add `feud.env` decorator for env. variable options (#91) --- docs/source/sections/decorators/env.rst | 67 +++++++++++ docs/source/sections/decorators/index.rst | 1 + feud/_internal/_command.py | 10 +- feud/config.py | 8 ++ feud/core/command.py | 11 ++ feud/core/group.py | 3 + feud/decorators.py | 76 ++++++++++++- tests/unit/test_config.py | 3 + tests/unit/test_decorators.py | 131 +++++++++++++++++++++- 9 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 docs/source/sections/decorators/env.rst diff --git a/docs/source/sections/decorators/env.rst b/docs/source/sections/decorators/env.rst new file mode 100644 index 0000000..b187caa --- /dev/null +++ b/docs/source/sections/decorators/env.rst @@ -0,0 +1,67 @@ +Using environment variables +=========================== + +In CLIs, environment variables are often used as an alternative method of +providing input for options. This is particularly useful for sensitive +information such as API keys, tokens and passwords. + +For example, an option named ``--token`` may be provided by an environment +variable ``SECRET_TOKEN``. + +Instead of manually specifying an environment variable with :py:func:`click.option`, e.g. + +.. code:: python + + # my_command.py + + import feud + from feud import click, typing as t + + @click.option( + "--token", help="A secret token.", type=str, + envvar="SECRET_TOKEN", show_envvar=True, + ) + def my_command(*, token: t.constr(max_length=16)): + """A command requiring a token no longer than 16 characters.""" + + if __name__ == "__main__": + feud.run(my_command) + +You can use the :py:func:`.env` decorator to do this, which also means you +do not have to manually provide ``help`` or ``type`` to :py:func:`click.option`, +and can instead rely on type hints and docstrings. + +.. code:: python + + # my_command.py + + import feud + from feud import typing as t + + @feud.env(token="SECRET_TOKEN") + def my_command(*, token: t.constr(max_length=16)): + """A command requiring a token no longer than 16 characters. + + Parameters + ---------- + token: + A secret token. + """ + + if __name__ == "__main__": + feud.run(my_command) + +This can be called with ``SECRET_TOKEN=hello-world python command.py``, for example. + +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + +---- + +API reference +------------- + +.. autofunction:: feud.decorators.env diff --git a/docs/source/sections/decorators/index.rst b/docs/source/sections/decorators/index.rst index 1a28433..4ccd6c9 100644 --- a/docs/source/sections/decorators/index.rst +++ b/docs/source/sections/decorators/index.rst @@ -9,4 +9,5 @@ This module consists of decorators that modify :doc:`../core/command` and their :titlesonly: alias.rst + env.rst .. rename.rst diff --git a/feud/_internal/_command.py b/feud/_internal/_command.py index f5ef2be..3cf9276 100644 --- a/feud/_internal/_command.py +++ b/feud/_internal/_command.py @@ -45,7 +45,10 @@ class CommandState: default_factory=dict ) options: dict[str, ParameterSpec] = dataclasses.field(default_factory=dict) - aliases: dict[str, str] = dataclasses.field(default_factory=dict) + aliases: dict[str, str | list[str]] = dataclasses.field( + default_factory=dict + ) + envs: dict[str, str] = dataclasses.field(default_factory=dict) overrides: dict[str, click.Parameter] = dataclasses.field( default_factory=dict ) @@ -63,7 +66,7 @@ def decorate(self: CommandState, func: t.Callable) -> click.Command: continue if param_name in self.overrides: param: click.Parameter = self.overrides[param_name] - sensitive = param.hide_input + sensitive = param.hide_input or param.envvar elif param_name in self.arguments: spec = self.arguments[param_name] spec.kwargs["type"] = _types.click.get_click_type( @@ -76,6 +79,9 @@ def decorate(self: CommandState, func: t.Callable) -> click.Command: spec.hint, config=self.config ) param = click.Option(spec.args, **spec.kwargs) + hide_input = spec.kwargs.get("hide_input") + envvar = spec.kwargs.get("envvar") + sensitive = hide_input or envvar meta_vars[param_name] = self.get_meta_var(param) sensitive_vars[param_name] = sensitive params.append(param) diff --git a/feud/config.py b/feud/config.py index 2e7148e..087935a 100644 --- a/feud/config.py +++ b/feud/config.py @@ -37,6 +37,9 @@ class Config(pyd.BaseModel): #: Whether to display datetime parameter formats in command help. show_help_datetime_formats: bool = False + #: Whether to display environment variable names in command help. + show_help_envvars: bool = True + #: Validation settings for #: :py:func:`pydantic.validate_call_decorator.validate_call`. pydantic_kwargs: dict[str, Any] = {} @@ -68,6 +71,7 @@ def config( negate_flags: bool | None = None, show_help_defaults: bool | None = None, show_help_datetime_formats: bool | None = None, + show_help_envvars: bool | None = None, pydantic_kwargs: dict[str, Any] | None = None, ) -> Config: """Create a reusable configuration for :py:func:`.command` or @@ -86,6 +90,9 @@ def config( show_help_datetime_formats: Whether to display datetime parameter formats in command help. + show_help_envvars: + Whether to display environment variable names in command help. + pydantic_kwargs: Validation settings for :py:func:`pydantic.validate_call_decorator.validate_call`. @@ -123,5 +130,6 @@ def config( negate_flags=negate_flags, show_help_defaults=show_help_defaults, show_help_datetime_formats=show_help_datetime_formats, + show_help_envvars=show_help_envvars, pydantic_kwargs=pydantic_kwargs, ) diff --git a/feud/core/command.py b/feud/core/command.py index 987ff9c..ab8f1b0 100644 --- a/feud/core/command.py +++ b/feud/core/command.py @@ -38,6 +38,7 @@ def command( negate_flags: bool | None = None, show_help_defaults: bool | None = None, show_help_datetime_formats: bool | None = None, + show_help_envvars: bool | None = None, pydantic_kwargs: dict[str, typing.Any] | None = None, config: Config | None = None, **click_kwargs: typing.Any, @@ -60,6 +61,9 @@ def command( show_help_datetime_formats: Whether to display datetime parameter formats in command help. + show_help_envvars: + Whether to display environment variable names in command help. + pydantic_kwargs: Validation settings for :py:func:`pydantic.validate_call_decorator.validate_call`. @@ -106,6 +110,7 @@ def decorate(__func: typing.Callable, /) -> typing.Callable: negate_flags=negate_flags, show_help_defaults=show_help_defaults, show_help_datetime_formats=show_help_datetime_formats, + show_help_envvars=show_help_envvars, pydantic_kwargs=pydantic_kwargs, ) # decorate function @@ -191,6 +196,11 @@ def build_command_state( ) ) + # add env var - if specified by feud.env decorator + if env := state.envs.get(param): + meta.kwargs["envvar"] = env + meta.kwargs["show_envvar"] = config.show_help_envvars + # add help - fetch parameter description from docstring if doc_param := next( (p for p in doc.params if p.arg_name == param), None @@ -232,6 +242,7 @@ def get_command( click_kwargs=click_kwargs, is_group=False, aliases=getattr(func, "__feud_aliases__", {}), + envs=getattr(func, "__feud_envs__", {}), overrides={ override.name: override for override in getattr(func, "__click_params__", []) diff --git a/feud/core/group.py b/feud/core/group.py index 57fe5b0..36dfae9 100644 --- a/feud/core/group.py +++ b/feud/core/group.py @@ -449,12 +449,15 @@ def compile(group: type[Group], /) -> click.Group: # noqa: A001 def get_group(__cls: type[Group], /) -> click.Group: func: callable = __cls.__main__ + if isinstance(func, staticmethod): + func = func.__func__ state = _command.CommandState( config=__cls.__feud_config__, click_kwargs=__cls.__feud_click_kwargs__, is_group=True, aliases=getattr(func, "__feud_aliases__", {}), + envs=getattr(func, "__feud_envs__", {}), overrides={ override.name: override for override in getattr(func, "__click_params__", []) diff --git a/feud/decorators.py b/feud/decorators.py index 29a0603..6522196 100644 --- a/feud/decorators.py +++ b/feud/decorators.py @@ -16,7 +16,7 @@ from feud.exceptions import CompilationError -__all__ = ["alias"] +__all__ = ["alias", "env"] @pyd.validate_call @@ -36,8 +36,8 @@ def alias(**aliases: str | list[str]) -> t.Callable: Mapping of option names to aliases. - Option names must be keyword-only parameters defined in - the signature of the decorated function. + Option names must be keyword-only parameters in the decorated + function signature. Returns ------- @@ -137,5 +137,75 @@ def decorator( return partial(decorator, aliases=aliases) +def env(**envs: str) -> t.Callable: + """Specify environment variable inputs for command options. + + Decorates a function by attaching command option environment variable + metadata, to be used at compile time by py:class:`click.Option` objects. + + Environment variables may only be defined for command-line options, not + arguments. This translates to keyword-only parameters, i.e. those + positioned after the ``*`` operator in a function signature. + + Parameters + ---------- + **envs: + + Mapping of option names to environment variables. + + Option names must be keyword-only parameters in the decorated + function signature. + + Returns + ------- + Function decorated with command option environment variable metadata. + + Examples + -------- + Using an environment variable for a single option. + + >>> import os + >>> import feud + >>> @feud.env(token="TOKEN") + ... def func(*, token: str) -> str: + ... return token + >>> os.environ["TOKEN"] = "Hello world!" + >>> feud.run(func, [], standalone_mode=False) + "Hello World!" + + Using environment variables for multiple options. + + >>> import os + >>> import feud + >>> @feud.env(token="TOKEN", key="API_KEY") + >>> def func(*, token: str, key: str) -> tuple[str, str]: + ... return token, key + >>> os.environ["TOKEN"] = "Hello world!" + >>> os.environ["API_KEY"] = "This is a secret key." + >>> feud.run(func, [], standalone_mode=False) + ("Hello world!", "This is a secret key.") + """ + + def decorator(f: t.Callable, *, envs: dict[str, str]) -> t.Callable: + # check provided envs and parameters match + sig = inspect.signature(f) + specified = set(envs.keys()) + received = { + p.name for p in sig.parameters.values() if p.kind == p.KEYWORD_ONLY + } + if len(specified - received) > 0: + msg = ( + f"Arguments provided to 'env' decorator must " + f"also be keyword parameters for function {f.__name__!r}. " + f"Received extra arguments: {specified - received!r}." + ) + raise CompilationError(msg) + + f.__feud_envs__ = envs + return f + + return partial(decorator, envs=envs) + + # def rename(command: str | None = None, /, **params: str) -> t.Callable: # rename("cmd") renames the command without requiring @feud.command(name="cmd") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c4113a6..ce3f5e2 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -14,6 +14,7 @@ def test_create_no_base_no_kwargs() -> None: assert config.negate_flags is True assert config.show_help_defaults is True assert config.show_help_datetime_formats is False + assert config.show_help_envvars is True assert config.pydantic_kwargs == {} @@ -23,11 +24,13 @@ def test_create_no_base_none_kwargs() -> None: negate_flags=None, show_help_defaults=None, show_help_datetime_formats=None, + show_help_envvars=None, pydantic_kwargs=None, ) assert config.negate_flags is True assert config.show_help_defaults is True assert config.show_help_datetime_formats is False + assert config.show_help_envvars is True assert config.pydantic_kwargs == {} diff --git a/tests/unit/test_decorators.py b/tests/unit/test_decorators.py index 2176609..f41ce9a 100644 --- a/tests/unit/test_decorators.py +++ b/tests/unit/test_decorators.py @@ -3,9 +3,56 @@ # SPDX-License-Identifier: MIT # This source code is part of the Feud project (https://feud.wiki). +import os +import re +from typing import Callable +from unittest import mock + import pytest import feud +from feud import click +from feud import typing as t + + +def assert_help( + __obj: t.Union[ + feud.Group, + click.Group, + click.Command, + Callable, + ], + /, + *, + capsys: pytest.CaptureFixture, + expected: str, +) -> None: + with pytest.raises(SystemExit): + feud.run(__obj, ["--help"]) + out, _ = capsys.readouterr() + assert out.strip() == expected.strip() + + +@pytest.fixture(scope="module") +def env_command() -> Callable: + @feud.env(opt1="OPT1", opt2="OPT2", opt3="OPT3") + def f( + *, opt1: t.PositiveInt, opt2: bool, opt3: t.NegativeFloat + ) -> tuple[t.PositiveInt, bool, t.NegativeFloat]: + """Returns a full path.\f + + Parameters + ---------- + opt1: + First option. + opt2: + Second option. + opt3: + Third option. + """ + return opt1, opt2, opt3 + + return f def test_valid_format() -> None: @@ -50,7 +97,7 @@ def f(*, arg1: int) -> None: pass -def test_invalid_format() -> None: +def test_invalid_format_alias() -> None: with pytest.raises(feud.CompilationError): @feud.alias(arg1="-a1") @@ -64,3 +111,85 @@ def test_alias_argument() -> None: @feud.alias(arg1="-a") def f(arg1: int) -> None: pass + + +def test_undefined_env() -> None: + with pytest.raises(feud.CompilationError): + + @feud.env(opt1="-a", opt2="-b") + def f(*, opt1: int) -> None: + pass + + +def test_env_help_with_show( + capsys: pytest.CaptureFixture, env_command: Callable +) -> None: + assert_help( + feud.command(show_help_envvars=True)(env_command), + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] + + Returns a full path. + +Options: + --opt1 INTEGER RANGE First option. [env var: OPT1; x>0; required] + --opt2 / --no-opt2 Second option. [env var: OPT2; required] + --opt3 FLOAT RANGE Third option. [env var: OPT3; x<0; required] + --help Show this message and exit. + """, + ) + + +def test_env_help_without_show( + capsys: pytest.CaptureFixture, env_command: Callable +) -> None: + assert_help( + feud.command(show_help_envvars=False)(env_command), + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] + + Returns a full path. + +Options: + --opt1 INTEGER RANGE First option. [x>0; required] + --opt2 / --no-opt2 Second option. [required] + --opt3 FLOAT RANGE Third option. [x<0; required] + --help Show this message and exit. + """, + ) + + +def test_env_call_no_env(env_command: Callable) -> None: + with pytest.raises(click.MissingParameter): + feud.run(env_command, [], standalone_mode=False) + + +@mock.patch.dict(os.environ, {"OPT1": "long"}, clear=True) +def test_env_call_hidden() -> None: + @feud.env(opt1="OPT1") + def f(*, opt1: t.constr(max_length=3)) -> None: + pass + + msg = "String should have at most 3 characters [input_value=hidden]" + with pytest.raises(click.UsageError, match=re.escape(msg)): + feud.run(f, [], standalone_mode=False) + + +@mock.patch.dict( + os.environ, {"OPT1": "1", "OPT2": "true", "OPT3": "-0.1"}, clear=True +) +def test_env_call_with_env(env_command: Callable) -> None: + assert feud.run(env_command, [], standalone_mode=False) == (1, True, -0.1) + + +@mock.patch.dict(os.environ, {"OPT": "long"}, clear=True) +def test_override_env_hidden() -> None: + @click.option("--opt", type=str, envvar="OPT") + def f(*, opt: t.constr(max_length=3)) -> str: + return opt + + msg = "String should have at most 3 characters [input_value=hidden]" + with pytest.raises(click.UsageError, match=re.escape(msg)): + feud.run(f, [], standalone_mode=False) From 3fafaa4e947bea6baf96576b85d48f56e649de66 Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Mon, 11 Dec 2023 01:49:27 +0000 Subject: [PATCH 09/12] docs: add postponed evaluation `README.md` disclaimer (#92) --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 90ade49..8a6c260 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ ## About -> [!CAUTION] +> [!WARNING] > _Writing command-line interfaces can get messy!_ It is not uncommon for CLIs to consist of many commands, @@ -756,17 +756,22 @@ This installs Feud with the optional dependencies: To install Feud without any optional dependencies, simply run `pip install feud`. +> [!CAUTION] +> Feud **will break** if used with postponed type hint evaluation ([PEP563](https://peps.python.org/pep-0563/)), i.e. `from __future__ import annotations`. +> +> This is because Feud relies on type hint evaluation in order to determine the expected input type for command parameters. + ### Improved formatting with Rich -Below is a demonstration of the difference between using Feud with and without `rich-click`. +Below is a comparison of Feud with and without `rich-click`. From b1f0dcfb86d684cdbacad279da4d7ed5950540d3 Mon Sep 17 00:00:00 2001 From: Edwin Onuonga Date: Wed, 13 Dec 2023 12:16:20 +0000 Subject: [PATCH 10/12] docs: `click.Option` intersphinx reference (#93) --- feud/decorators.py | 2 +- .../test_internal/test_types/test_click/conftest.py | 8 +++++--- .../test_click/test_get_click_type/test_literal.py | 4 +++- .../test_click/test_get_click_type/test_typing.py | 4 +++- .../test_types/test_click/test_is_collection_type.py | 4 +++- .../unit/test_internal/test_types/test_click/utils.py | 10 ---------- 6 files changed, 15 insertions(+), 17 deletions(-) delete mode 100644 tests/unit/test_internal/test_types/test_click/utils.py diff --git a/feud/decorators.py b/feud/decorators.py index 6522196..b7b275e 100644 --- a/feud/decorators.py +++ b/feud/decorators.py @@ -141,7 +141,7 @@ def env(**envs: str) -> t.Callable: """Specify environment variable inputs for command options. Decorates a function by attaching command option environment variable - metadata, to be used at compile time by py:class:`click.Option` objects. + metadata, to be used at compile time by :py:class:`click.Option` objects. Environment variables may only be defined for command-line options, not arguments. This translates to keyword-only parameters, i.e. those diff --git a/tests/unit/test_internal/test_types/test_click/conftest.py b/tests/unit/test_internal/test_types/test_click/conftest.py index ab0d0fe..f4d9476 100644 --- a/tests/unit/test_internal/test_types/test_click/conftest.py +++ b/tests/unit/test_internal/test_types/test_click/conftest.py @@ -5,13 +5,15 @@ from __future__ import annotations -import pytest +import typing as t -from .utils import annotate +import pytest class Helpers: - annotate = annotate + @staticmethod + def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: + return t.Annotated[hint, "annotation"] @pytest.fixture(scope="module") diff --git a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_literal.py b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_literal.py index 5aee821..d71f61b 100644 --- a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_literal.py +++ b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_literal.py @@ -13,7 +13,9 @@ from feud import typing as t from feud.config import Config -from ..utils import annotate # noqa: TID252 + +def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: + return t.Annotated[hint, "annotation"] @pytest.mark.parametrize("annotated", [False, True]) diff --git a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py index 34dec2c..62386d4 100644 --- a/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py +++ b/tests/unit/test_internal/test_types/test_click/test_get_click_type/test_typing.py @@ -12,7 +12,9 @@ from feud._internal._types.click import Union from feud.config import Config -from ..utils import annotate # noqa: TID252 + +def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: + return t.Annotated[hint, "annotation"] @pytest.mark.parametrize("annotated", [False, True]) diff --git a/tests/unit/test_internal/test_types/test_click/test_is_collection_type.py b/tests/unit/test_internal/test_types/test_click/test_is_collection_type.py index 4f7aae3..6e2b789 100644 --- a/tests/unit/test_internal/test_types/test_click/test_is_collection_type.py +++ b/tests/unit/test_internal/test_types/test_click/test_is_collection_type.py @@ -12,7 +12,9 @@ from feud._internal import _types -from .utils import annotate + +def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: + return t.Annotated[hint, "annotation"] @pytest.fixture(scope="module") diff --git a/tests/unit/test_internal/test_types/test_click/utils.py b/tests/unit/test_internal/test_types/test_click/utils.py deleted file mode 100644 index df5f823..0000000 --- a/tests/unit/test_internal/test_types/test_click/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2023-2025 Feud Developers. -# Distributed under the terms of the MIT License (see the LICENSE file). -# SPDX-License-Identifier: MIT -# This source code is part of the Feud project (https://feud.wiki). - -import typing as t - - -def annotate(hint: t.Any) -> t.Annotated[t.Any, "annotation"]: - return t.Annotated[hint, "annotation"] From a364b56a8037882036ed32be7e33ac1c461204d1 Mon Sep 17 00:00:00 2001 From: "Edwin (Ed) Onuonga" Date: Thu, 14 Dec 2023 23:48:13 +0000 Subject: [PATCH 11/12] feat: add `feud.rename` decorator (#94) --- README.md | 22 ++- docs/source/sections/config/index.rst | 12 +- docs/source/sections/core/command.rst | 12 +- docs/source/sections/core/group.rst | 12 +- docs/source/sections/decorators/alias.rst | 12 +- docs/source/sections/decorators/env.rst | 12 +- docs/source/sections/decorators/index.rst | 2 +- docs/source/sections/decorators/rename.rst | 159 +++++++++++++++- docs/source/sections/typing/other.rst | 12 +- docs/source/sections/typing/pydantic.rst | 12 +- .../sections/typing/pydantic_extra_types.rst | 12 +- docs/source/sections/typing/stdlib.rst | 12 +- feud/_internal/_command.py | 35 +++- feud/_internal/_decorators.py | 5 +- feud/core/command.py | 10 +- feud/core/group.py | 6 + feud/decorators.py | 80 ++++++-- tests/unit/test_decorators.py | 174 ++++++++++++++++++ tests/unit/test_internal/test_decorators.py | 10 + 19 files changed, 517 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 8a6c260..8b454a1 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ somewhat verbose and often requires frequently looking up documentation. Consider the following example command for serving local files on a HTTP server. -In red is a typical Click implementation, and in green is the Feud equivalent. +**In red is a typical Click implementation, and in green is the Feud equivalent.** ```diff - import click @@ -472,8 +472,8 @@ $ python blog.py post list --help ### Powerful typing -Feud is powered by Pydantic – a validation library with extensive support for -many data types, including: +Feud is powered by [Pydantic](https://docs.pydantic.dev/latest/) – a +validation library with extensive support for many data types, including: - simple types such as integers and dates, - complex types such as emails, IP addresses, file/directory paths, database @@ -742,22 +742,26 @@ You can install Feud using `pip`. The latest stable version of Feud can be installed with the following command. ```console -pip install feud[all] +pip install "feud[all]" ``` This installs Feud with the optional dependencies: -- [`rich-click`](https://github.com/ewels/rich-click) (can install individually with `pip install feud[rich]`)
+- [`rich-click`](https://github.com/ewels/rich-click) (can install individually with `pip install "feud[rich]"`)
_Provides improved formatting for CLIs produced by Feud._ -- [`pydantic-extra-types`](https://github.com/pydantic/pydantic-extra-types) (can install individually with `pip install feud[extra-types]`)
+- [`pydantic-extra-types`](https://github.com/pydantic/pydantic-extra-types) (can install individually with `pip install "feud[extra-types]"`)
_Provides additional types that can be used as type hints for Feud commands._ -- [`email-validator`](https://github.com/JoshData/python-email-validator) (can install individually with `pip install feud[email]`)
+- [`email-validator`](https://github.com/JoshData/python-email-validator) (can install individually with `pip install "feud[email]"`)
_Provides Pydantic support for email validation._ To install Feud without any optional dependencies, simply run `pip install feud`. > [!CAUTION] -> Feud **will break** if used with postponed type hint evaluation ([PEP563](https://peps.python.org/pep-0563/)), i.e. `from __future__ import annotations`. +> Feud **will break** if used with postponed type hint evaluation ([PEP563](https://peps.python.org/pep-0563/)), i.e.: +> +> ```python +> from __future__ import annotations +> ``` > > This is because Feud relies on type hint evaluation in order to determine the expected input type for command parameters. @@ -890,7 +894,7 @@ All contributions to this repository are greatly appreciated. Contribution guide > > We're living in an imperfect world!
-> Feud is in a public beta-test phase, likely with lots of bugs. Please leave feedback if you come across anything strange! +> Feud is in a public beta-test phase, likely with lots of bugs. Please leave feedback if you come across anything strange! ## Licensing diff --git a/docs/source/sections/config/index.rst b/docs/source/sections/config/index.rst index 9dfebc6..8213182 100644 --- a/docs/source/sections/config/index.rst +++ b/docs/source/sections/config/index.rst @@ -3,6 +3,12 @@ Configuration ============= +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + :doc:`../core/command` are defined by :py:func:`.command`, which accepts various Feud configuration key-word arguments such as ``negate_flags`` or ``show_help_defaults`` directly. @@ -15,12 +21,6 @@ object that can be provided to other commands or groups. This functionality is implemented by :py:func:`.config`, which creates a configuration which can be provided to :py:func:`.command` or :py:class:`.Group`. -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 - ---- API reference diff --git a/docs/source/sections/core/command.rst b/docs/source/sections/core/command.rst index 5224c74..3086754 100644 --- a/docs/source/sections/core/command.rst +++ b/docs/source/sections/core/command.rst @@ -1,6 +1,12 @@ Commands ======== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + Commands are the core component of a CLI, running a user-defined function that may be parameterized with arguments or options. @@ -18,12 +24,6 @@ Commands may be executed using :py:func:`.run`. - `Arguments `__ - `Options `__ -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 - ---- Understanding function signatures diff --git a/docs/source/sections/core/group.rst b/docs/source/sections/core/group.rst index 9c7a0d1..6a18fcf 100644 --- a/docs/source/sections/core/group.rst +++ b/docs/source/sections/core/group.rst @@ -1,6 +1,12 @@ Groups ====== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + Groups are a component of CLIs that allow you to group together related :doc:`command`. In addition to commands, groups may also contain further nested groups by :py:obj:`.register`\ ing subgroups, @@ -18,12 +24,6 @@ Groups and their subgroups or commands can be executed using :py:func:`.run`. - `Arguments `__ - `Options `__ -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 - ---- API reference diff --git a/docs/source/sections/decorators/alias.rst b/docs/source/sections/decorators/alias.rst index c063acc..b3144fa 100644 --- a/docs/source/sections/decorators/alias.rst +++ b/docs/source/sections/decorators/alias.rst @@ -1,6 +1,12 @@ Aliasing parameters =================== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + In CLIs, it is common for options to have an alias allowing for quicker short-hand usage. @@ -43,12 +49,6 @@ and can instead rely on type hints and docstrings. In the case of boolean flags such as ``--verbose`` in this case, the ``--no-verbose`` option will also have a corresponding ``--no-v`` alias automatically defined. - -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 ---- diff --git a/docs/source/sections/decorators/env.rst b/docs/source/sections/decorators/env.rst index b187caa..7f67476 100644 --- a/docs/source/sections/decorators/env.rst +++ b/docs/source/sections/decorators/env.rst @@ -1,6 +1,12 @@ Using environment variables =========================== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + In CLIs, environment variables are often used as an alternative method of providing input for options. This is particularly useful for sensitive information such as API keys, tokens and passwords. @@ -52,12 +58,6 @@ and can instead rely on type hints and docstrings. feud.run(my_command) This can be called with ``SECRET_TOKEN=hello-world python command.py``, for example. - -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 ---- diff --git a/docs/source/sections/decorators/index.rst b/docs/source/sections/decorators/index.rst index 4ccd6c9..e92a9ce 100644 --- a/docs/source/sections/decorators/index.rst +++ b/docs/source/sections/decorators/index.rst @@ -10,4 +10,4 @@ This module consists of decorators that modify :doc:`../core/command` and their alias.rst env.rst - .. rename.rst + rename.rst diff --git a/docs/source/sections/decorators/rename.rst b/docs/source/sections/decorators/rename.rst index bea3a76..b90ccdf 100644 --- a/docs/source/sections/decorators/rename.rst +++ b/docs/source/sections/decorators/rename.rst @@ -1,11 +1,162 @@ -Renaming parameters -=================== +Renaming commands/parameters +============================ -TODO +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + +In certain cases, it may be desirable or even necessary for the names of the +commands or parameters generated by Feud to be different to the names of the +Python functions (and their parameters) that were used to generate the +commands. + +The :py:func:`.rename` operator can be used in these scenarios to rename commands +or parameters. + +Examples +-------- + +Defining commands or parameters with reserved keywords +****************************************************** + +Suppose we have the following command, ``sum``, +which takes a starting number ``from``, and an ending number ``to``, +and sums all numbers between and including the starting and ending number. + +This might be called in the following way: + +.. code:: bash + + $ sum --from 1 --to 10 + +With generating code that might look like: + +.. code:: python + + # sum.py + + import feud + + def sum(*, from: int, to: int): + """Sums the numbers between and including a start and end number. + + Parameters + ---------- + from: + Starting number. + to: + Ending number. + """ + print(sum(range(from, to + 1))) + + if __name__ == "__main__": + feud.run(sum) + +There are two problems here: + +1. By naming the function ``sum``, we are shadowing the in-built Python + function ``sum``. This is also an issue as our function actually relies + on the in-built Python ``sum`` function to actually do the addition. +2. ``from`` is also a reserved Python keyword which is used in module imports, + and cannot be used as a function parameter. + +We can use the :py:func:`.rename` decorator to rename both the command and parameter. + +.. code:: python + + # sum.py + + import feud + + @feud.rename("sum", from_="from") + def sum_(*, from_: int, to: int): + """Sums the numbers between and including a start and end number. + + Parameters + ---------- + from_: + Starting number. + to: + Ending number. + """ + print(sum(range(from, to + 1))) + + if __name__ == "__main__": + feud.run(sum_) + +This gives us valid Python code, and also our expected CLI behaviour. + +Defining hyphenated commands or parameters +****************************************** + +Suppose we have a command that should be called in the following way: + +.. code:: bash + + $ say-hi --welcome-message "Hello World!" + +As Feud uses the parameter names present in the Python function signature as +the parameter names for the generated CLI, this means that defining parameters +with hyphens is *usually* not possible, as Python identifiers cannot have hyphens. +Similarly, a function name cannot have a hyphen: + +.. code:: python + + # hyphen.py + + import feud + + def say-hi(*, welcome-message: str): + print(welcome-message) + + if __name__ == "__main__": + feud.run(say-hi) + +We can use the :py:func:`.rename` decorator to rename both the command and parameter. + +.. code:: python + + # hyphen.py + + import feud + + @feud.rename("say-hi", welcome_message="welcome-message") + def say_hi(*, welcome_message: str): + print(welcome_message) + + if __name__ == "__main__": + feud.run(say_hi) + +This gives us valid Python code, and also our expected CLI behaviour. + +Special use case for maintaining group-level configurations +*********************************************************** + +Although :py:func:`.command` accepts a ``name`` argument (passed to Click) that can be +used to rename a command, this can sometimes be undesirable in the case of :doc:`../core/group`. + +In the following example, although ``show_help_defaults`` has been set to +``False`` at the group level (which would usually mean that all commands +defined within the group will not have their parameter defaults shown in +``--help``), this has been overridden by the ``feud.command`` call which +has ``show_help_defaults=True`` by default. + +.. code:: python + + class CLI(feud.Group, show_help_defaults=False): + @feud.command(name="my-func") + def my_func(*, opt: int = 1): + pass + +Using ``@feud.rename("my-func")`` instead of ``@feud.command(name="my-func")`` +would allow for the group-level configuration to be used, while still renaming +the function. ---- API reference ------------- -TODO +.. autofunction:: feud.decorators.rename diff --git a/docs/source/sections/typing/other.rst b/docs/source/sections/typing/other.rst index 20de8e3..53b35be 100644 --- a/docs/source/sections/typing/other.rst +++ b/docs/source/sections/typing/other.rst @@ -1,6 +1,12 @@ Other types =========== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + Feud provides the following additional types for common CLI needs. .. tip:: @@ -16,12 +22,6 @@ Feud provides the following additional types for common CLI needs. t.Counter # feud.typing.custom.Counter t.concounter # feud.typing.custom.concounter -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 - ---- Counting types diff --git a/docs/source/sections/typing/pydantic.rst b/docs/source/sections/typing/pydantic.rst index ddebd09..ec0952c 100644 --- a/docs/source/sections/typing/pydantic.rst +++ b/docs/source/sections/typing/pydantic.rst @@ -1,6 +1,12 @@ Pydantic types ============== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + `Pydantic `__ is a validation library that provides a rich selection of useful types for command-line inputs. @@ -29,12 +35,6 @@ The following commonly used Pydantic types can be used as type hints for Feud co t.conint # pydantic.types.conint t.IPvAnyAddress # pydantic.networks.IPvAnyAddress -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 - ---- String types diff --git a/docs/source/sections/typing/pydantic_extra_types.rst b/docs/source/sections/typing/pydantic_extra_types.rst index 7b2ada2..0e01157 100644 --- a/docs/source/sections/typing/pydantic_extra_types.rst +++ b/docs/source/sections/typing/pydantic_extra_types.rst @@ -1,6 +1,12 @@ Pydantic extra types ==================== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + `Pydantic Extra Types `__ is a package that extends `Pydantic `__ with support for additional types. @@ -29,12 +35,6 @@ The following types can be used as type hints for Feud commands. t.Latitude # pydantic_extra_types.coordinate.Latitude t.Color # pydantic_extra_types.color.Color -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 - ---- Color type diff --git a/docs/source/sections/typing/stdlib.rst b/docs/source/sections/typing/stdlib.rst index 0b0c1e0..64203fe 100644 --- a/docs/source/sections/typing/stdlib.rst +++ b/docs/source/sections/typing/stdlib.rst @@ -1,6 +1,12 @@ Standard library types ====================== +.. contents:: Table of Contents + :class: this-will-duplicate-information-and-it-is-still-useful-here + :local: + :backlinks: none + :depth: 3 + The following Python standard library types can be used as type hints for Feud commands. .. tip:: @@ -21,12 +27,6 @@ The following Python standard library types can be used as type hints for Feud c t.NamedTuple # typing.NamedTuple t.Union # typing.Union -.. contents:: Table of Contents - :class: this-will-duplicate-information-and-it-is-still-useful-here - :local: - :backlinks: none - :depth: 3 - ---- String type diff --git a/feud/_internal/_command.py b/feud/_internal/_command.py index 3cf9276..d356cd4 100644 --- a/feud/_internal/_command.py +++ b/feud/_internal/_command.py @@ -34,24 +34,26 @@ class ParameterSpec: kwargs: dict[str, t.Any] = dataclasses.field(default_factory=dict) +class NameDict(t.TypedDict): + command: str | None + params: dict[str, str] + + @dataclasses.dataclass class CommandState: config: Config click_kwargs: dict[str, t.Any] is_group: bool + names: dict[str, NameDict] # key: parameter name + aliases: dict[str, str | list[str]] # key: parameter name + envs: dict[str, str] # key: parameter name + overrides: dict[str, click.Parameter] # key: parameter name pass_context: bool = False # below keys are parameter name arguments: dict[str, ParameterSpec] = dataclasses.field( default_factory=dict ) options: dict[str, ParameterSpec] = dataclasses.field(default_factory=dict) - aliases: dict[str, str | list[str]] = dataclasses.field( - default_factory=dict - ) - envs: dict[str, str] = dataclasses.field(default_factory=dict) - overrides: dict[str, click.Parameter] = dataclasses.field( - default_factory=dict - ) def decorate(self: CommandState, func: t.Callable) -> click.Command: meta_vars: dict[str, str] = {} @@ -82,8 +84,18 @@ def decorate(self: CommandState, func: t.Callable) -> click.Command: hide_input = spec.kwargs.get("hide_input") envvar = spec.kwargs.get("envvar") sensitive = hide_input or envvar - meta_vars[param_name] = self.get_meta_var(param) - sensitive_vars[param_name] = sensitive + + # get renamed parameter if @feud.rename used + name: str = self.names["params"].get(param_name, param_name) + + # set parameter name + param.name = name + + # get meta vars and identify sensitive parameters for validate_call + meta_vars[name] = self.get_meta_var(param) + sensitive_vars[name] = sensitive + + # add the parameter params.append(param) # add any overrides that don't appear in function signature @@ -92,9 +104,14 @@ def decorate(self: CommandState, func: t.Callable) -> click.Command: if param_name not in sig.parameters: params.append(param) + # rename command if @feud.rename used + if command_rename := self.names["command"]: + self.click_kwargs = {**self.click_kwargs, "name": command_rename} + command = _decorators.validate_call( func, name=self.click_kwargs["name"], + param_renames=self.names["params"], meta_vars=meta_vars, sensitive_vars=sensitive_vars, pydantic_kwargs=self.config.pydantic_kwargs, diff --git a/feud/_internal/_decorators.py b/feud/_internal/_decorators.py index 1a02ac3..a130648 100644 --- a/feud/_internal/_decorators.py +++ b/feud/_internal/_decorators.py @@ -23,6 +23,7 @@ def validate_call( /, *, name: str, + param_renames: dict[str, str], meta_vars: dict[str, str], sensitive_vars: dict[str, bool], pydantic_kwargs: dict[str, t.Any], @@ -30,8 +31,10 @@ def validate_call( @ft.wraps(func) def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Callable: try: + inv_mapping = {v: k for k, v in param_renames.items()} config = pyd.ConfigDict(**pydantic_kwargs) - return pyd.validate_call(func, config=config)(*args, **kwargs) + true_kwargs = {inv_mapping.get(k, k): v for k, v in kwargs.items()} + return pyd.validate_call(func, config=config)(*args, **true_kwargs) except pyd.ValidationError as e: msg = re.sub( r"validation error(s?) for (.*)\n", diff --git a/feud/core/command.py b/feud/core/command.py index ab8f1b0..d0b8567 100644 --- a/feud/core/command.py +++ b/feud/core/command.py @@ -134,6 +134,9 @@ def build_command_state( meta = _command.ParameterSpec() meta.hint: type = spec.annotation + # get renamed parameter if @feud.rename used + name: str = state.names["params"].get(param, param) + if _command.pass_context(sig) and param == _command.CONTEXT_PARAM: # skip handling for click.Context argument state.pass_context = True @@ -143,7 +146,7 @@ def build_command_state( meta.type = _command.ParameterType.ARGUMENT # add the argument - meta.args = [param] + meta.args = [name] # special handling for variable-length collections is_collection, base_type = _types.click.is_collection_type( @@ -182,7 +185,7 @@ def build_command_state( # add the option meta.args = [ _command.get_option( - param, hint=meta.hint, negate_flags=config.negate_flags + name, hint=meta.hint, negate_flags=config.negate_flags ) ] @@ -243,6 +246,9 @@ def get_command( is_group=False, aliases=getattr(func, "__feud_aliases__", {}), envs=getattr(func, "__feud_envs__", {}), + names=getattr( + func, "__feud_names__", _command.NameDict(command=None, params={}) + ), overrides={ override.name: override for override in getattr(func, "__click_params__", []) diff --git a/feud/core/group.py b/feud/core/group.py index 36dfae9..88f1896 100644 --- a/feud/core/group.py +++ b/feud/core/group.py @@ -63,6 +63,9 @@ class Group(metaclass=_metaclass.GroupBase): - :py:func:`~descendants` - :py:func:`~register` - :py:func:`~subgroups` + + See :py:func:`.rename` if you wish to define a command with one of the + above names. """ __feud_config__: t.ClassVar[Config] @@ -458,6 +461,9 @@ def get_group(__cls: type[Group], /) -> click.Group: is_group=True, aliases=getattr(func, "__feud_aliases__", {}), envs=getattr(func, "__feud_envs__", {}), + names=getattr( + func, "__feud_names__", _command.NameDict(command=None, params={}) + ), overrides={ override.name: override for override in getattr(func, "__click_params__", []) diff --git a/feud/decorators.py b/feud/decorators.py index b7b275e..2dd280d 100644 --- a/feud/decorators.py +++ b/feud/decorators.py @@ -10,13 +10,13 @@ import inspect import re import typing as t -from functools import partial import pydantic as pyd +from feud._internal import _command from feud.exceptions import CompilationError -__all__ = ["alias", "env"] +__all__ = ["alias", "env", "rename"] @pyd.validate_call @@ -33,9 +33,7 @@ def alias(**aliases: str | list[str]) -> t.Callable: Parameters ---------- **aliases: - Mapping of option names to aliases. - Option names must be keyword-only parameters in the decorated function signature. @@ -90,9 +88,7 @@ def alias(**aliases: str | list[str]) -> t.Callable: 3 """ - def decorator( - f: t.Callable, *, aliases: dict[str, str | list[str]] - ) -> t.Callable: + def decorator(f: t.Callable) -> t.Callable: # check provided aliases and parameters match sig = inspect.signature(f) specified = set(aliases.keys()) @@ -134,7 +130,7 @@ def decorator( } return f - return partial(decorator, aliases=aliases) + return decorator def env(**envs: str) -> t.Callable: @@ -150,9 +146,7 @@ def env(**envs: str) -> t.Callable: Parameters ---------- **envs: - Mapping of option names to environment variables. - Option names must be keyword-only parameters in the decorated function signature. @@ -186,7 +180,7 @@ def env(**envs: str) -> t.Callable: ("Hello world!", "This is a secret key.") """ - def decorator(f: t.Callable, *, envs: dict[str, str]) -> t.Callable: + def decorator(f: t.Callable) -> t.Callable: # check provided envs and parameters match sig = inspect.signature(f) specified = set(envs.keys()) @@ -204,8 +198,66 @@ def decorator(f: t.Callable, *, envs: dict[str, str]) -> t.Callable: f.__feud_envs__ = envs return f - return partial(decorator, envs=envs) + return decorator + + +def rename(command: str | None = None, /, **params: str) -> t.Callable: + """Rename a command and/or its parameters. + + Useful for command/parameter names that use hyphens, reserved Python + keywords or in-built function names. + + Parameters + ---------- + command: + New command name. If ``None``, the command is not renamed. + + **params: + Mapping of parameter names to new names. + Parameter names must be defined in the decorated function signature. + + Returns + ------- + Function decorated with command/parameter renaming metadata. + Examples + -------- + Renaming a command. + + >>> import feud + >>> @feud.rename("my-func") + ... def my_func(arg_1: int, *, opt_1: str, opt_2: bool): + ... pass + + Renaming parameters. + + >>> import feud + >>> @feud.rename(arg_1="arg-1", opt_2="opt-2") + ... def my_func(arg_1: int, *, opt_1: str, opt_2: bool): + ... pass + + Renaming a command and parameters. + + >>> import feud + >>> @feud.rename("my-func", arg_1="arg-1", opt_2="opt-2") + ... def my_func(arg_1: int, *, opt_1: str, opt_2: bool): + ... pass + """ + + def decorator(f: t.Callable) -> t.Callable: + # check provided names and parameters match + sig = inspect.signature(f) + specified = set(params.keys()) + received = {p.name for p in sig.parameters.values()} + if len(specified - received) > 0: + msg = ( + f"Arguments provided to 'env' decorator must " + f"also be parameters for function {f.__name__!r}. " + f"Received extra arguments: {specified - received!r}." + ) + raise CompilationError(msg) + + f.__feud_names__ = _command.NameDict(command=command, params=params) + return f -# def rename(command: str | None = None, /, **params: str) -> t.Callable: -# rename("cmd") renames the command without requiring @feud.command(name="cmd") + return decorator diff --git a/tests/unit/test_decorators.py b/tests/unit/test_decorators.py index f41ce9a..9cba1e3 100644 --- a/tests/unit/test_decorators.py +++ b/tests/unit/test_decorators.py @@ -193,3 +193,177 @@ def f(*, opt: t.constr(max_length=3)) -> str: msg = "String should have at most 3 characters [input_value=hidden]" with pytest.raises(click.UsageError, match=re.escape(msg)): feud.run(f, [], standalone_mode=False) + + +def test_rename_command() -> None: + @feud.command + @feud.rename("func") + def f(*, opt: int) -> int: + return opt + + assert f.name == "func" + + +def test_rename_params() -> None: + @feud.rename(arg1="arg-1", arg2="arg-2", opt1="opt-1", opt2="opt-2") + def f( + ctx: click.Context, arg1: int, arg2: str, *, opt1: bool, opt2: float + ) -> None: + return arg1, arg2, opt1, opt2 + + cmd = feud.command(f) + + # check arg1 -> arg-1 rename + assert cmd.params[0].name == "arg-1" + + # check arg2 -> arg-2 rename + assert cmd.params[1].name == "arg-2" + + # check opt1 -> opt-1 rename + # should create options --opt-1/--no-opt-1 + assert cmd.params[2].name == "opt-1" + assert cmd.params[2].opts == ["--opt-1"] + assert cmd.params[2].secondary_opts == ["--no-opt-1"] + + # check opt2 -> opt-2 rename + # should create option --opt-2 + assert cmd.params[3].name == "opt-2" + assert cmd.params[3].opts == ["--opt-2"] + + # test call + assert cmd( + ["2", "test", "--no-opt-1", "--opt-2", "0.2"], standalone_mode=False + ) == (2, "test", False, 0.2) + + +def test_rename_command_and_params(capsys: pytest.CaptureFixture) -> None: + @feud.rename( + "func", arg1="arg-1", arg2="arg-2", opt1="opt-1", opt2="opt-2" + ) + def f( + ctx: click.Context, arg1: int, arg2: str, *, opt1: bool, opt2: float + ) -> None: + return arg1, arg2, opt1, opt2 + + cmd = feud.command(f) + + # check command name + assert cmd.name == "func" + + # check arg1 -> arg-1 rename + assert cmd.params[0].name == "arg-1" + + # check arg2 -> arg-2 rename + assert cmd.params[1].name == "arg-2" + + # check opt1 -> opt-1 rename + # should create options --opt-1/--no-opt-1 + assert cmd.params[2].name == "opt-1" + assert cmd.params[2].opts == ["--opt-1"] + assert cmd.params[2].secondary_opts == ["--no-opt-1"] + + # check opt2 -> opt-2 rename + # should create option --opt-2 + assert cmd.params[3].name == "opt-2" + assert cmd.params[3].opts == ["--opt-2"] + + # check help + assert_help( + cmd, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] ARG-1 ARG-2 + +Options: + --opt-1 / --no-opt-1 [required] + --opt-2 FLOAT [required] + --help Show this message and exit. + """, + ) + + # test call + assert cmd( + ["2", "test", "--no-opt-1", "--opt-2", "0.2"], standalone_mode=False + ) == (2, "test", False, 0.2) + + +@mock.patch.dict(os.environ, {"OPT1": "1", "OPT2": "true"}, clear=True) +def test_all_decorators(capsys: pytest.CaptureFixture) -> None: + @feud.rename("cmd", opt1="opt-1", opt2="opt-2", opt3="opt_3") + @feud.env(opt1="OPT1", opt2="OPT2") + @feud.alias(opt3="-o") + def command( + *, opt1: t.PositiveInt, opt2: bool, opt3: t.NegativeFloat + ) -> t.Path: + """Returns a full path.\f + + Parameters + ---------- + opt1: + First option. + opt2: + Second option. + opt3: + Third option. + """ + return opt1, opt2, opt3 + + cmd = feud.command(command) + + # check command name + assert cmd.name == "cmd" + + # check opt1 -> opt-1 rename + # should create option --opt-1 + assert cmd.params[0].name == "opt-1" + assert cmd.params[0].opts == ["--opt-1"] + assert cmd.params[0].envvar == "OPT1" + + # check opt2 -> opt-2 rename + # should create option --opt-2 + assert cmd.params[1].name == "opt-2" + assert cmd.params[1].opts == ["--opt-2"] + assert cmd.params[1].envvar == "OPT2" + + # check opt3 -> opt_3 rename + # should create option --opt_3 + assert cmd.params[2].name == "opt_3" + assert cmd.params[2].opts == ["--opt_3", "-o"] + + # check help + assert_help( + cmd, + capsys=capsys, + expected=""" +Usage: pytest [OPTIONS] + + Returns a full path. + +Options: + --opt-1 INTEGER RANGE First option. [env var: OPT1; x>0; required] + --opt-2 / --no-opt-2 Second option. [env var: OPT2; required] + -o, --opt_3 FLOAT RANGE Third option. [x<0; required] + --help Show this message and exit. + """, + ) + + # test call + assert cmd(["--opt_3", "-1.2"], standalone_mode=False) == (1, True, -1.2) + + +def test_rename_group(capsys: pytest.CaptureFixture) -> None: + class Test(feud.Group): + @staticmethod + @feud.rename("test-group", opt1="opt-1") + def __main__(ctx: click.Context, *, opt1: int) -> None: + ctx.obj = {"opt1": opt1} + + @staticmethod + @feud.rename("func", opt2="opt_2") + def f(ctx: click.Context, *, opt2: int) -> int: + return ctx.obj["opt1"], opt2 + + return Test( + ["--opt-1", "1", "func", "--opt_2", "2"], + standalone_mode=False, + ) == (1, 2) diff --git a/tests/unit/test_internal/test_decorators.py b/tests/unit/test_internal/test_decorators.py index 7f701f1..ca458b2 100644 --- a/tests/unit/test_internal/test_decorators.py +++ b/tests/unit/test_internal/test_decorators.py @@ -19,6 +19,7 @@ def test_validate_call_single_invalid() -> None: value. """ name = "func" + param_renames = {} meta_vars = {"arg2": "--arg2"} sensitive_vars = {"arg2": False} pydantic_kwargs = {} @@ -30,6 +31,7 @@ def f(*, arg2: t.Literal["a", "b", "c"]) -> None: _decorators.validate_call( f, name=name, + param_renames=param_renames, meta_vars=meta_vars, sensitive_vars=sensitive_vars, pydantic_kwargs=pydantic_kwargs, @@ -50,6 +52,7 @@ def test_validate_call_multiple_invalid() -> None: input values. """ name = "func" + param_renames = {} meta_vars = {"0": "ARG1", "arg2": "--arg2"} sensitive_vars = {"0": False, "arg2": False} pydantic_kwargs = {} @@ -61,6 +64,7 @@ def f(arg1: int, *, arg2: t.Literal["a", "b", "c"]) -> None: _decorators.validate_call( f, name=name, + param_renames=param_renames, meta_vars=meta_vars, sensitive_vars=sensitive_vars, pydantic_kwargs=pydantic_kwargs, @@ -83,6 +87,7 @@ def test_validate_call_list() -> None: a list argument. """ name = "func" + param_renames = {} meta_vars = {"0": "[ARG1]..."} sensitive_vars = {"0": False} pydantic_kwargs = {} @@ -94,6 +99,7 @@ def f(arg1: list[t.conint(multiple_of=2)]) -> None: _decorators.validate_call( f, name=name, + param_renames=param_renames, meta_vars=meta_vars, sensitive_vars=sensitive_vars, pydantic_kwargs=pydantic_kwargs, @@ -116,6 +122,7 @@ def test_validate_call_enum() -> None: for an enum parameter. """ name = "func" + param_renames = {} meta_vars = {"arg2": "--arg2"} sensitive_vars = {"arg2": False} pydantic_kwargs = {} @@ -132,6 +139,7 @@ def f(*, arg2: Choice) -> None: _decorators.validate_call( f, name=name, + param_renames=param_renames, meta_vars=meta_vars, sensitive_vars=sensitive_vars, pydantic_kwargs=pydantic_kwargs, @@ -152,6 +160,7 @@ def test_validate_call_datetime() -> None: for a datetime parameter. """ name = "func" + param_renames = {} meta_vars = {"time": "--time"} sensitive_vars = {"time": False} pydantic_kwargs = {} @@ -163,6 +172,7 @@ def f(*, time: t.FutureDatetime) -> None: _decorators.validate_call( f, name=name, + param_renames=param_renames, meta_vars=meta_vars, sensitive_vars=sensitive_vars, pydantic_kwargs=pydantic_kwargs, From 58a33b6f6d0b12a3b88f2e572bf3acac5296ef44 Mon Sep 17 00:00:00 2001 From: eonu Date: Thu, 14 Dec 2023 23:49:37 +0000 Subject: [PATCH 12/12] release: v0.1.1 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ docs/source/conf.py | 2 +- pyproject.toml | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 281585f..8d2dccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to this project will be documented in this file. +## [v0.1.1](https://github.com/eonu/feud/releases/tag/v0.1.1) - 2023-12-14 + +### Bug Fixes + +- change `feud.config` from package to module ([#86](https://github.com/eonu/feud/issues/86)) +- use `==` instead of `is` for `typing.Annotated` comparison ([#88](https://github.com/eonu/feud/issues/88)) + +### Documentation + +- add postponed evaluation `README.md` disclaimer ([#92](https://github.com/eonu/feud/issues/92)) +- `click.Option` intersphinx reference ([#93](https://github.com/eonu/feud/issues/93)) + +### Features + +- add `email` extra, issue/PR templates, `version` module ([#84](https://github.com/eonu/feud/issues/84)) +- add `typing.Pattern` to `feud.typing` ([#85](https://github.com/eonu/feud/issues/85)) +- add metavars for `typing.Union` and literal `|` union types ([#89](https://github.com/eonu/feud/issues/89)) +- add `Group.__main__()` support ([#90](https://github.com/eonu/feud/issues/90)) +- add `feud.env` decorator for env. variable options ([#91](https://github.com/eonu/feud/issues/91)) +- add `feud.rename` decorator ([#94](https://github.com/eonu/feud/issues/94)) + +### Testing + +- add test for inheritance command override ([#87](https://github.com/eonu/feud/issues/87)) + ## [v0.1.0](https://github.com/eonu/feud/releases/tag/v0.1.0) - 2023-12-05 ### Build System diff --git a/docs/source/conf.py b/docs/source/conf.py index 9727f0f..af6285a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -20,7 +20,7 @@ project = "feud" copyright = "2023-2025, Feud Developers" # noqa: A001 author = "Edwin Onuonga (eonu)" -release = "0.1.0" +release = "0.1.1" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 66ca4b4..b90d367 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "feud" -version = "0.1.0" +version = "0.1.1" license = "MIT" authors = ["Edwin Onuonga "] maintainers = ["Edwin Onuonga "]
-With Rich +With Rich-formatted output -Without Rich +Without Rich-formatted output