From 7287193e13450b50693be3d2deb3071b57086183 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 22:32:34 +0000 Subject: [PATCH 1/5] Support positional-only arguments in the strict sense Previously valimp ignored any '/' in a decorated function's signature, allowing intended positional-only arguments to be passed as keyword arguments. valimp now enforces positional-only arguments: a positional-only argument can only be passed positionally. Passing it by keyword raises an `InputsError`, unless the signature provides for **kwargs, in which case the keyword input is absorbed by **kwargs (consistent with standard Python behaviour). The verification is implemented within `validate_against_signature`, which covers the other signature checks. `inspect.signature` is interrogated to identify positional-only parameters (these are not distinguished by `inspect.getfullargspec`). The coerce/parse logic of the reconstruction loop has been factored into a new `apply_metadata` helper so that values absorbed by **kwargs under a positional-only name are validated, coerced and parsed consistently with other **kwargs inputs. Tests added to cover the new verification and the **kwargs absorption behaviour. The corresponding 'does not currently support' note has been removed from the module docstring and the README. --- README.md | 6 -- src/valimp/valimp.py | 176 +++++++++++++++++++++++++++++++++++-------- tests/test_valimp.py | 172 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 55243fb..f76f6fd 100644 --- a/README.md +++ b/README.md @@ -241,12 +241,6 @@ In short, if you only want to validate the type of function inputs then Pydantic verify that an object passed to a parameter annotated as `Callable` is in fact callable). -`valimp` does NOT currently support: - - Positional-only arguments. Any '/' in the signature (to define - positional-only arguments) will be ignored. Consequently valimp DOES - allow intended positional-only arguments to be passed as keyword - arguments. - The library has been built with development in mind and PRs are very much welcome! ## License diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 743c48e..7609878 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -10,11 +10,11 @@ The `parse_cls`decorator provides the same functionality for inputs to dataclasses. -Valimp does NOT currently support: - - Positional-only arguments. Any '/' in the signature (to define - positional-only arguments) will be ignored. Consequently valimp DOES - allow intended positional-only arguments to be passed as keyword - arguments. +Positional-only arguments (those defined ahead of a '/' in the signature) +are supported. As when calling an undecorated function, a positional-only +argument can only be passed positionally; passing it by keyword will raise +unless the signature provides for **kwargs, in which case the keyword input +is absorbed by **kwargs (consistent with standard Python behaviour). See the tutorial for a walk-through of all functionality: https://github.com/maread99/valimp/blob/master/docs/tutorials/tutorial.ipynb @@ -777,6 +777,8 @@ def validate_against_signature( req_kwargs: list[str], excess_args: tuple[Any, ...], excess_kwargs: list[str], + posonly: list[str], + posonly_as_kwarg: list[str], ) -> list[TypeError]: """Validate inputs against arguments expected by signature. @@ -787,13 +789,14 @@ def validate_against_signature( name, value as received input (i.e. as if were received as a keyword argument). - NB module does not support positional-only arguments (i.e. these - could have been receieved as keyword args). - kwargs Inputs for arguments receieved as keyword arguments. Key as argument name, value as received input. + NB any keyword input matching a positional-only parameter name + should have been removed from `kwargs` before being passed to this + function (such inputs are advised via `posonly_as_kwarg`). + req_args List of names of required positional arguments. @@ -808,6 +811,14 @@ def validate_against_signature( Names of any received kwargs that are not accommodated by the signature. + posonly + List of names of all positional-only parameters. + + posonly_as_kwarg + List of names of positional-only parameters that were invalidly + received as keyword arguments (i.e. as a keyword argument that + cannot be absorbed by a **kwargs parameter). + Returns ------- errors @@ -848,9 +859,31 @@ def validate_against_signature( ) ) + # positional-only arguments invalidly received as keyword arguments + if posonly_as_kwarg: + errors.append( + TypeError( + f"Got positional-only" + f" argument{'s' if len(posonly_as_kwarg) > 1 else ''} passed as" + f" keyword argument{'s' if len(posonly_as_kwarg) > 1 else ''}:" + f" {args_name_inset(posonly_as_kwarg)}." + ) + ) + # missing required arguments all_as_kwargs = args_as_kwargs | kwargs - missing = [a for a in req_args if a not in all_as_kwargs] + # a positional-only argument can only be satisfied by being received + # positionally - it is missing if it was not received positionally (any + # keyword input matching its name will have been removed from `kwargs`). + # Such an argument is not flagged as missing if it was received as a + # keyword argument that could not be absorbed by **kwargs, as this is + # advised separately via `posonly_as_kwarg`. + missing = [ + a + for a in req_args + if a not in posonly_as_kwarg + and (a not in all_as_kwargs or (a in posonly and a not in args_as_kwargs)) + ] if missing: errors.append(get_missing_arg_error(missing, positional=True)) @@ -1000,6 +1033,50 @@ def get_unreceived_kwargs( return {k: v for k, v in spec.kwonlydefaults.items() if k not in names_received} +def apply_metadata( + name: str, + obj: Any, + hint: type[Any] | typing._Final, + params: dict[str, Any], +) -> Any: + """Apply `Coerce` and `Parser` metadata of a hint to an input. + + Parameters + ---------- + name + Name of the argument being parsed. + + obj + Input object to coerce and/or parse. + + hint + Type hint, possibly wrapped in `typing.Annotated`, against which + `obj` was validated. Any `Coerce` and `Parser` instances in the + metadata are applied in the order in which they are defined. + + params + Shallow copy of prior inputs that have already been parsed and, if + applicable, coerced. Passed through to any `Parser` function. + + Returns + ------- + Any + `obj` as coerced and/or parsed. Returned unchanged if `hint` is not + wrapped in `typing.Annotated`. + """ + if not is_annotated(hint): + return obj + for data in hint.__metadata__: + # let order of coercion and parsing depend on their order within metadata + if obj is not None and isinstance(data, Coerce): + obj = data.coerce_to(obj) + if isinstance(data, Parser): + if obj is None and not data.parse_none: + continue + obj = data.function(name, obj, params) + return obj + + def parse( # noqa: C901 f=None, *, @@ -1023,6 +1100,13 @@ def parse( # noqa: C901 if f is None: return functools.partial(parse, no_item_validation=no_item_validation) spec = inspect.getfullargspec(f) + # `inspect.getfullargspec` does not distinguish positional-only parameters + # (they are included to `spec.args`), hence interrogate the signature. + posonly_args = [ + name + for name, param in inspect.signature(f).parameters.items() + if param.kind is inspect.Parameter.POSITIONAL_ONLY + ] hints = typing.get_type_hints(f, include_extras=True) hints = fix_hints_for_none_default(hints, spec) req_args = spec.args if spec.defaults is None else spec.args[: -len(spec.defaults)] @@ -1057,22 +1141,45 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 if hint: del hints_[spec.varargs] + # positional-only parameters cannot be bound by keyword. Remove any + # keyword input that matches a positional-only parameter name: it does + # not bind to that parameter, rather it will either be absorbed by a + # **kwargs parameter (if provided for) or is invalid. + posonly_as_kwarg = { + name: kwargs.pop(name) for name in posonly_args if name in kwargs + } + + # hint for any **kwargs parameter (None if no **kwargs or **kwargs not typed) + varkw_hint: type[Any] | typing._Final | None = None extra_kwargs = [a for a in kwargs if a not in all_param_names] if spec.varkw is None: # no provision for extra kwargs, e.g. no **kwargs in sig excess_kwargs = extra_kwargs for name in excess_kwargs: del kwargs[name] extra_kwargs = [] + # no **kwargs to absorb positional-only inputs received by keyword + posonly_kwarg_errors = list(posonly_as_kwarg) + posonly_as_kwarg = {} else: # extra kwargs provided for, e.g. with **kwargs excess_kwargs = [] + # positional-only inputs received by keyword are absorbed by **kwargs + posonly_kwarg_errors = [] # add a hint for each extra kwarg - if hint := hints.get(spec.varkw, False): + varkw_hint = hints.get(spec.varkw) + if varkw_hint is not None: for name in extra_kwargs: - hints_[name] = hint + hints_[name] = varkw_hint del hints_[spec.varkw] sig_errors = validate_against_signature( - args_as_kwargs, kwargs, req_args, req_kwargs, excess_args, excess_kwargs + args_as_kwargs, + kwargs, + req_args, + req_kwargs, + excess_args, + excess_kwargs, + posonly_args, + posonly_kwarg_errors, ) params_as_kwargs = { # remove arguments not provided for in signature @@ -1084,6 +1191,16 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 ann_errors = validate_against_hints( params_as_kwargs, hints_, no_item_validation=no_item_validation ) + # validate positional-only inputs absorbed by **kwargs against the + # **kwargs type annotation (if any). + if posonly_as_kwarg and varkw_hint is not None: + ann_errors.update( + validate_against_hints( + posonly_as_kwarg, + dict.fromkeys(posonly_as_kwarg, varkw_hint), + no_item_validation=no_item_validation, + ) + ) if sig_errors or ann_errors: raise InputsError(f.__name__, sig_errors, ann_errors) @@ -1098,27 +1215,12 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 ) new_extra_args = [] - new_kwargs = {} + new_kwargs: dict[str, Any] = {} for name, obj in all_as_kwargs.items(): - if name not in hints_: - if name.startswith(name_extra_args): - new_extra_args.append(obj) - else: - new_kwargs[name] = obj - continue - hint = hints_[name] - if is_annotated(hint): - meta = hint.__metadata__ - for data in meta: - # let order of coercion and parsing depend on their - # order within metadata - if obj is not None and isinstance(data, Coerce): - obj = data.coerce_to(obj) # noqa: PLW2901 - if isinstance(data, Parser): - if obj is None and not data.parse_none: - continue - obj = data.function(name, obj, new_kwargs.copy()) # noqa: PLW2901 - + if name in hints_: + obj = apply_metadata( # noqa: PLW2901 + name, obj, hints_[name], new_kwargs.copy() + ) if name.startswith(name_extra_args): new_extra_args.append(obj) else: @@ -1129,6 +1231,16 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 new_args.append(new_kwargs[arg_name]) del new_kwargs[arg_name] + # add positional-only inputs received by keyword and absorbed by + # **kwargs (these never bind to the positional-only parameter, hence + # are added after extracting the positional arguments). + for name, obj in posonly_as_kwarg.items(): + if varkw_hint is not None: + obj = apply_metadata( # noqa: PLW2901 + name, obj, varkw_hint, new_kwargs.copy() + ) + new_kwargs[name] = obj + return f(*new_args, *new_extra_args, **new_kwargs) return wrapped_f diff --git a/tests/test_valimp.py b/tests/test_valimp.py index 8ff5f23..f16076c 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2214,3 +2214,175 @@ class DataCls: ) with pytest.raises(m.InputsError, match=re.escape(msg)): DataCls("not a list", {0: "val0"}) + + +def test_positional_only_valid(): + """Verify positional-only arguments can be passed positionally.""" + + @m.parse + def f( + a: Annotated[Union[int, str], m.Coerce(str)], + b: Annotated[int, m.Parser(lambda _n, obj, _p: obj + 1)], + /, + c: int, + d: int = 3, + ) -> tuple: + return a, b, c, d + + # all positional-only args passed positionally, including coerce/parse + assert f(5, 10, 2) == ("5", 11, 2, 3) + assert f(5, 10, 2, 4) == ("5", 11, 2, 4) + # the positional-or-keyword args can still be passed by keyword + assert f(5, 10, c=2, d=4) == ("5", 11, 2, 4) + + +def test_positional_only_as_keyword_invalid(): + """Verify error if positional-only argument passed as keyword. + + Verifies for a function that does not provide for **kwargs. + """ + + @m.parse + def f(a: int, /, b: str) -> tuple: + return a, b + + # single positional-only argument passed as keyword + msg = ( + "Inputs to 'f' do not conform with the function signature:" + "\n\nGot positional-only argument passed as keyword argument: 'a'." + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + f(a=1, b="x") + + @m.parse + def g(a: int, b: str, /, c: int) -> tuple: + return a, b, c + + # multiple positional-only arguments passed as keyword + msg = ( + "Inputs to 'g' do not conform with the function signature:" + "\n\nGot positional-only arguments passed as keyword arguments:" + " 'a' and 'b'." + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + g(a=1, b="x", c=3) + + +def test_positional_only_as_keyword_with_other_sig_errors(): + """Verify positional-only error reported alongside other errors.""" + + @m.parse + def f(a: int, b: str, /, c: int) -> tuple: + return a, b, c + + # 'b' passed as keyword (positional-only), 'a' passed positionally, + # 'c' missing and an unexpected keyword argument received. + msg = ( + "Inputs to 'f' do not conform with the function signature:" + "\n\nGot unexpected keyword argument: 'not_a_kwarg'." + "\n\nGot positional-only argument passed as keyword argument: 'b'." + "\n\nMissing 1 positional argument: 'c'." + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + f(1, b="x", not_a_kwarg="foo") + + +def test_positional_only_absorbed_by_kwargs(): + """Verify positional-only name as keyword is absorbed by **kwargs. + + Matches standard Python behaviour where a keyword argument matching a + positional-only parameter name is absorbed by **kwargs rather than + binding to the parameter. + """ + + @m.parse + def f(a: int, /, **kwargs: int) -> tuple: + return a, kwargs + + # 'a' passed positionally, 'a' as keyword absorbed by **kwargs + assert f(1, a=2, b=3) == (1, {"a": 2, "b": 3}) + # only positional 'a' + assert f(5, x=9) == (5, {"x": 9}) + + +def test_positional_only_absorbed_by_kwargs_default(): + """Verify absorption by **kwargs when positional-only arg takes default.""" + + @m.parse + def f(a: int = 0, /, **kwargs: int) -> tuple: + return a, kwargs + + # 'a' not passed positionally so takes default, keyword 'a' to **kwargs + assert f(x=2, a=3) == (0, {"x": 2, "a": 3}) + + +def test_positional_only_required_missing_with_kwargs(): + """Verify required positional-only arg flagged missing despite **kwargs. + + A keyword matching the positional-only parameter name is absorbed by + **kwargs and does not satisfy the (required) positional-only parameter. + """ + + @m.parse + def f(a: int, /, **kwargs: int) -> tuple: + return a, kwargs + + msg = ( + "Inputs to 'f' do not conform with the function signature:" + "\n\nMissing 1 positional argument: 'a'." + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + f(a=2, b=3) + + +def test_positional_only_absorbed_kwargs_validated(): + """Verify values absorbed by **kwargs are validated and coerced. + + Includes verification for a value absorbed under a positional-only + parameter name. + """ + + @m.parse + def f( + a: int, + /, + **kwargs: Annotated[Union[int, str], m.Coerce(str)], + ) -> tuple: + return a, kwargs + + # keyword 'a' (absorbed) and 'b' coerced to str against the **kwargs hint + assert f(1, a=2, b=3) == (1, {"a": "2", "b": "3"}) + + # absorbed value that does not conform with the **kwargs hint + @m.parse + def g(a: int, /, **kwargs: int) -> tuple: + return a, kwargs + + msg = ( + "The following inputs to 'g' do not conform with the corresponding" + " type annotation:\n\na\n\tTakes type " + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + g(1, a="not an int") + + +def test_positional_only_method(): + """Verify positional-only support for a decorated method.""" + + class A: + """Class to hold decorated method.""" + + @m.parse + def meth(self, a: int, b: int, /, c: int) -> tuple: + return a, b, c + + inst = A() + assert inst.meth(1, 2, 3) == (1, 2, 3) + assert inst.meth(1, 2, c=3) == (1, 2, 3) + + msg = ( + "Inputs to 'meth' do not conform with the function signature:" + "\n\nGot positional-only argument passed as keyword argument: 'b'." + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + inst.meth(1, b=2, c=3) From aa172547f5660e14e30951d356652dc4d2c9690f Mon Sep 17 00:00:00 2001 From: Marcus Read Date: Wed, 3 Jun 2026 11:55:57 +0100 Subject: [PATCH 2/5] Manual revisions Doc and other non-functional revisions. --- src/valimp/valimp.py | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 7609878..0248f56 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -1100,13 +1100,6 @@ def parse( # noqa: C901 if f is None: return functools.partial(parse, no_item_validation=no_item_validation) spec = inspect.getfullargspec(f) - # `inspect.getfullargspec` does not distinguish positional-only parameters - # (they are included to `spec.args`), hence interrogate the signature. - posonly_args = [ - name - for name, param in inspect.signature(f).parameters.items() - if param.kind is inspect.Parameter.POSITIONAL_ONLY - ] hints = typing.get_type_hints(f, include_extras=True) hints = fix_hints_for_none_default(hints, spec) req_args = spec.args if spec.defaults is None else spec.args[: -len(spec.defaults)] @@ -1119,6 +1112,14 @@ def parse( # noqa: C901 ) name_extra_args = "_" + spec.varargs if spec.varargs is not None else "_a5f12_3adz" + # necessary to interrogate the signature as `inspect.getfullargspec` does not + # distinguish positional-only parameters (they are included to `spec.args`). + posonly_args = [ + name + for name, param in inspect.signature(f).parameters.items() + if param.kind is inspect.Parameter.POSITIONAL_ONLY + ] + @functools.wraps(f) def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 hints_ = hints.copy() @@ -1126,10 +1127,10 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 # handle extra args extra_args = args[len(spec.args) :] - if spec.varargs is None: # no provision for extra args, e.g. no *args in sig + if spec.varargs is None: # no variadic positional parameter (no *args in sig) excess_args = extra_args extra_args = () - else: # extra args provided for, e.g. with *args + else: # extra args provided for, e.g. within *args excess_args = () # add a hint for each extra arg hint = hints.get(spec.varargs, False) @@ -1144,15 +1145,23 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 # positional-only parameters cannot be bound by keyword. Remove any # keyword input that matches a positional-only parameter name: it does # not bind to that parameter, rather it will either be absorbed by a - # **kwargs parameter (if provided for) or is invalid. + # **kwargs parameter (if provided for) or is invalid. (Note that if + # there is a **kwargs parameter then any passed kwarg with a name + # that matches a positional-only argument will be considered valid and + # included to **kwargs, for example it's valid to call function + # `def g(a, /, **kwargs: int)` with `g(1, a=2, b=3)`, in which case + # **kwags will be received as `{'a': 2, 'b': 3}`.) + # Popping from kwargs necessary here to distinguish between a positional + # argument passed positionally and as a kwarg posonly_as_kwarg = { name: kwargs.pop(name) for name in posonly_args if name in kwargs } # hint for any **kwargs parameter (None if no **kwargs or **kwargs not typed) - varkw_hint: type[Any] | typing._Final | None = None + varkw_hint: type[Any] | typing._Final | None extra_kwargs = [a for a in kwargs if a not in all_param_names] if spec.varkw is None: # no provision for extra kwargs, e.g. no **kwargs in sig + varkw_hint = None excess_kwargs = extra_kwargs for name in excess_kwargs: del kwargs[name] @@ -1182,17 +1191,24 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 posonly_kwarg_errors, ) - params_as_kwargs = { # remove arguments not provided for in signature + # remove arguments not provided for in signature + params_as_kwargs = { k: v for k, v in (args_as_kwargs | kwargs).items() if (k in all_param_names + extra_kwargs) or k.startswith(name_extra_args) } + # note that `posonly_as_kwarg` will not included to the above + # `params_as_kwargs` (they can't be as they would share the same + # name as the positional argument - can't have two keys with the + # same value) ann_errors = validate_against_hints( params_as_kwargs, hints_, no_item_validation=no_item_validation ) - # validate positional-only inputs absorbed by **kwargs against the - # **kwargs type annotation (if any). + + # now consider any inputs absorbed by **kwargs that shared the same + # name as a positional-only argument. Validate these against the + # variadic type hint for **kwargs if posonly_as_kwarg and varkw_hint is not None: ann_errors.update( validate_against_hints( From 76e543d53153db5255c2055a7884c7b358ef946f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 10:58:16 +0000 Subject: [PATCH 3/5] Report both errors on positional-only / **kwargs name collision When a positional-only argument is received both positionally and as a keyword absorbed by **kwargs, and both values are invalid against their respective type annotations, both errors are now reported. Previously the absorbed value's error overrode the positional argument's error as both were keyed by the same parameter name in the consolidated errors mapping. The absorbed value's error is filed under a disambiguated key (e.g. "a (**kwargs)") only when the same name already carries an error from a positionally-received value, so ordinary cases are unaffected. Test added for the doubly-invalid collision case. --- src/valimp/valimp.py | 17 +++++++++++------ tests/test_valimp.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 0248f56..035d6d9 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -1210,13 +1210,18 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 # name as a positional-only argument. Validate these against the # variadic type hint for **kwargs if posonly_as_kwarg and varkw_hint is not None: - ann_errors.update( - validate_against_hints( - posonly_as_kwarg, - dict.fromkeys(posonly_as_kwarg, varkw_hint), - no_item_validation=no_item_validation, - ) + posonly_errors = validate_against_hints( + posonly_as_kwarg, + dict.fromkeys(posonly_as_kwarg, varkw_hint), + no_item_validation=no_item_validation, ) + # disambiguate the key only where the same name already has an + # error from a positional-only argument received positionally, + # so that both errors are reported rather than one overriding + # the other. + for name, error in posonly_errors.items(): + key = name if name not in ann_errors else f"{name} (**{spec.varkw})" + ann_errors[key] = error if sig_errors or ann_errors: raise InputsError(f.__name__, sig_errors, ann_errors) diff --git a/tests/test_valimp.py b/tests/test_valimp.py index f16076c..8ffc50f 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2366,6 +2366,44 @@ def g(a: int, /, **kwargs: int) -> tuple: g(1, a="not an int") +def test_positional_only_absorbed_kwargs_name_collision_errors(): + """Verify both errors reported when a colliding name is doubly invalid. + + When a positional-only argument is received both positionally and as a + keyword (absorbed by **kwargs), and both values are invalid against + their respective type annotations, both errors should be reported (the + absorbed value's error keyed distinctly so it does not override the + positional argument's error). + """ + + @m.parse + def f(a: str, /, **kwargs: int) -> tuple: + return a, kwargs + + # positional 'a' (1) invalid against `str`, absorbed 'a' ("x") invalid + # against the **kwargs hint `int` - both errors reported + msg = ( + "The following inputs to 'f' do not conform with the corresponding" + " type annotation:" + "\n\na\n\tTakes type although received '1' of type" + " ." + "\n\na (**kwargs)\n\tTakes type although received 'x'" + " of type ." + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + f(1, a="x") + + # only the absorbed value is invalid (positional 'a' valid) - the error + # is reported under the plain name (no disambiguation required) + msg = ( + "The following inputs to 'f' do not conform with the corresponding" + " type annotation:\n\na\n\tTakes type although received" + " 'x' of type ." + ) + with pytest.raises(m.InputsError, match=re.escape(msg)): + f("ok", a="x") + + def test_positional_only_method(): """Verify positional-only support for a decorated method.""" From b18f7bf204626ada9dce9802572466b243034b04 Mon Sep 17 00:00:00 2001 From: Marcus Read Date: Wed, 3 Jun 2026 17:46:50 +0100 Subject: [PATCH 4/5] Manual revisions. Revisions to doc. Other changes non-functional with important exception of changes to error messages. --- src/valimp/valimp.py | 107 +++++++++++++++++++++---------------------- tests/test_valimp.py | 47 +++++++++---------- 2 files changed, 77 insertions(+), 77 deletions(-) diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 035d6d9..21d368c 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -10,12 +10,6 @@ The `parse_cls`decorator provides the same functionality for inputs to dataclasses. -Positional-only arguments (those defined ahead of a '/' in the signature) -are supported. As when calling an undecorated function, a positional-only -argument can only be passed positionally; passing it by keyword will raise -unless the signature provides for **kwargs, in which case the keyword input -is absorbed by **kwargs (consistent with standard Python behaviour). - See the tutorial for a walk-through of all functionality: https://github.com/maread99/valimp/blob/master/docs/tutorials/tutorial.ipynb @@ -770,7 +764,7 @@ def get_missing_arg_error(missing: list[str], *, positional: bool = True) -> Typ ) -def validate_against_signature( +def validate_against_signature( # noqa: C901 args_as_kwargs: dict[str, Any], kwargs: dict[str, Any], req_args: list[str], @@ -790,12 +784,12 @@ def validate_against_signature( keyword argument). kwargs - Inputs for arguments receieved as keyword arguments. Key + Inputs for arguments received as keyword arguments. Key as argument name, value as received input. - NB any keyword input matching a positional-only parameter name - should have been removed from `kwargs` before being passed to this - function (such inputs are advised via `posonly_as_kwarg`). + NB should not include any keyword input matching the name of a + positional-only parameter (such inputs should be included to + `posonly_as_kwarg`). req_args List of names of required positional arguments. @@ -861,29 +855,31 @@ def validate_against_signature( # positional-only arguments invalidly received as keyword arguments if posonly_as_kwarg: + argument = "argument" if len(posonly_as_kwarg) == 1 else "arguments" errors.append( TypeError( - f"Got positional-only" - f" argument{'s' if len(posonly_as_kwarg) > 1 else ''} passed as" - f" keyword argument{'s' if len(posonly_as_kwarg) > 1 else ''}:" + f"Received positional-only {argument} as keyword {argument}:" f" {args_name_inset(posonly_as_kwarg)}." ) ) # missing required arguments all_as_kwargs = args_as_kwargs | kwargs - # a positional-only argument can only be satisfied by being received - # positionally - it is missing if it was not received positionally (any - # keyword input matching its name will have been removed from `kwargs`). - # Such an argument is not flagged as missing if it was received as a - # keyword argument that could not be absorbed by **kwargs, as this is - # advised separately via `posonly_as_kwarg`. - missing = [ - a - for a in req_args - if a not in posonly_as_kwarg - and (a not in all_as_kwargs or (a in posonly and a not in args_as_kwargs)) - ] + missing = [] + for a in req_args: + if a in posonly_as_kwarg: + continue # already considered in `posonly_as_kwarg` TypeError + # a positional-only argument can only be satisfied by being received + # positionally - it is missing if it was not received positionally. + posonly_missing = a in posonly and a not in args_as_kwargs + if posonly_missing or a not in all_as_kwargs: + # Note that `a not in all_as_kwargs` is not enough as if a + # keyword argument was validly passed with the same name as a + # positional-only argument (i.e. if the sig included **kwargs) + # then the name of the missing positional-only arg *would* appear + # in `all_as_kwargs`. + missing.append(a) + if missing: errors.append(get_missing_arg_error(missing, positional=True)) @@ -1033,13 +1029,13 @@ def get_unreceived_kwargs( return {k: v for k, v in spec.kwonlydefaults.items() if k not in names_received} -def apply_metadata( +def apply_coerce_and_parser( name: str, obj: Any, hint: type[Any] | typing._Final, params: dict[str, Any], ) -> Any: - """Apply `Coerce` and `Parser` metadata of a hint to an input. + """Apply any `Coerce` and `Parser` in metadata of a hint to an input. Parameters ---------- @@ -1142,18 +1138,26 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 if hint: del hints_[spec.varargs] - # positional-only parameters cannot be bound by keyword. Remove any - # keyword input that matches a positional-only parameter name: it does - # not bind to that parameter, rather it will either be absorbed by a + # Remove any keyword input that matches a positional-only parameter name: + # it does not bind to that name, rather it will either be absorbed by a # **kwargs parameter (if provided for) or is invalid. (Note that if # there is a **kwargs parameter then any passed kwarg with a name # that matches a positional-only argument will be considered valid and # included to **kwargs, for example it's valid to call function # `def g(a, /, **kwargs: int)` with `g(1, a=2, b=3)`, in which case - # **kwags will be received as `{'a': 2, 'b': 3}`.) + # **kwags will be received as `{'a': 2, 'b': 3}` - this is the standard + # Python behaviour.) # Popping from kwargs necessary here to distinguish between a positional - # argument passed positionally and as a kwarg - posonly_as_kwarg = { + # argument passed positionally and a keyword argument being passed with + # the same name (to be absorbed by **kwargs or to be considered invalid). + # When later in the code all parameters (positional and kwarg) are + # consoliated to `params_as_kwargs` that dict will include all + # positional-only arguments but it will not include any keyword arguments + # with the same name as a positional-only argument - such keyword + # arguments are instead held in this `kwarg_name_as_posonly` dict. (Why? + # Because they both have the same name - they can't both be represented + # in the same dictionary.) + kwarg_name_as_posonly = { name: kwargs.pop(name) for name in posonly_args if name in kwargs } @@ -1167,8 +1171,8 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 del kwargs[name] extra_kwargs = [] # no **kwargs to absorb positional-only inputs received by keyword - posonly_kwarg_errors = list(posonly_as_kwarg) - posonly_as_kwarg = {} + posonly_kwarg_errors = list(kwarg_name_as_posonly) + kwarg_name_as_posonly = {} else: # extra kwargs provided for, e.g. with **kwargs excess_kwargs = [] # positional-only inputs received by keyword are absorbed by **kwargs @@ -1197,10 +1201,8 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 for k, v in (args_as_kwargs | kwargs).items() if (k in all_param_names + extra_kwargs) or k.startswith(name_extra_args) } - # note that `posonly_as_kwarg` will not included to the above - # `params_as_kwargs` (they can't be as they would share the same - # name as the positional argument - can't have two keys with the - # same value) + # note that `kwarg_name_as_posonly` will not included to the above + # `params_as_kwargs` - see comment further above for `kwarg_name_as_posonly`. ann_errors = validate_against_hints( params_as_kwargs, hints_, no_item_validation=no_item_validation @@ -1209,18 +1211,16 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 # now consider any inputs absorbed by **kwargs that shared the same # name as a positional-only argument. Validate these against the # variadic type hint for **kwargs - if posonly_as_kwarg and varkw_hint is not None: + if kwarg_name_as_posonly and varkw_hint is not None: posonly_errors = validate_against_hints( - posonly_as_kwarg, - dict.fromkeys(posonly_as_kwarg, varkw_hint), + kwarg_name_as_posonly, + dict.fromkeys(kwarg_name_as_posonly, varkw_hint), no_item_validation=no_item_validation, ) - # disambiguate the key only where the same name already has an - # error from a positional-only argument received positionally, - # so that both errors are reported rather than one overriding - # the other. + # disambiguate names that refer to keyword arguments received + # to **kwargs from positional-only args for the same name. for name, error in posonly_errors.items(): - key = name if name not in ann_errors else f"{name} (**{spec.varkw})" + key = f"{name} (**{spec.varkw})" ann_errors[key] = error if sig_errors or ann_errors: @@ -1239,7 +1239,7 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 new_kwargs: dict[str, Any] = {} for name, obj in all_as_kwargs.items(): if name in hints_: - obj = apply_metadata( # noqa: PLW2901 + obj = apply_coerce_and_parser( # noqa: PLW2901 name, obj, hints_[name], new_kwargs.copy() ) if name.startswith(name_extra_args): @@ -1252,12 +1252,11 @@ def wrapped_f(*args, **kwargs) -> Any: # noqa: C901, PLR0912 new_args.append(new_kwargs[arg_name]) del new_kwargs[arg_name] - # add positional-only inputs received by keyword and absorbed by - # **kwargs (these never bind to the positional-only parameter, hence - # are added after extracting the positional arguments). - for name, obj in posonly_as_kwarg.items(): + # add inputs absorbed by **kwargs that shared the same name as a + # positional-only argument. + for name, obj in kwarg_name_as_posonly.items(): if varkw_hint is not None: - obj = apply_metadata( # noqa: PLW2901 + obj = apply_coerce_and_parser( # noqa: PLW2901 name, obj, varkw_hint, new_kwargs.copy() ) new_kwargs[name] = obj diff --git a/tests/test_valimp.py b/tests/test_valimp.py index 8ffc50f..b400242 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2249,7 +2249,7 @@ def f(a: int, /, b: str) -> tuple: # single positional-only argument passed as keyword msg = ( "Inputs to 'f' do not conform with the function signature:" - "\n\nGot positional-only argument passed as keyword argument: 'a'." + "\n\nReceived positional-only argument as keyword argument: 'a'." ) with pytest.raises(m.InputsError, match=re.escape(msg)): f(a=1, b="x") @@ -2261,7 +2261,7 @@ def g(a: int, b: str, /, c: int) -> tuple: # multiple positional-only arguments passed as keyword msg = ( "Inputs to 'g' do not conform with the function signature:" - "\n\nGot positional-only arguments passed as keyword arguments:" + "\n\nReceived positional-only arguments as keyword arguments:" " 'a' and 'b'." ) with pytest.raises(m.InputsError, match=re.escape(msg)): @@ -2272,15 +2272,16 @@ def test_positional_only_as_keyword_with_other_sig_errors(): """Verify positional-only error reported alongside other errors.""" @m.parse - def f(a: int, b: str, /, c: int) -> tuple: + def f(a: int, b: str, /, c: int, d: str = "hi", *, e: bool = False) -> tuple: return a, b, c - # 'b' passed as keyword (positional-only), 'a' passed positionally, + # 'a' passed positionally, as required + # 'b' passed as keyword (positional-only) # 'c' missing and an unexpected keyword argument received. msg = ( "Inputs to 'f' do not conform with the function signature:" "\n\nGot unexpected keyword argument: 'not_a_kwarg'." - "\n\nGot positional-only argument passed as keyword argument: 'b'." + "\n\nReceived positional-only argument as keyword argument: 'b'." "\n\nMissing 1 positional argument: 'c'." ) with pytest.raises(m.InputsError, match=re.escape(msg)): @@ -2309,11 +2310,11 @@ def test_positional_only_absorbed_by_kwargs_default(): """Verify absorption by **kwargs when positional-only arg takes default.""" @m.parse - def f(a: int = 0, /, **kwargs: int) -> tuple: - return a, kwargs + def f(a: str, b: int = 0, /, **kwargs: int) -> tuple: + return a, b, kwargs - # 'a' not passed positionally so takes default, keyword 'a' to **kwargs - assert f(x=2, a=3) == (0, {"x": 2, "a": 3}) + # 'b' not passed positionally so takes default, keyword 'b' to **kwargs + assert f("a", b=3, x=2) == ("a", 0, {"b": 3, "x": 2}) def test_positional_only_required_missing_with_kwargs(): @@ -2350,30 +2351,31 @@ def f( ) -> tuple: return a, kwargs - # keyword 'a' (absorbed) and 'b' coerced to str against the **kwargs hint + # keyword 'a' absorbed to **kwargs and coerced to str + # keyword 'b' coerced to str against the **kwargs hint assert f(1, a=2, b=3) == (1, {"a": "2", "b": "3"}) # absorbed value that does not conform with the **kwargs hint @m.parse - def g(a: int, /, **kwargs: int) -> tuple: + def g(a: str, /, **kwargs: Union[list, tuple]) -> tuple: return a, kwargs msg = ( "The following inputs to 'g' do not conform with the corresponding" - " type annotation:\n\na\n\tTakes type " + " type annotation:\n\na (**kwargs)\n\tTakes input that conforms with" + " <(, )>" ) with pytest.raises(m.InputsError, match=re.escape(msg)): - g(1, a="not an int") + g("1", a="not an int") def test_positional_only_absorbed_kwargs_name_collision_errors(): """Verify both errors reported when a colliding name is doubly invalid. - When a positional-only argument is received both positionally and as a - keyword (absorbed by **kwargs), and both values are invalid against - their respective type annotations, both errors should be reported (the - absorbed value's error keyed distinctly so it does not override the - positional argument's error). + When a keyword argument is received with the same name as a + positional-only argument, and both values are invalid against their + their respective type annotations (with the kwarg being absorbed within + **kwargs), both errors should be reported. """ @m.parse @@ -2393,12 +2395,11 @@ def f(a: str, /, **kwargs: int) -> tuple: with pytest.raises(m.InputsError, match=re.escape(msg)): f(1, a="x") - # only the absorbed value is invalid (positional 'a' valid) - the error - # is reported under the plain name (no disambiguation required) + # only the absorbed value is invalid (positional 'a' valid) msg = ( "The following inputs to 'f' do not conform with the corresponding" - " type annotation:\n\na\n\tTakes type although received" - " 'x' of type ." + " type annotation:\n\na (**kwargs)\n\tTakes type " + " although received 'x' of type ." ) with pytest.raises(m.InputsError, match=re.escape(msg)): f("ok", a="x") @@ -2420,7 +2421,7 @@ def meth(self, a: int, b: int, /, c: int) -> tuple: msg = ( "Inputs to 'meth' do not conform with the function signature:" - "\n\nGot positional-only argument passed as keyword argument: 'b'." + "\n\nReceived positional-only argument as keyword argument: 'b'." ) with pytest.raises(m.InputsError, match=re.escape(msg)): inst.meth(1, b=2, c=3) From ca5010b56cff8d3b0f467a6652ebab543edf6df0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 16:56:48 +0000 Subject: [PATCH 5/5] Demonstrate positional-only argument support in docs Surface strict positional-only argument support within the existing signature-verification examples of the README and tutorial, preserving every current demonstration. - README public_function: mark 'a' as positional-only (ahead of '/'), pass it positionally in the invalid-types call, and rework the signature-mismatch call to show the new 'positional-only argument passed as keyword argument' error while relocating the 'got multiple values' demonstration to parameter 'c'. Add a 'What's supported' bullet. - tutorial Signature validation section: make pf's 'a' positional-only, add 'c' to retain the 'got multiple values' demo, and refresh the pasted error outputs accordingly. https://claude.ai/code/session_015Ci5jP93WGZiN2UE3SXyoh --- README.md | 28 +++++++++++++++++++--------- docs/tutorials/tutorial.ipynb | 15 +++++++++++---- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f76f6fd..a64cdd0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ from typing import Annotated, Union, Optional, Any def public_function( # validate against built-in or custom types a: str, + # 'a' is positional-only as it appears ahead of the '/' + /, # support for type unions b: Union[int, float], # or from Python 3.10 `int | float` # validate type of container items @@ -61,7 +63,8 @@ def public_function( return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "g":g, "h":h, "i":i, "args":args, "j":j, "k":k} public_function( - # NB parameters 'a' through 'i' can be passed positionally + # NB 'a' is positional-only (must be passed positionally); 'b' through + # 'i' can also be passed positionally "zero", # a 1.0, # b {"two": 2}, # c @@ -95,7 +98,7 @@ returns: And if there are invalid inputs... ```python public_function( - a=["not a string"], # INVALID + ["not a string"], # a, positional-only, passed positionally; INVALID b="not an int or a float", # INVALID c={2: "two"}, # INVALID, key not a str and value not an int or float d=3.2, # valid input @@ -129,24 +132,27 @@ h And if the inputs do not match the signature... ```python public_function( - "zero", - "invalid input", # invalid (not int or float), included in errors - {"two": 2}, - 3.2, + "zero", # a, positional-only, passed positionally + "invalid input", # b, invalid (not int or float), included in errors + {"two": 2}, # c, passed positionally... + 3.2, # d # no argument passed for required positional args 'e', 'f', 'g', 'h' and 'i' - a="a again", # passing multiple values for parameter 'a' - # no argument passed for required keyword arg 'j' + a="a again", # 'a' is positional-only: cannot be passed as a keyword arg + c={"three": 3}, # ...and again by keyword: passing multiple values for 'c' not_a_kwarg="not a kwarg", # including an unexpected kwarg + # no argument passed for required keyword arg 'j' ) ``` raises: ``` InputsError: Inputs to 'public_function' do not conform with the function signature: -Got multiple values for argument: 'a'. +Got multiple values for argument: 'c'. Got unexpected keyword argument: 'not_a_kwarg'. +Received positional-only argument as keyword argument: 'a'. + Missing 5 positional arguments: 'e', 'f', 'g', 'h' and 'i'. Missing 1 keyword-only argument: 'j'. @@ -232,6 +238,10 @@ In short, if you only want to validate the type of function inputs then Pydantic * `collections.abc.Mapping` * packing and optionally coercing, parsing and validating packed objects, i.e. objects received to, for example, *args and **kwargs. +* positional-only parameters (those defined ahead of a `/` in the signature). As when + calling an undecorated function, a positional-only parameter can only be satisfied + positionally; a keyword input matching its name is absorbed by **kwargs if the + signature provides for **kwargs, otherwise it raises. `valimp` does NOT support: - Validation of subscripted types in `collections.abc.Callable`. Any subscriptions to diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index 428d8a2..24dc7e9 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -1708,12 +1708,15 @@ "\n", "Valimp also validates that inputs conform with a function's signature.\n", "\n", + "Positional-only parameters (those defined ahead of a `/` in the signature) are supported. As when calling an undecorated function, a positional-only parameter can only be satisfied positionally; a keyword input matching its name is absorbed by `**kwargs` if the signature provides for `**kwargs`, otherwise it raises.\n", + "\n", "A `valimp.InputsError` will be raised if at least one of the following is true.\n", "* A required argument is not passed (missing positional argument).\n", "* A required keyword-only argument is not passed (missing keyword-only argument).\n", "* A keyword argument is passed that is not represented in the signature (unexpected keyword argument).\n", "* More arguments are passed positionally than accommodated for by the signature (excess positional arguments).\n", "* A parameter is passed both positionally and as a keyword argument (got multiple values).\n", + "* A positional-only argument is passed as a keyword argument (received positional-only argument as keyword argument).\n", "\n", "All signature errors are advised in the error message, together with any errors relating to invalid types." ] @@ -1728,7 +1731,9 @@ "@parse\n", "def pf(\n", " a: int,\n", + " /, # 'a' is positional-only\n", " b: int,\n", + " c: int,\n", " *,\n", " kw_a: int,\n", "):\n", @@ -1742,7 +1747,7 @@ "metadata": {}, "outputs": [], "source": [ - "pf(3, \"not an int\", 5, a=3, not_a_kwarg=3)" + "pf(3, \"not an int\", 4, 5, a=3, c=2, not_a_kwarg=3)" ] }, { @@ -1754,17 +1759,19 @@ "---------------------------------------------------------------------------\n", "InputsError Traceback (most recent call last)\n", "Cell In[47], line 1\n", - "----> 1 pf(3, \"not an int\", 5, a=3, not_a_kwarg=3)\n", + "----> 1 pf(3, \"not an int\", 4, 5, a=3, c=2, not_a_kwarg=3)\n", "\n", "InputsError: Inputs to 'pf' do not conform with the function signature:\n", "\n", - "Got multiple values for argument: 'a'.\n", + "Got multiple values for argument: 'c'.\n", "\n", "Received 1 excess positional argument as:\n", "\t'5' of type .\n", "\n", "Got unexpected keyword argument: 'not_a_kwarg'.\n", "\n", + "Received positional-only argument as keyword argument: 'a'.\n", + "\n", "Missing 1 keyword-only argument: 'kw_a'.\n", "\n", "The following inputs to 'pf' do not conform with the corresponding type annotation:\n", @@ -1797,7 +1804,7 @@ "\n", "InputsError: Inputs to 'pf' do not conform with the function signature:\n", "\n", - "Missing 1 positional argument: 'b'.\n", + "Missing 2 positional arguments: 'b' and 'c'.\n", "```" ] },