From 60a3ca74e9e3288450316cb16bf13abf590ee44f Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Sun, 17 Dec 2023 21:38:47 -0800 Subject: [PATCH 01/20] Make all Parameter defaults genuinely None. --- cyclopts/bind.py | 8 ++++---- cyclopts/coercion.py | 9 ++++++--- cyclopts/help.py | 2 +- cyclopts/parameter.py | 26 +++++++++++++++++++------- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/cyclopts/bind.py b/cyclopts/bind.py index 18a4648c..6b767d30 100644 --- a/cyclopts/bind.py +++ b/cyclopts/bind.py @@ -46,7 +46,7 @@ def cli2parameter(f: Callable) -> Dict[str, Tuple[inspect.Parameter, Any]]: annotation = str if iparam.annotation is iparam.empty else iparam.annotation _, cparam = get_hint_parameter(annotation) - if not cparam.parse: + if cparam.parse is False: continue if iparam.kind in (iparam.POSITIONAL_OR_KEYWORD, iparam.KEYWORD_ONLY): @@ -174,7 +174,7 @@ def _parse_pos(f: Callable, tokens: Iterable[str], mapping: Dict) -> List[str]: def remaining_parameters(): for parameter in signature.parameters.values(): _, cparam = get_hint_parameter(parameter.annotation) - if not cparam.parse: + if cparam.parse is False: continue _, consume_all = token_count(parameter.annotation) if parameter in mapping and not consume_all: @@ -269,7 +269,7 @@ def f_pos_append(p): # * Only args before a ``/`` are ``POSITIONAL_ONLY``. for iparam in signature.parameters.values(): _, cparam = get_hint_parameter(iparam.annotation) - if not cparam.parse: + if cparam.parse is False: has_unparsed_parameters |= _is_required(iparam) continue @@ -297,7 +297,7 @@ def _convert(mapping: Dict[inspect.Parameter, List[str]]) -> dict: for iparam, parameter_tokens in mapping.items(): type_, cparam = get_hint_parameter(iparam.annotation) - if not cparam.parse: + if cparam.parse is False: continue # Checking if parameter_token is a string is a little jank, diff --git a/cyclopts/coercion.py b/cyclopts/coercion.py index 18adeb48..b79c0e04 100644 --- a/cyclopts/coercion.py +++ b/cyclopts/coercion.py @@ -238,14 +238,17 @@ def token_count(type_: Type) -> Tuple[int, bool]: return 1, False -def str_to_tuple_converter(input_value: Union[str, Iterable[str]]) -> Tuple[str, ...]: +def str_to_tuple_converter(input_value: Union[None, str, Iterable[str]]) -> Tuple[str, ...]: """Convert a string or Iterable into an Iterable. Intended to be used in an ``attrs.Field``. """ - if isinstance(input_value, str): + if input_value is None: + return () + elif isinstance(input_value, str): return (input_value,) - return tuple(input_value) + else: + return tuple(input_value) def optional_str_to_tuple_converter(input_value: Union[None, str, Iterable[str]]) -> Optional[Tuple[str, ...]]: diff --git a/cyclopts/help.py b/cyclopts/help.py index d90420f9..3a2b51a3 100644 --- a/cyclopts/help.py +++ b/cyclopts/help.py @@ -157,7 +157,7 @@ def format_parameters(app, title, show_special=True): help_lookup = parameter2docstring(app.default_command) for parameter in inspect.signature(app.default_command).parameters.values(): _, param = get_hint_parameter(parameter.annotation) - if not param.parse or not param.show_: + if param.parse is False or not param.show_: continue parameters.append(parameter) else: diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index 54df2d02..3f2b62e2 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -33,7 +33,7 @@ def validate_command(f): for iparam in signature.parameters.values(): _ = get_origin_and_validate(iparam.annotation) type_, cparam = get_hint_parameter(iparam.annotation) - if not cparam.parse and iparam.kind is not iparam.KEYWORD_ONLY: + if cparam.parse is False and iparam.kind is not iparam.KEYWORD_ONLY: raise ValueError("Parameter.parse=False must be used with a KEYWORD_ONLY function parameter.") if get_origin(type_) is tuple: if ... in get_args(type_): @@ -44,7 +44,9 @@ def validate_command(f): class Parameter: """Cyclopts configuration for individual function parameters.""" - name: Union[str, Iterable[str]] = field(default=[], converter=str_to_tuple_converter) + _name: Union[None, str, Iterable[str]] = field( + default=None, converter=optional_str_to_tuple_converter, alias="name" + ) """ Name(s) to expose to the CLI. Defaults to the python parameter's name, prepended with ``--``. @@ -92,7 +94,7 @@ def validator(type_, value: Any) -> None: Defaults to autodetecting based on type annotation. """ - parse: bool = field(default=True) + parse: Optional[bool] = field(default=None) """ Attempt to use this parameter while parsing. Intended only for advance usage with custom command invocation. @@ -132,21 +134,31 @@ def validator(type_, value: Any) -> None: Defaults to ``True``. """ - env_var: Union[str, Iterable[str]] = field(default=[], converter=str_to_tuple_converter) + _env_var: Union[None, str, Iterable[str]] = field( + default=None, converter=optional_str_to_tuple_converter, alias="env_var" + ) """ Fallback to environment variable(s) if CLI value not provided. If multiple environment variables are given, the left-most environment variable with a set value will be used. If no environment variable is set, Cyclopts will fallback to the function-signature default. """ + @property + def name(self): + return str_to_tuple_converter(self._name) + + @property + def env_var(self): + return str_to_tuple_converter(self._env_var) + @property def show_(self): if self.show is not None: return self.show - elif self.parse: - return True - else: + elif self.parse is False: return False + else: + return True @property def converter_(self): From 3162e97c8cacae7f41cc00ef6aa7fce51efbd8dd Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 14:03:27 -0800 Subject: [PATCH 02/20] make show attribute consistent --- cyclopts/help.py | 4 ++-- cyclopts/parameter.py | 8 ++++---- docs/source/api.rst | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cyclopts/help.py b/cyclopts/help.py index 3a2b51a3..a2ff2d3a 100644 --- a/cyclopts/help.py +++ b/cyclopts/help.py @@ -157,7 +157,7 @@ def format_parameters(app, title, show_special=True): help_lookup = parameter2docstring(app.default_command) for parameter in inspect.signature(app.default_command).parameters.values(): _, param = get_hint_parameter(parameter.annotation) - if param.parse is False or not param.show_: + if param.parse is False or not param.show: continue parameters.append(parameter) else: @@ -210,7 +210,7 @@ def is_short(s): for parameter in parameters: type_, param = get_hint_parameter(parameter.annotation) - if not param.show_: + if not param.show: continue has_short = any(is_short(x) for x in get_names(parameter)) if has_short: diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index 3f2b62e2..413a0815 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -102,7 +102,7 @@ def validator(type_, value: Any) -> None: Defaults to ``True``. """ - show: Optional[bool] = field(default=None) + _show: Optional[bool] = field(default=None, alias="show") """ Show this parameter in the help screen. If ``False``, state of all other ``show_*`` flags are ignored. @@ -152,9 +152,9 @@ def env_var(self): return str_to_tuple_converter(self._env_var) @property - def show_(self): - if self.show is not None: - return self.show + def show(self): + if self._show is not None: + return self._show elif self.parse is False: return False else: diff --git a/docs/source/api.rst b/docs/source/api.rst index 998715b5..39208caf 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -10,7 +10,7 @@ API .. autoclass:: cyclopts.Parameter :members: - :exclude-members: get_negatives, show_ + :exclude-members: get_negatives .. autofunction:: cyclopts.coerce From d1fcd4fd7f719b22957a8f76ba14329dcc1e5747 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 14:09:29 -0800 Subject: [PATCH 03/20] Add a non-default __repr__ to Parameter --- cyclopts/parameter.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index 413a0815..6d7b52c5 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -196,6 +196,17 @@ def get_negatives(self, type_, *names) -> Tuple[str, ...]: out.append(f"{prefix}{negative_word}-{name}") return tuple(out) + def __repr__(self): + """Only shows non-default values.""" + content = ", ".join( + [ + f"{a.alias}={v!r}" + for a in self.__attrs_attrs__ # pyright: ignore[reportGeneralTypeIssues] + if (v := getattr(self, a.name)) != a.default + ] + ) + return f"{type(self).__name__}({content})" + def get_names(parameter: inspect.Parameter) -> List[str]: """Derive the CLI name for an ``inspect.Parameter``.""" From 33c8e71219a3510c8a90592f6e9a35dbab71ceaf Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 15:37:21 -0800 Subject: [PATCH 04/20] Add custom __repr__ to App --- cyclopts/core.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cyclopts/core.py b/cyclopts/core.py index 8469c7d3..b25dca62 100644 --- a/cyclopts/core.py +++ b/cyclopts/core.py @@ -587,3 +587,16 @@ def default_dispatcher(command, bound): pass except Exception: print(traceback.format_exc()) + + def __repr__(self): + """Only shows non-default values.""" + non_defaults = {} + for a in self.__attrs_attrs__: # pyright: ignore[reportGeneralTypeIssues] + if not a.init: + continue + v = getattr(self, a.name) + if v != a.default: + non_defaults[a.alias] = v + + signature = ", ".join(f"{k}={v!r}" for k, v in non_defaults.items()) + return f"{type(self).__name__}({signature})" From 69a9970b06d345c5e780a7f94e52139d59fedf57 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 15:43:19 -0800 Subject: [PATCH 05/20] Add linkage from subapp to parent --- cyclopts/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cyclopts/core.py b/cyclopts/core.py index b25dca62..aee6b54c 100644 --- a/cyclopts/core.py +++ b/cyclopts/core.py @@ -139,6 +139,8 @@ class App: # Maps CLI-name of a command to a function handle. _commands: Dict[str, "App"] = field(init=False, factory=dict) + _parents: List["App"] = field(init=False, factory=list) + _meta: "App" = field(init=False, default=None) _meta_parent: "App" = field(init=False, default=None) @@ -273,6 +275,8 @@ def command( self._commands[n] = app + app._parents.append(self) + return obj def default(self, obj=None): From 32cf85e06d037a21ecf79ab9e4168c6373b1d26d Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 16:10:55 -0800 Subject: [PATCH 06/20] Parameter.combine class method --- cyclopts/parameter.py | 17 +++++++++++++++++ tests/test_parameter.py | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index 6d7b52c5..6852ed3f 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -207,6 +207,23 @@ def __repr__(self): ) return f"{type(self).__name__}({content})" + @classmethod + def combine(cls, *parameters: "Parameter") -> "Parameter": + """Returns a new Parameter with values of ``new_parameter`` overriding ``self``. + + Earlier parameters have higher attribute priority. + """ + kwargs = {} + for parameter in reversed(parameters): + for a in parameter.__attrs_attrs__: # pyright: ignore[reportGeneralTypeIssues] + if not a.init: + continue + v = getattr(parameter, a.name) + if v != a.default: + kwargs[a.alias] = v + + return cls(**kwargs) + def get_names(parameter: inspect.Parameter) -> List[str]: """Derive the CLI name for an ``inspect.Parameter``.""" diff --git a/tests/test_parameter.py b/tests/test_parameter.py index e0675b4f..cfeb12fc 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -54,3 +54,20 @@ def test_get_hint_parameter_optional_annotated(): type_, cparam = get_hint_parameter(Optional[Annotated[bool, expected_cparam]]) assert type_ is bool assert cparam == expected_cparam + + +def test_parameter_combine(): + p1 = Parameter(negative="--foo") + p2 = Parameter(show_default=False) + p_combined = Parameter.combine(p1, p2) + + assert p_combined.negative == ("--foo",) + assert p_combined.show_default is False + + +def test_parameter_combine_priority(): + p1 = Parameter(negative="--foo") + p2 = Parameter(negative="--bar") + p_combined = Parameter.combine(p1, p2) + + assert p_combined.negative == ("--foo",) From 253b2135abd3132b0959f6d0e5ca0984d2ef6eb7 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 16:14:51 -0800 Subject: [PATCH 07/20] Add a default_parameter field to App --- cyclopts/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cyclopts/core.py b/cyclopts/core.py index aee6b54c..70b84be2 100644 --- a/cyclopts/core.py +++ b/cyclopts/core.py @@ -22,7 +22,7 @@ format_cyclopts_error, ) from cyclopts.help import create_panel_table_commands, format_command_rows, format_doc, format_parameters, format_usage -from cyclopts.parameter import validate_command +from cyclopts.parameter import Parameter, validate_command from cyclopts.protocols import Dispatcher with suppress(ImportError): @@ -119,9 +119,12 @@ class App: help_title_parameters: str Title for the "parameters" help-panel. Defaults to ``"Parameters"``. + default_parameter: Parameter + Default :class:`Parameter` configuration. """ default_command: Optional[Callable] = field(default=None, converter=_validate_default_command) + default_parameter: Parameter = field(factory=Parameter) _name: Optional[str] = field(default=None, alias="name") From 9d6bd6291c2aa7be920bc2def818683222a0e59c Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 19:09:39 -0800 Subject: [PATCH 08/20] Allow a None parameter when combining --- cyclopts/parameter.py | 4 +++- tests/test_parameter.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index 6852ed3f..06f166ec 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -208,13 +208,15 @@ def __repr__(self): return f"{type(self).__name__}({content})" @classmethod - def combine(cls, *parameters: "Parameter") -> "Parameter": + def combine(cls, *parameters: Optional["Parameter"]) -> "Parameter": """Returns a new Parameter with values of ``new_parameter`` overriding ``self``. Earlier parameters have higher attribute priority. """ kwargs = {} for parameter in reversed(parameters): + if parameter is None: + continue for a in parameter.__attrs_attrs__: # pyright: ignore[reportGeneralTypeIssues] if not a.init: continue diff --git a/tests/test_parameter.py b/tests/test_parameter.py index cfeb12fc..2ec656cf 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -59,7 +59,7 @@ def test_get_hint_parameter_optional_annotated(): def test_parameter_combine(): p1 = Parameter(negative="--foo") p2 = Parameter(show_default=False) - p_combined = Parameter.combine(p1, p2) + p_combined = Parameter.combine(p1, None, p2) assert p_combined.negative == ("--foo",) assert p_combined.show_default is False From 09b27b679ddd5748ec54c5c5eea7936709bb23d0 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 21:08:55 -0800 Subject: [PATCH 09/20] shove default_parameter EVERYWHERE. --- cyclopts/bind.py | 68 +++++++++++++++++++++++------------------ cyclopts/coercion.py | 47 +++++++++++++++++----------- cyclopts/core.py | 25 ++++++++++++--- cyclopts/help.py | 12 ++++---- cyclopts/parameter.py | 56 ++++++++++++++++----------------- tests/test_parameter.py | 2 +- 6 files changed, 124 insertions(+), 86 deletions(-) diff --git a/cyclopts/bind.py b/cyclopts/bind.py index 6b767d30..600ec98c 100644 --- a/cyclopts/bind.py +++ b/cyclopts/bind.py @@ -3,7 +3,7 @@ import shlex import sys from functools import lru_cache -from typing import Any, Callable, Dict, Iterable, List, Tuple, Union, get_origin +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union, get_origin from cyclopts.coercion import resolve, token_count from cyclopts.exceptions import ( @@ -13,7 +13,7 @@ RepeatArgumentError, ValidationError, ) -from cyclopts.parameter import get_hint_parameter, get_names, validate_command +from cyclopts.parameter import Parameter, get_hint_parameter, get_names, validate_command def normalize_tokens(tokens: Union[None, str, Iterable[str]]) -> List[str]: @@ -27,7 +27,9 @@ def normalize_tokens(tokens: Union[None, str, Iterable[str]]) -> List[str]: @lru_cache(maxsize=16) -def cli2parameter(f: Callable) -> Dict[str, Tuple[inspect.Parameter, Any]]: +def cli2parameter( + f: Callable, default_parameter: Optional[Parameter] = None +) -> Dict[str, Tuple[inspect.Parameter, Any]]: """Creates a dictionary mapping CLI keywords to python keywords. Typically the mapping is something like:: @@ -44,14 +46,14 @@ def cli2parameter(f: Callable) -> Dict[str, Tuple[inspect.Parameter, Any]]: signature = inspect.signature(f) for iparam in signature.parameters.values(): annotation = str if iparam.annotation is iparam.empty else iparam.annotation - _, cparam = get_hint_parameter(annotation) + _, cparam = get_hint_parameter(annotation, default_parameter=default_parameter) if cparam.parse is False: continue if iparam.kind in (iparam.POSITIONAL_OR_KEYWORD, iparam.KEYWORD_ONLY): hint = resolve(annotation) - keys = get_names(iparam) + keys = get_names(iparam, default_parameter=default_parameter) for key in keys: mapping[key] = (iparam, True if hint is bool else None) @@ -63,8 +65,8 @@ def cli2parameter(f: Callable) -> Dict[str, Tuple[inspect.Parameter, Any]]: @lru_cache(maxsize=16) -def parameter2cli(f: Callable) -> Dict[inspect.Parameter, List[str]]: - c2p = cli2parameter(f) +def parameter2cli(f: Callable, default_parameter: Optional[Parameter] = None) -> Dict[inspect.Parameter, List[str]]: + c2p = cli2parameter(f, default_parameter=default_parameter) p2c = {} for cli, tup in c2p.items(): @@ -95,8 +97,8 @@ def _cli_kw_to_f_kw(cli_key: str): return cli_key -def _parse_kw_and_flags(f, tokens, mapping): - cli2kw = cli2parameter(f) +def _parse_kw_and_flags(f, tokens, mapping, default_parameter: Optional[Parameter] = None): + cli2kw = cli2parameter(f, default_parameter=default_parameter) kwargs_parameter = next((p for p in inspect.signature(f).parameters.values() if p.kind == p.VAR_KEYWORD), None) if kwargs_parameter: @@ -140,7 +142,7 @@ def _parse_kw_and_flags(f, tokens, mapping): if implicit_value is not None: cli_values.append(implicit_value) else: - consume_count += max(1, token_count(parameter.annotation)[0]) + consume_count += max(1, token_count(parameter.annotation, default_parameter=default_parameter)[0]) try: for j in range(consume_count): @@ -150,7 +152,7 @@ def _parse_kw_and_flags(f, tokens, mapping): skip_next_iterations = consume_count - _, repeatable = token_count(parameter.annotation) + _, repeatable = token_count(parameter.annotation, default_parameter=default_parameter) if parameter is kwargs_parameter: assert kwargs_key is not None if kwargs_key in mapping[parameter] and not repeatable: @@ -167,16 +169,18 @@ def _parse_kw_and_flags(f, tokens, mapping): return unused_tokens -def _parse_pos(f: Callable, tokens: Iterable[str], mapping: Dict) -> List[str]: +def _parse_pos( + f: Callable, tokens: Iterable[str], mapping: Dict, default_parameter: Optional[Parameter] = None +) -> List[str]: tokens = list(tokens) signature = inspect.signature(f) def remaining_parameters(): for parameter in signature.parameters.values(): - _, cparam = get_hint_parameter(parameter.annotation) + _, cparam = get_hint_parameter(parameter.annotation, default_parameter=default_parameter) if cparam.parse is False: continue - _, consume_all = token_count(parameter.annotation) + _, consume_all = token_count(parameter.annotation, default_parameter=default_parameter) if parameter in mapping and not consume_all: continue if parameter.kind is parameter.KEYWORD_ONLY: # pragma: no cover @@ -193,7 +197,7 @@ def remaining_parameters(): tokens = [] break - consume_count, consume_all = token_count(iparam.annotation) + consume_count, consume_all = token_count(iparam.annotation, default_parameter=default_parameter) if consume_all: mapping.setdefault(iparam, []) mapping[iparam] = tokens + mapping[iparam] @@ -211,7 +215,7 @@ def remaining_parameters(): return tokens -def _parse_env(f, mapping): +def _parse_env(f, mapping, default_parameter: Optional[Parameter] = None): """Populate argument defaults from environment variables. In cyclopts, arguments are parsed with the following priority: @@ -225,7 +229,7 @@ def _parse_env(f, mapping): # Don't check environment variables for already-parsed parameters. continue - _, cparam = get_hint_parameter(iparam.annotation) + _, cparam = get_hint_parameter(iparam.annotation, default_parameter=default_parameter) for env_var_name in cparam.env_var: try: env_var_value = os.environ[env_var_name] @@ -241,7 +245,7 @@ def _is_required(parameter: inspect.Parameter) -> bool: return parameter.default is parameter.empty -def _bind(f: Callable, mapping: Dict[inspect.Parameter, Any]): +def _bind(f: Callable, mapping: Dict[inspect.Parameter, Any], default_parameter: Optional[Parameter] = None): """Bind the mapping to the function signature. Better than directly using ``signature.bind`` because this can handle @@ -268,7 +272,7 @@ def f_pos_append(p): # * Parameters before a ``*args`` may have type ``POSITIONAL_OR_KEYWORD``. # * Only args before a ``/`` are ``POSITIONAL_ONLY``. for iparam in signature.parameters.values(): - _, cparam = get_hint_parameter(iparam.annotation) + _, cparam = get_hint_parameter(iparam.annotation, default_parameter=default_parameter) if cparam.parse is False: has_unparsed_parameters |= _is_required(iparam) continue @@ -292,10 +296,10 @@ def f_pos_append(p): return bound -def _convert(mapping: Dict[inspect.Parameter, List[str]]) -> dict: +def _convert(mapping: Dict[inspect.Parameter, List[str]], default_parameter: Optional[Parameter] = None) -> dict: coerced = {} for iparam, parameter_tokens in mapping.items(): - type_, cparam = get_hint_parameter(iparam.annotation) + type_, cparam = get_hint_parameter(iparam.annotation, default_parameter=default_parameter) if cparam.parse is False: continue @@ -334,7 +338,9 @@ def _convert(mapping: Dict[inspect.Parameter, List[str]]) -> dict: return coerced -def create_bound_arguments(f: Callable, tokens: List[str]) -> Tuple[inspect.BoundArguments, List[str]]: +def create_bound_arguments( + f: Callable, tokens: List[str], default_parameter: Optional[Parameter] = None +) -> Tuple[inspect.BoundArguments, List[str]]: """Parse and coerce CLI tokens to match a function's signature. Parameters @@ -343,6 +349,8 @@ def create_bound_arguments(f: Callable, tokens: List[str]) -> Tuple[inspect.Boun A function with (possibly) annotated parameters. tokens: List[str] CLI tokens to parse and coerce to match ``f``'s signature. + default_parameter: Optional[Parameter] + Default Parameter configuration. Returns ------- @@ -355,19 +363,19 @@ def create_bound_arguments(f: Callable, tokens: List[str]) -> Tuple[inspect.Boun # Note: mapping is updated inplace mapping: Dict[inspect.Parameter, List[str]] = {} - validate_command(f) + validate_command(f, default_parameter=default_parameter) c2p, p2c = None, None unused_tokens = [] try: - c2p = cli2parameter(f) - p2c = parameter2cli(f) - unused_tokens = _parse_kw_and_flags(f, tokens, mapping) - unused_tokens = _parse_pos(f, unused_tokens, mapping) - _parse_env(f, mapping) - coerced = _convert(mapping) - bound = _bind(f, coerced) + c2p = cli2parameter(f, default_parameter=default_parameter) + p2c = parameter2cli(f, default_parameter=default_parameter) + unused_tokens = _parse_kw_and_flags(f, tokens, mapping, default_parameter=default_parameter) + unused_tokens = _parse_pos(f, unused_tokens, mapping, default_parameter=default_parameter) + _parse_env(f, mapping, default_parameter=default_parameter) + coerced = _convert(mapping, default_parameter=default_parameter) + bound = _bind(f, coerced, default_parameter=default_parameter) except CycloptsError as e: e.target = f e.root_input_tokens = tokens diff --git a/cyclopts/coercion.py b/cyclopts/coercion.py index b79c0e04..25ba120c 100644 --- a/cyclopts/coercion.py +++ b/cyclopts/coercion.py @@ -2,12 +2,15 @@ import inspect from enum import Enum from inspect import isclass -from typing import Any, Iterable, List, Literal, Optional, Set, Tuple, Type, Union, get_args, get_origin +from typing import TYPE_CHECKING, Any, Iterable, List, Literal, Optional, Set, Tuple, Type, Union, get_args, get_origin from typing_extensions import Annotated from cyclopts.exceptions import CoercionError +if TYPE_CHECKING: + from cyclopts.parameter import Parameter + # from types import NoneType is available >=3.10 NoneType = type(None) AnnotatedType = type(Annotated[int, 0]) @@ -57,22 +60,26 @@ def _bytearray(s: str) -> bytearray: } -def _convert(type_, element): +def _convert(type_, element, default_parameter=None): origin_type = get_origin(type_) inner_types = [resolve(x) for x in get_args(type_)] if type_ in _implicit_iterable_type_mapping: - return _convert(_implicit_iterable_type_mapping[type_], element) + return _convert(_implicit_iterable_type_mapping[type_], element, default_parameter=default_parameter) elif origin_type is collections.abc.Iterable: assert len(inner_types) == 1 - return _convert(List[inner_types[0]], element) # pyright: ignore[reportGeneralTypeIssues] + return _convert( + List[inner_types[0]], # pyright: ignore[reportGeneralTypeIssues] + element, + default_parameter=default_parameter, + ) elif origin_type is Union: for t in inner_types: if t is NoneType: continue try: - return _convert(t, element) + return _convert(t, element, default_parameter=default_parameter) except Exception: pass else: @@ -80,7 +87,7 @@ def _convert(type_, element): elif origin_type is Literal: for choice in get_args(type_): try: - res = _convert(type(choice), (element)) + res = _convert(type(choice), (element), default_parameter=default_parameter) except Exception: continue if res == choice: @@ -94,14 +101,16 @@ def _convert(type_, element): return member raise CoercionError(input_value=element, target_type=type_) elif origin_type in _iterable_types: - count, _ = token_count(inner_types[0]) + count, _ = token_count(inner_types[0], default_parameter=default_parameter) if count > 1: gen = zip(*[iter(element)] * count) else: gen = element - return origin_type(_convert(inner_types[0], e) for e in gen) # pyright: ignore[reportOptionalCall] + return origin_type( + _convert(inner_types[0], e, default_parameter=default_parameter) for e in gen + ) # pyright: ignore[reportOptionalCall] elif origin_type is tuple: - return tuple(_convert(t, e) for t, e in zip(inner_types, element)) + return tuple(_convert(t, e, default_parameter=default_parameter) for t, e in zip(inner_types, element)) else: try: return _converters.get(type_, type_)(element) @@ -154,7 +163,7 @@ def resolve_annotated(type_: Type) -> Type: return type_ -def coerce(type_: Type, *args: str): +def coerce(type_: Type, *args: str, default_parameter=None): """Coerce variables into a specified type. Internally used to coercing string CLI tokens into python builtin types. @@ -195,16 +204,18 @@ def coerce(type_: Type, *args: str): raise ValueError( f"Number of arguments does not match the tuple structure: expected {len(inner_types)} but got {len(args)}" ) - return tuple(_convert(inner_type, arg) for inner_type, arg in zip(inner_types, args)) + return tuple( + _convert(inner_type, arg, default_parameter=default_parameter) for inner_type, arg in zip(inner_types, args) + ) elif (origin_type or type_) in _iterable_types or origin_type is collections.abc.Iterable: - return _convert(type_, args) + return _convert(type_, args, default_parameter=default_parameter) elif len(args) == 1: - return _convert(type_, args[0]) + return _convert(type_, args[0], default_parameter=default_parameter) else: - return [_convert(type_, item) for item in args] + return [_convert(type_, item, default_parameter=default_parameter) for item in args] -def token_count(type_: Type) -> Tuple[int, bool]: +def token_count(type_: Type, default_parameter: Optional["Parameter"] = None) -> Tuple[int, bool]: """The number of tokens after a keyword the parameter should consume. Returns @@ -213,13 +224,15 @@ def token_count(type_: Type) -> Tuple[int, bool]: Number of tokens that constitute a single element. bool If this is ``True`` and positional, consume all remaining tokens. + default_parameter: Optional[Parameter] + Default Parameter configuration. """ from cyclopts.parameter import get_hint_parameter if type_ is inspect.Parameter.empty: return 1, False - type_, param = get_hint_parameter(type_) + type_, param = get_hint_parameter(type_, default_parameter=default_parameter) if param.token_count is not None: return abs(param.token_count), param.token_count < 0 @@ -233,7 +246,7 @@ def token_count(type_: Type) -> Tuple[int, bool]: elif type_ in _iterable_types or (origin_type in _iterable_types and len(get_args(type_)) == 0): return 1, True elif (origin_type in _iterable_types or origin_type is collections.abc.Iterable) and len(get_args(type_)): - return token_count(get_args(type_)[0])[0], True + return token_count(get_args(type_)[0], default_parameter=default_parameter)[0], True else: return 1, False diff --git a/cyclopts/core.py b/cyclopts/core.py index 70b84be2..f7730cbe 100644 --- a/cyclopts/core.py +++ b/cyclopts/core.py @@ -124,7 +124,7 @@ class App: """ default_command: Optional[Callable] = field(default=None, converter=_validate_default_command) - default_parameter: Parameter = field(factory=Parameter) + _default_parameter: Optional[Parameter] = field(default=None, alias="default_parameter") _name: Optional[str] = field(default=None, alias="name") @@ -150,6 +150,20 @@ class App: ########### # Methods # ########### + @property + def default_parameter(self) -> Parameter: + """Parameter value defaults for all Annotated Parameters. + + Usually, an :class:`App` has at most one parent. + + TODO: explain resolution-order. + """ + return Parameter.combine(*(x.default_parameter for x in reversed(self._parents)), self._default_parameter) + + @default_parameter.setter + def default_parameter(self, value: Optional[Parameter]): + self._default_parameter = value + @property def name(self) -> str: """Application name. Dynamically derived if not previously set.""" @@ -261,7 +275,7 @@ def command( if kwargs: raise ValueError("Cannot supplied additional configuration when registering a sub-App.") else: - validate_command(obj) + validate_command(obj, default_parameter=self.default_parameter) kwargs.setdefault("help_flags", []) kwargs.setdefault("version_flags", []) app = App(default_command=obj, **kwargs) @@ -293,7 +307,7 @@ def default(self, obj=None): if self.default_command is not None: raise CommandCollisionError(f"Default command previously set to {self.default_command}.") - validate_command(obj) + validate_command(obj, default_parameter=self.default_parameter) self.default_command = obj return obj @@ -334,7 +348,9 @@ def parse_known_args( if self.default_command: command = self.default_command - bound, unused_tokens = create_bound_arguments(command, unused_tokens) + bound, unused_tokens = create_bound_arguments( + command, unused_tokens, default_parameter=self.default_parameter + ) return command, bound, unused_tokens else: if unused_tokens: @@ -510,6 +526,7 @@ def walk_apps(): subapp, subapp.help_title_parameters, show_special=show_special, + default_parameter=self.default_parameter, ) ) diff --git a/cyclopts/help.py b/cyclopts/help.py index a2ff2d3a..71adeea2 100644 --- a/cyclopts/help.py +++ b/cyclopts/help.py @@ -147,7 +147,7 @@ def _get_choices(type_: Type) -> str: return choices -def format_parameters(app, title, show_special=True): +def format_parameters(app, title, show_special=True, default_parameter: Optional[Parameter] = None): panel, table = create_panel_table(title=title) has_required, has_short = False, False @@ -156,7 +156,7 @@ def format_parameters(app, title, show_special=True): if app.default_command: help_lookup = parameter2docstring(app.default_command) for parameter in inspect.signature(app.default_command).parameters.values(): - _, param = get_hint_parameter(parameter.annotation) + _, param = get_hint_parameter(parameter.annotation, default_parameter=default_parameter) if param.parse is False or not param.show: continue parameters.append(parameter) @@ -209,10 +209,10 @@ def is_short(s): has_required = any(is_required(p) for p in parameters) for parameter in parameters: - type_, param = get_hint_parameter(parameter.annotation) + type_, param = get_hint_parameter(parameter.annotation, default_parameter=default_parameter) if not param.show: continue - has_short = any(is_short(x) for x in get_names(parameter)) + has_short = any(is_short(x) for x in get_names(parameter, default_parameter=default_parameter)) if has_short: break @@ -224,9 +224,9 @@ def is_short(s): table.add_column(justify="left") # For main help text. for parameter in parameters: - type_, param = get_hint_parameter(parameter.annotation) + type_, param = get_hint_parameter(parameter.annotation, default_parameter=default_parameter) - options = get_names(parameter) + options = get_names(parameter, default_parameter=default_parameter) options.extend(param.get_negatives(type_, *options)) if parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD): diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index 06f166ec..c5e47e44 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -1,5 +1,5 @@ import inspect -from typing import Iterable, List, Optional, Tuple, Type, Union, get_args, get_origin +from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Type, Union, get_args, get_origin from attrs import field, frozen @@ -21,25 +21,6 @@ def _token_count_validator(instance, attribute, value): raise ValueError('Must specify a "converter" if setting "token_count".') -def validate_command(f): - """Validate if a function abides by Cyclopts's rules. - - Raises - ------ - ValueError - Function has naming or parameter/signature inconsistencies. - """ - signature = inspect.signature(f) - for iparam in signature.parameters.values(): - _ = get_origin_and_validate(iparam.annotation) - type_, cparam = get_hint_parameter(iparam.annotation) - if cparam.parse is False and iparam.kind is not iparam.KEYWORD_ONLY: - raise ValueError("Parameter.parse=False must be used with a KEYWORD_ONLY function parameter.") - if get_origin(type_) is tuple: - if ... in get_args(type_): - raise ValueError("Cannot use a variable-length tuple.") - - @frozen class Parameter: """Cyclopts configuration for individual function parameters.""" @@ -211,10 +192,10 @@ def __repr__(self): def combine(cls, *parameters: Optional["Parameter"]) -> "Parameter": """Returns a new Parameter with values of ``new_parameter`` overriding ``self``. - Earlier parameters have higher attribute priority. + ``*parameters`` ordered from least-to-highest attribute priority. """ kwargs = {} - for parameter in reversed(parameters): + for parameter in parameters: if parameter is None: continue for a in parameter.__attrs_attrs__: # pyright: ignore[reportGeneralTypeIssues] @@ -227,9 +208,28 @@ def combine(cls, *parameters: Optional["Parameter"]) -> "Parameter": return cls(**kwargs) -def get_names(parameter: inspect.Parameter) -> List[str]: +def validate_command(f, default_parameter: Optional[Parameter] = None): + """Validate if a function abides by Cyclopts's rules. + + Raises + ------ + ValueError + Function has naming or parameter/signature inconsistencies. + """ + signature = inspect.signature(f) + for iparam in signature.parameters.values(): + _ = get_origin_and_validate(iparam.annotation) + type_, cparam = get_hint_parameter(iparam.annotation, default_parameter=default_parameter) + if cparam.parse is False and iparam.kind is not iparam.KEYWORD_ONLY: + raise ValueError("Parameter.parse=False must be used with a KEYWORD_ONLY function parameter.") + if get_origin(type_) is tuple: + if ... in get_args(type_): + raise ValueError("Cannot use a variable-length tuple.") + + +def get_names(parameter: inspect.Parameter, default_parameter: Optional[Parameter] = None) -> List[str]: """Derive the CLI name for an ``inspect.Parameter``.""" - _, param = get_hint_parameter(parameter.annotation) + _, param = get_hint_parameter(parameter.annotation, default_parameter=default_parameter) if param.name: names = list(param.name) else: @@ -242,7 +242,7 @@ def get_names(parameter: inspect.Parameter) -> List[str]: return names -def get_hint_parameter(type_: Type) -> Tuple[Type, Parameter]: +def get_hint_parameter(type_: Type, default_parameter: Optional[Parameter] = None) -> Tuple[Type, Parameter]: """Get the type hint and Cyclopts :class:`Parameter` from a type-hint. If a ``cyclopts.Parameter`` is not found, a default Parameter is returned. @@ -259,10 +259,10 @@ def get_hint_parameter(type_: Type) -> Tuple[Type, Parameter]: if len(cyclopts_parameters) > 2: raise MultipleParameterAnnotationError elif len(cyclopts_parameters) == 1: - cyclopts_parameter = cyclopts_parameters[0] + cyclopts_parameter = Parameter.combine(cyclopts_parameters[0], default_parameter) else: - cyclopts_parameter = Parameter() + cyclopts_parameter = Parameter.combine(default_parameter) else: - cyclopts_parameter = Parameter() + cyclopts_parameter = Parameter.combine(default_parameter) return resolve(type_), cyclopts_parameter diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 2ec656cf..da267e5f 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -70,4 +70,4 @@ def test_parameter_combine_priority(): p2 = Parameter(negative="--bar") p_combined = Parameter.combine(p1, p2) - assert p_combined.negative == ("--foo",) + assert p_combined.negative == ("--bar",) From 3f6bd7ec38a1953fafa8e71d61a1aff988629ffc Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 21:23:15 -0800 Subject: [PATCH 10/20] test default_parameter actual usage --- cyclopts/core.py | 8 ++++++-- tests/test_bind_boolean_flag.py | 20 ++++++++++++++++++++ tests/test_subapp.py | 18 +++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/cyclopts/core.py b/cyclopts/core.py index f7730cbe..aeee61bd 100644 --- a/cyclopts/core.py +++ b/cyclopts/core.py @@ -154,9 +154,13 @@ class App: def default_parameter(self) -> Parameter: """Parameter value defaults for all Annotated Parameters. - Usually, an :class:`App` has at most one parent. + The ``default_parameter`` is treated as a hierarchical configuration, inheriting from parenting ``App``s. - TODO: explain resolution-order. + Usually, an :class:`App` has at most one parent. + In the event of multiple parents, they are evaluated in reverse-registered order, + where each ``default_parameter`` attributes overwrites the previous. + I.e. the first registered parents have highest-priority of the parents. + The specified ``default_parameter`` for this ``App`` object has highest priority. """ return Parameter.combine(*(x.default_parameter for x in reversed(self._parents)), self._default_parameter) diff --git a/tests/test_bind_boolean_flag.py b/tests/test_bind_boolean_flag.py index c3fb1ca9..3c545e27 100644 --- a/tests/test_bind_boolean_flag.py +++ b/tests/test_bind_boolean_flag.py @@ -26,6 +26,26 @@ def foo(my_flag: bool = True): assert actual_bind == expected_bind +def test_boolean_flag_app_parameter_default(app): + app.default_parameter = Parameter(negative="") + + @app.default + def foo(my_flag: bool = True): + pass + + signature = inspect.signature(foo) + expected_bind = signature.bind(True) + + # Normal positive flag should still work. + actual_command, actual_bind = app.parse_args("--my-flag") + assert actual_command == foo + assert actual_bind == expected_bind + + # The negative flag should be disabled. + with pytest.raises(CoercionError): + app.parse_args("--no-my-flag", exit_on_error=False) + + @pytest.mark.parametrize( "cmd_str,expected", [ diff --git a/tests/test_subapp.py b/tests/test_subapp.py index 77293d5c..48dcbbe1 100644 --- a/tests/test_subapp.py +++ b/tests/test_subapp.py @@ -1,6 +1,6 @@ import pytest -from cyclopts import App +from cyclopts import App, Parameter def test_subapp_basic(app): @@ -43,3 +43,19 @@ def test_subapp_registering_cannot_have_other_kwargs(app): def test_subapp_cannot_be_default(app): with pytest.raises(TypeError): app.default(App(name="foo")) + + +def test_subapp_default_parameter_resolution(): + parent_app_1 = App(default_parameter=Parameter(show_default=True)) + + sub_app = App(name="foo") + parent_app_1.command(sub_app) + + # Standard Single Resolution + assert sub_app.default_parameter == Parameter(show_default=True) + + parent_app_2 = App(default_parameter=Parameter(show_default=False, negative=())) + parent_app_2.command(sub_app) + + # 2-Parent Resolution + assert sub_app.default_parameter == Parameter(show_default=True, negative=()) From a905459d3b22ef7abefba7b239b1290832e5881c Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 21:38:57 -0800 Subject: [PATCH 11/20] begin default_parameter documentation --- docs/source/default_parameter.rst | 37 +++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + 2 files changed, 38 insertions(+) create mode 100644 docs/source/default_parameter.rst diff --git a/docs/source/default_parameter.rst b/docs/source/default_parameter.rst new file mode 100644 index 00000000..355dee65 --- /dev/null +++ b/docs/source/default_parameter.rst @@ -0,0 +1,37 @@ +================= +Default Parameter +================= +The default values of :class:`Parameter` can be configured via the ``default_parameter`` field of :class:`App`. + +For example, to disable the ``negative`` flag feature: + +.. code-block:: python + + from cyclopts import App, Parameter + + app = App(default_parameter=Parameter(negative=())) + + + @app.command + def foo(flag: bool): + pass + + + app() + +We can see that ``--no-flag`` is no longer provided: + +.. code-block:: + + $ my-script foo --help + Usage: my-script foo [ARGS] [OPTIONS] + + ╭─ Parameters ──────────────────────────────────────────────────╮ + │ * FLAG,--flag [required] │ + ╰───────────────────────────────────────────────────────────────╯ + +When resolving what the ``default_parameter`` values should be, explicitly set values from higher priority sources override lower-priority sources: + +1. *Highest Priority:* Parameter-annotated command function signature ``Annotated[..., Parameter()]``. +2. :class:`App` ``default_parameter`` that registered the command. +3. *Lowest Priority:* :class:`App` parenting app(s)'s ``default_parameter``. diff --git a/docs/source/index.rst b/docs/source/index.rst index 308f09ed..7c4bf977 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,6 +13,7 @@ Cyclopts getting_started.rst commands.rst parameters.rst + default_parameter.rst validators.rst help.rst version.rst From b95a0774fa76d06783312c038aa76b4e4af867d4 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 18 Dec 2023 22:44:01 -0800 Subject: [PATCH 12/20] fix rebase. --- cyclopts/bind.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cyclopts/bind.py b/cyclopts/bind.py index 600ec98c..06e24076 100644 --- a/cyclopts/bind.py +++ b/cyclopts/bind.py @@ -77,14 +77,14 @@ def parameter2cli(f: Callable, default_parameter: Optional[Parameter] = None) -> signature = inspect.signature(f) for iparam in signature.parameters.values(): annotation = str if iparam.annotation is iparam.empty else iparam.annotation - _, cparam = get_hint_parameter(annotation) + _, cparam = get_hint_parameter(annotation, default_parameter=default_parameter) - if not cparam.parse: + if cparam.parse is False: continue # POSITIONAL_OR_KEYWORD and KEYWORD_ONLY already handled in cli2parameter if iparam.kind in (iparam.POSITIONAL_ONLY, iparam.VAR_KEYWORD, iparam.VAR_POSITIONAL): - p2c[iparam] = get_names(iparam) + p2c[iparam] = get_names(iparam, default_parameter=default_parameter) return p2c From 01d4c0c5c00e24327464dc013fb080a5da3b63ba Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Tue, 19 Dec 2023 20:34:21 -0800 Subject: [PATCH 13/20] class decorator record_attrs_init_kwargs --- cyclopts/parameter.py | 18 ++++++++++-------- cyclopts/utils.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 cyclopts/utils.py diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index c5e47e44..f759d3a5 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -1,5 +1,5 @@ import inspect -from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Type, Union, get_args, get_origin +from typing import Iterable, List, Optional, Tuple, Type, Union, get_args, get_origin from attrs import field, frozen @@ -14,6 +14,7 @@ ) from cyclopts.exceptions import MultipleParameterAnnotationError from cyclopts.protocols import Converter, Validator +from cyclopts.utils import record_init_kwargs def _token_count_validator(instance, attribute, value): @@ -21,6 +22,7 @@ def _token_count_validator(instance, attribute, value): raise ValueError('Must specify a "converter" if setting "token_count".') +@record_init_kwargs("_provided_args") @frozen class Parameter: """Cyclopts configuration for individual function parameters.""" @@ -124,6 +126,9 @@ def validator(type_, value: Any) -> None: If no environment variable is set, Cyclopts will fallback to the function-signature default. """ + # Populated by the record_attrs_init_args decorator. + _provided_args: Tuple[str] = field(default=(), init=False, eq=False) + @property def name(self): return str_to_tuple_converter(self._name) @@ -181,9 +186,9 @@ def __repr__(self): """Only shows non-default values.""" content = ", ".join( [ - f"{a.alias}={v!r}" + f"{a.alias}={getattr(self, a.name)!r}" for a in self.__attrs_attrs__ # pyright: ignore[reportGeneralTypeIssues] - if (v := getattr(self, a.name)) != a.default + if a.alias in self._provided_args ] ) return f"{type(self).__name__}({content})" @@ -199,11 +204,8 @@ def combine(cls, *parameters: Optional["Parameter"]) -> "Parameter": if parameter is None: continue for a in parameter.__attrs_attrs__: # pyright: ignore[reportGeneralTypeIssues] - if not a.init: - continue - v = getattr(parameter, a.name) - if v != a.default: - kwargs[a.alias] = v + if a.init and a.alias in parameter._provided_args: + kwargs[a.alias] = getattr(parameter, a.name) return cls(**kwargs) diff --git a/cyclopts/utils.py b/cyclopts/utils.py new file mode 100644 index 00000000..359211b6 --- /dev/null +++ b/cyclopts/utils.py @@ -0,0 +1,19 @@ +import functools + + +def record_init_kwargs(target: str): + """Class decorator that records init argument names as a tuple to ``target``.""" + + def decorator(cls): + original_init = cls.__init__ + + @functools.wraps(original_init) + def new_init(self, **kwargs): + original_init(self, **kwargs) + # Circumvent frozen protection. + object.__setattr__(self, target, tuple(kwargs.keys())) + + cls.__init__ = new_init + return cls + + return decorator From d1ee4e1d1b28d55a91e4bfba8fb03778d157f039 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Tue, 19 Dec 2023 20:41:10 -0800 Subject: [PATCH 14/20] Parameter=None override test --- tests/test_parameter.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_parameter.py b/tests/test_parameter.py index da267e5f..21c223f6 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -71,3 +71,11 @@ def test_parameter_combine_priority(): p_combined = Parameter.combine(p1, p2) assert p_combined.negative == ("--bar",) + + +def test_parameter_combine_priority_none(): + p1 = Parameter(negative="--foo") + p2 = Parameter(negative=None) + p_combined = Parameter.combine(p1, p2) + + assert p_combined.negative is None From 0891eb9416ad873ef5129d9a73b9c42a685ec4c9 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Tue, 19 Dec 2023 21:32:13 -0800 Subject: [PATCH 15/20] move over most of Parameter docs to api.rst to gain greater control over the attribute alias displayed --- cyclopts/parameter.py | 73 +-------------------------------- docs/source/api.rst | 94 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 73 deletions(-) diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index f759d3a5..d4ba75ce 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -27,104 +27,35 @@ def _token_count_validator(instance, attribute, value): class Parameter: """Cyclopts configuration for individual function parameters.""" + # All documentation has been moved to ``docs/api.rst`` for greater control with attrs. + _name: Union[None, str, Iterable[str]] = field( default=None, converter=optional_str_to_tuple_converter, alias="name" ) - """ - Name(s) to expose to the CLI. - Defaults to the python parameter's name, prepended with ``--``. - Single-character options should start with ``-``. - Full-name options should start with ``--``. - """ converter: Optional[Converter] = field(default=None) - """ - A function that converts string token(s) into an object. The converter must have signature: - - .. code-block:: python - - def converter(type_, *args) -> Any: - pass - - where ``Any`` is the intended type of the annotated variable. - If not provided, defaults to :ref:`Cyclopts's internal coercion engine `. - """ validator: Optional[Validator] = field(default=None) - """ - A function that validates data returned by the ``converter``. - - .. code-block:: python - - def validator(type_, value: Any) -> None: - pass # Raise a TypeError, ValueError, or AssertionError here if data is invalid. - """ negative: Union[None, str, Iterable[str]] = field(default=None, converter=optional_str_to_tuple_converter) - """ - Name(s) for empty iterables or false boolean flags. - For booleans, defaults to ``--no-{name}``. - For iterables, defaults to ``--empty-{name}``. - Set to an empty list to disable this feature. - """ token_count: Optional[int] = field(default=None, validator=_token_count_validator) - """ - Number of CLI tokens this parameter consumes. - For advanced usage when the annotated parameter is a custom class that consumes more - (or less) than the standard single token. - If specified, a custom ``converter`` **must** also be specified. - Defaults to autodetecting based on type annotation. - """ parse: Optional[bool] = field(default=None) - """ - Attempt to use this parameter while parsing. - Intended only for advance usage with custom command invocation. - Annotated parameter **must** be keyword-only. - Defaults to ``True``. - """ _show: Optional[bool] = field(default=None, alias="show") - """ - Show this parameter in the help screen. - If ``False``, state of all other ``show_*`` flags are ignored. - Defaults to ``parse`` value (``True``). - """ show_default: Optional[bool] = field(default=None) - """ - If a variable has a default, display the default in the help page. - - Defaults to ``None``, which is similar to ``True``, but will not display the default if it's ``None``. - """ show_choices: Optional[bool] = field(default=None) - """ - If a variable has a set of choices (``Literal``, ``Enum``), display the default in the help page. - Defaults to ``True``. - """ help: Optional[str] = field(default=None) - """ - Help string to be displayed in the help page. - If not specified, defaults to the docstring. - """ show_env_var: Optional[bool] = field(default=None) - """ - If a variable has ``env_var`` set, display the variable name in the help page. - Defaults to ``True``. - """ _env_var: Union[None, str, Iterable[str]] = field( default=None, converter=optional_str_to_tuple_converter, alias="env_var" ) - """ - Fallback to environment variable(s) if CLI value not provided. - If multiple environment variables are given, the left-most environment variable with a set value will be used. - If no environment variable is set, Cyclopts will fallback to the function-signature default. - """ # Populated by the record_attrs_init_args decorator. _provided_args: Tuple[str] = field(default=(), init=False, eq=False) diff --git a/docs/source/api.rst b/docs/source/api.rst index 39208caf..49d5736a 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -9,8 +9,98 @@ API :special-members: __call__, __getitem__ .. autoclass:: cyclopts.Parameter - :members: - :exclude-members: get_negatives + + Cyclopts configuration for individual function parameters. + + .. attribute:: name + :type: Union[None, str, Iterable[str]] + + Name(s) to expose to the CLI. + Defaults to the python parameter's name, prepended with ``--``. + Single-character options should start with ``-``. + Full-name options should start with ``--``. + + .. attribute:: converter + :type: Optional[Converter] + + A function that converts string token(s) into an object. The converter must have signature: + + .. code-block:: python + + def converter(type_, *args) -> Any: + pass + + If not provided, defaults to Cyclopts's internal coercion engine. + + .. attribute:: validator + :type: Optional[Validator] + + A function that validates data returned by the ``converter``. + + .. code-block:: python + + def validator(type_, value: Any) -> None: + pass # Raise a TypeError, ValueError, or AssertionError here if data is invalid. + + .. attribute:: negative + :type: Union[None, str, Iterable[str]] + + Name(s) for empty iterables or false boolean flags. + For booleans, defaults to ``--no-{name}``. + For iterables, defaults to ``--empty-{name}``. + Set to an empty list to disable this feature. + + .. attribute:: token_count + :type: Optional[int] + + Number of CLI tokens this parameter consumes. + If specified, a custom ``converter`` **must** also be specified. + Defaults to autodetecting based on type annotation. + + .. attribute:: parse + :type: Optional[bool] + + Attempt to use this parameter while parsing. + Annotated parameter **must** be keyword-only. + Defaults to ``True``. + + .. attribute:: show + :type: Optional[bool] + + Show this parameter in the help screen. + If ``False``, state of all other ``show_*`` flags are ignored. + Defaults to ``parse`` value (``True``). + + .. attribute:: show_default + :type: Optional[bool] + + If a variable has a default, display the default in the help page. + Defaults to ``None``, similar to ``True``, but will not display the default if it's ``None``. + + .. attribute:: show_choices + :type: Optional[bool] + + If a variable has a set of choices, display the choices in the help page. + Defaults to ``True``. + + .. attribute:: help + :type: Optional[str] + + Help string to be displayed in the help page. + If not specified, defaults to the docstring. + + .. attribute:: show_env_var + :type: Optional[bool] + + If a variable has ``env_var`` set, display the variable name in the help page. + Defaults to ``True``. + + .. attribute:: env_var + :type: Union[None, str, Iterable[str]] + + Fallback to environment variable(s) if CLI value not provided. + If multiple environment variables are given, the left-most environment variable with a set value will be used. + If no environment variable is set, Cyclopts will fallback to the function-signature default. .. autofunction:: cyclopts.coerce From a69c1b1106be6df429af0fc65c55606462221dd3 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Tue, 19 Dec 2023 21:33:34 -0800 Subject: [PATCH 16/20] remove Method generated by attrs from docs --- docs/source/conf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 4aa65546..42e9fe9d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -200,5 +200,10 @@ def simplify_exception_signature( return ("", None) # Return an empty signature and no return annotation +def remove_attrs_methods(app, what, name, obj, options, lines): + lines[:] = [line for line in lines if not line.startswith("Method generated by attrs for")] + + def setup(app: Sphinx): app.connect("autodoc-process-signature", simplify_exception_signature) + app.connect("autodoc-process-docstring", remove_attrs_methods) From b4062682e7ebec1efb832712285d8e7c54b891c4 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Tue, 19 Dec 2023 21:47:58 -0800 Subject: [PATCH 17/20] Fix parameter dependency resolution order. Remove MultipleParameterAnnotationError. --- cyclopts/__init__.py | 2 -- cyclopts/exceptions.py | 14 -------------- cyclopts/parameter.py | 18 ++++++++---------- docs/source/api.rst | 2 -- docs/source/default_parameter.rst | 16 +++++++++++++--- tests/test_bind_boolean_flag.py | 20 ++++++++++++++++++++ 6 files changed, 41 insertions(+), 31 deletions(-) diff --git a/cyclopts/__init__.py b/cyclopts/__init__.py index 8e698f86..3eece053 100644 --- a/cyclopts/__init__.py +++ b/cyclopts/__init__.py @@ -11,7 +11,6 @@ "DocstringError", "InvalidCommandError", "MissingArgumentError", - "MultipleParameterAnnotationError", "Parameter", "UnusedCliTokensError", "ValidationError", @@ -31,7 +30,6 @@ DocstringError, InvalidCommandError, MissingArgumentError, - MultipleParameterAnnotationError, UnusedCliTokensError, ValidationError, ) diff --git a/cyclopts/exceptions.py b/cyclopts/exceptions.py index 1a65ee3e..3e947432 100644 --- a/cyclopts/exceptions.py +++ b/cyclopts/exceptions.py @@ -18,7 +18,6 @@ "DocstringError", "InvalidCommandError", "MissingArgumentError", - "MultipleParameterAnnotationError", "UnusedCliTokensError", "ValidationError", ] @@ -35,19 +34,6 @@ class CommandCollisionError(Exception): # rather than a runtime error. -class MultipleParameterAnnotationError(Exception): - """Multiple ``cyclopts.Parameter`` objects found in type annotation. - - For example:: - - def foo(a: Annotated[int, Parameter(), Parameter()]): - pass - """ - - # This doesn't derive from CycloptsError since this is a developer error - # rather than a runtime error. - - class DocstringError(Exception): """The docstring either has a syntax error, or inconsistency with the function signature.""" diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index d4ba75ce..a605c7bb 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -12,7 +12,6 @@ resolve_optional, str_to_tuple_converter, ) -from cyclopts.exceptions import MultipleParameterAnnotationError from cyclopts.protocols import Converter, Validator from cyclopts.utils import record_init_kwargs @@ -126,9 +125,13 @@ def __repr__(self): @classmethod def combine(cls, *parameters: Optional["Parameter"]) -> "Parameter": - """Returns a new Parameter with values of ``new_parameter`` overriding ``self``. + """Returns a new Parameter with values of ``new_parameters`` overriding ``self``. - ``*parameters`` ordered from least-to-highest attribute priority. + Parameters + ---------- + `*parameters`: Optional[Parameter] + Parameters who's attributes override ``self`` attributes. + Ordered from least-to-highest attribute priority. """ kwargs = {} for parameter in parameters: @@ -189,13 +192,8 @@ def get_hint_parameter(type_: Type, default_parameter: Optional[Parameter] = Non annotations = type_.__metadata__ # pyright: ignore[reportGeneralTypeIssues] type_ = get_args(type_)[0] cyclopts_parameters = [x for x in annotations if isinstance(x, Parameter)] - if len(cyclopts_parameters) > 2: - raise MultipleParameterAnnotationError - elif len(cyclopts_parameters) == 1: - cyclopts_parameter = Parameter.combine(cyclopts_parameters[0], default_parameter) - else: - cyclopts_parameter = Parameter.combine(default_parameter) else: - cyclopts_parameter = Parameter.combine(default_parameter) + cyclopts_parameters = [] + cyclopts_parameter = Parameter.combine(default_parameter, *cyclopts_parameters) return resolve(type_), cyclopts_parameter diff --git a/docs/source/api.rst b/docs/source/api.rst index 49d5736a..0abcb81a 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -147,5 +147,3 @@ Exceptions :members: .. autoexception:: cyclopts.CommandCollisionError - -.. autoexception:: cyclopts.MultipleParameterAnnotationError diff --git a/docs/source/default_parameter.rst b/docs/source/default_parameter.rst index 355dee65..24a1e286 100644 --- a/docs/source/default_parameter.rst +++ b/docs/source/default_parameter.rst @@ -3,7 +3,7 @@ Default Parameter ================= The default values of :class:`Parameter` can be configured via the ``default_parameter`` field of :class:`App`. -For example, to disable the ``negative`` flag feature: +For example, to disable the ``negative`` flag feature across your entire app: .. code-block:: python @@ -13,7 +13,7 @@ For example, to disable the ``negative`` flag feature: @app.command - def foo(flag: bool): + def foo(*, flag: bool): pass @@ -27,9 +27,19 @@ We can see that ``--no-flag`` is no longer provided: Usage: my-script foo [ARGS] [OPTIONS] ╭─ Parameters ──────────────────────────────────────────────────╮ - │ * FLAG,--flag [required] │ + │ * --flag [required] │ ╰───────────────────────────────────────────────────────────────╯ +However, if we explicitly set ``negative`` in the function signature, it works as expected: + + +.. code-block:: + + @app.command + def foo(*, flag: Annotated[bool, Parameter(negative="--no-flag")]): + pass + + When resolving what the ``default_parameter`` values should be, explicitly set values from higher priority sources override lower-priority sources: 1. *Highest Priority:* Parameter-annotated command function signature ``Annotated[..., Parameter()]``. diff --git a/tests/test_bind_boolean_flag.py b/tests/test_bind_boolean_flag.py index 3c545e27..6db057c1 100644 --- a/tests/test_bind_boolean_flag.py +++ b/tests/test_bind_boolean_flag.py @@ -46,6 +46,26 @@ def foo(my_flag: bool = True): app.parse_args("--no-my-flag", exit_on_error=False) +def test_boolean_flag_app_parameter_default_annotated_override(app): + app.default_parameter = Parameter(negative="") + + @app.default + def foo(my_flag: Annotated[bool, Parameter(negative="--NO-flag")] = True): + pass + + signature = inspect.signature(foo) + + expected_bind = signature.bind(True) + actual_command, actual_bind = app.parse_args("--my-flag") + assert actual_command == foo + assert actual_bind == expected_bind + + expected_bind = signature.bind(False) + actual_command, actual_bind = app.parse_args("--NO-flag") + assert actual_command == foo + assert actual_bind == expected_bind + + @pytest.mark.parametrize( "cmd_str,expected", [ From 595ee40f9b39fb1466658859de70749a2bfb4cb7 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Tue, 19 Dec 2023 22:04:39 -0800 Subject: [PATCH 18/20] Added a test for nested annotations-parameter resolution --- tests/test_bind_boolean_flag.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_bind_boolean_flag.py b/tests/test_bind_boolean_flag.py index 6db057c1..192dd00d 100644 --- a/tests/test_bind_boolean_flag.py +++ b/tests/test_bind_boolean_flag.py @@ -66,6 +66,26 @@ def foo(my_flag: Annotated[bool, Parameter(negative="--NO-flag")] = True): assert actual_bind == expected_bind +def test_boolean_flag_app_parameter_default_nested_annotated_override(app): + app.default_parameter = Parameter(negative="") + + def my_converter(type_, *values): + return 5 + + my_int = Annotated[int, Parameter(converter=my_converter)] + + @app.default + def foo(*, foo: Annotated[my_int, Parameter(name="--bar")] = True): + pass + + signature = inspect.signature(foo) + + expected_bind = signature.bind(foo=5) + actual_command, actual_bind = app.parse_args("--bar=10") + assert actual_command == foo + assert actual_bind == expected_bind + + @pytest.mark.parametrize( "cmd_str,expected", [ From a35fee3afbe0e691801c751e8678d995ee4a65db Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Tue, 19 Dec 2023 22:28:12 -0800 Subject: [PATCH 19/20] more docs --- cyclopts/core.py | 6 +++--- docs/source/api.rst | 2 +- docs/source/default_parameter.rst | 28 +++++++++++++++++++------ docs/source/parameters.rst | 35 +++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/cyclopts/core.py b/cyclopts/core.py index aeee61bd..3162f9ff 100644 --- a/cyclopts/core.py +++ b/cyclopts/core.py @@ -154,13 +154,13 @@ class App: def default_parameter(self) -> Parameter: """Parameter value defaults for all Annotated Parameters. - The ``default_parameter`` is treated as a hierarchical configuration, inheriting from parenting ``App``s. + The ``default_parameter`` is treated as a hierarchical configuration, inheriting from parenting ``App`` s. Usually, an :class:`App` has at most one parent. In the event of multiple parents, they are evaluated in reverse-registered order, where each ``default_parameter`` attributes overwrites the previous. - I.e. the first registered parents have highest-priority of the parents. - The specified ``default_parameter`` for this ``App`` object has highest priority. + I.e. the first registered parent has the highest-priority of the parents. + The specified ``default_parameter`` for this ``App`` object has higher priority over parents. """ return Parameter.combine(*(x.default_parameter for x in reversed(self._parents)), self._default_parameter) diff --git a/docs/source/api.rst b/docs/source/api.rst index 0abcb81a..a56c9242 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -5,7 +5,7 @@ API === .. autoclass:: cyclopts.App - :members: name, default, command, version_print, help_print, interactive_shell, parse_known_args, parse_args + :members: name, default, default_parameter, command, version_print, help_print, interactive_shell, parse_known_args, parse_args :special-members: __call__, __getitem__ .. autoclass:: cyclopts.Parameter diff --git a/docs/source/default_parameter.rst b/docs/source/default_parameter.rst index 24a1e286..60709b52 100644 --- a/docs/source/default_parameter.rst +++ b/docs/source/default_parameter.rst @@ -1,7 +1,7 @@ ================= Default Parameter ================= -The default values of :class:`Parameter` can be configured via the ``default_parameter`` field of :class:`App`. +The default values of :class:`Parameter` can be configured via :attr:`.App.default_parameter`. For example, to disable the ``negative`` flag feature across your entire app: @@ -19,7 +19,7 @@ For example, to disable the ``negative`` flag feature across your entire app: app() -We can see that ``--no-flag`` is no longer provided: +Consequently, ``--no-flag`` is no longer provided: .. code-block:: @@ -30,18 +30,34 @@ We can see that ``--no-flag`` is no longer provided: │ * --flag [required] │ ╰───────────────────────────────────────────────────────────────╯ -However, if we explicitly set ``negative`` in the function signature, it works as expected: +Explicitly setting ``negative`` in the function signature works as expected: .. code-block:: @app.command - def foo(*, flag: Annotated[bool, Parameter(negative="--no-flag")]): + def foo(*, flag: Annotated[bool, Parameter(negative="--anti-flag")]): pass +.. code-block:: + + $ my-script foo --help + Usage: my-script foo [ARGS] [OPTIONS] + + ╭─ Parameters ──────────────────────────────────────────────────╮ + │ * --flag,--anti-flag [required] │ + ╰───────────────────────────────────────────────────────────────╯ + +.. _Parameter Resolution Order: -When resolving what the ``default_parameter`` values should be, explicitly set values from higher priority sources override lower-priority sources: +---------------- +Resolution Order +---------------- + +When resolving what the Parameter values for an individual function parameter should be, explicitly set attributes of higher priority Parameters override lower priority Parameters. The resolution order is as follows: 1. *Highest Priority:* Parameter-annotated command function signature ``Annotated[..., Parameter()]``. 2. :class:`App` ``default_parameter`` that registered the command. -3. *Lowest Priority:* :class:`App` parenting app(s)'s ``default_parameter``. +3. *Lowest Priority:* :class:`App` parenting app(s)'s ``default_parameter`` (and their parents, and so on). + +Any of Parameter's fields can be set to `None` to revert back to the true-original Cyclopts default. diff --git a/docs/source/parameters.rst b/docs/source/parameters.rst index 1496ce7b..1d34a34c 100644 --- a/docs/source/parameters.rst +++ b/docs/source/parameters.rst @@ -87,6 +87,8 @@ It's recommended to use docstrings for your parameter help, but if necessary, yo │ * VALUE,--value THIS IS USED. [required] │ ╰───────────────────────────────────────────────────────────────╯ +.. _Converters: + ---------- Converters ---------- @@ -187,3 +189,36 @@ If we had a program that accepted an integer user age as an input, ``-1`` is an ╭─ Error ──────────────────────────────────────────────────────────────────╮ │ Invalid value for --age. You are too old to be using this application. │ ╰──────────────────────────────────────────────────────────────────────────╯ + +------------------- +Multiple Parameters +------------------- +Say you want to define a new ``int`` type that uses the :ref:`byte-centric converter from above`. + +We can define the type: + +.. code-block:: python + + ByteSize = Annotated[int, Parameter(converter=byte_units)] + +We can then either directly annotate a function parameter with this: + +.. code-block:: python + + @app.command + def zero(size: ByteSize): + pass + +or even stack annotations to add additional features, like a validator: + +.. code-block:: python + + def must_be_multiple_of_4096(type_, value): + assert value % 4096 == 0 + + + @app.command + def zero(size: Annotated[ByteSize, Parameter(validator=must_be_multiple_of_4096)]): + pass + +See :ref:`Parameter Resolution Order` for more details. From 8e9c3095a2a7225233929442de5eaf034d40d74d Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Wed, 20 Dec 2023 09:51:30 -0800 Subject: [PATCH 20/20] Add Parameter.default class method. --- cyclopts/parameter.py | 11 +++++++++++ docs/source/api.rst | 4 ++++ tests/test_parameter.py | 13 +++++++++++++ 3 files changed, 28 insertions(+) diff --git a/cyclopts/parameter.py b/cyclopts/parameter.py index a605c7bb..078af773 100644 --- a/cyclopts/parameter.py +++ b/cyclopts/parameter.py @@ -143,6 +143,17 @@ def combine(cls, *parameters: Optional["Parameter"]) -> "Parameter": return cls(**kwargs) + @classmethod + def default(cls) -> "Parameter": + """Create a Parameter with all Cyclopts-default values. + + This is different than just ``Parameter()`` because it will override + all values of upstream Parameters. + """ + return cls( + **{a.alias: a.default for a in cls.__attrs_attrs__ if a.init} # pyright: ignore[reportGeneralTypeIssues] + ) + def validate_command(f, default_parameter: Optional[Parameter] = None): """Validate if a function abides by Cyclopts's rules. diff --git a/docs/source/api.rst b/docs/source/api.rst index a56c9242..30523c8c 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -102,6 +102,10 @@ API If multiple environment variables are given, the left-most environment variable with a set value will be used. If no environment variable is set, Cyclopts will fallback to the function-signature default. + .. automethod:: combine + + .. automethod:: default + .. autofunction:: cyclopts.coerce .. autofunction:: cyclopts.create_bound_arguments diff --git a/tests/test_parameter.py b/tests/test_parameter.py index 21c223f6..c150b954 100644 --- a/tests/test_parameter.py +++ b/tests/test_parameter.py @@ -79,3 +79,16 @@ def test_parameter_combine_priority_none(): p_combined = Parameter.combine(p1, p2) assert p_combined.negative is None + + +def test_parameter_default(): + p1 = Parameter() + p2 = Parameter.default() + + # The two parameters should be equivalent. + assert p1 == p2 + + # However, the _provided_args field should differ + assert p1._provided_args == () + # Just testing a few + assert {"name", "converter", "validator"}.issubset(p2._provided_args)