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,