From 7b6ab4bf0ffca30adf435028444ba9da9b599867 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 17:18:04 +0000 Subject: [PATCH] Support validation of subscripted collections.abc.Callable types Previously valimp only verified that an input annotated with `collections.abc.Callable` was callable; any subscripted argument types and return type were ignored. Validation now additionally checks a callable input's signature against the subscripted types: - the number of positional arguments accepted by the input is validated as accommodating the number of subscripted argument types (unless the arguments are subscripted as `...`); - where the input annotates a parameter or its return, that annotation is validated as matching the corresponding subscripted type (unannotated parameters/return are treated as `typing.Any`). Validation is skipped (and passes) where the input's signature cannot be introspected, as can be the case for some built-in callables. Tests, the README example and the tutorial are updated to exhibit the new functionality. --- README.md | 21 +++- docs/tutorials/tutorial.ipynb | 102 +++++++++++++++---- src/valimp/valimp.py | 187 +++++++++++++++++++++++++++++++++- tests/test_valimp.py | 161 ++++++++++++++++++++++++++++- 4 files changed, 442 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0a418ff..a4b9960 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This is the sole use of `valimp`. It's a single short module with no depenencies Works like this: ```python from valimp import parse, Parser, Coerce +from collections.abc import Callable from typing import Annotated, Union, Optional, Any @parse # add the `valimp.parse`` decorator to a public function or method @@ -43,6 +44,9 @@ def public_function( h: type[int], # ... or of specific classes... i: type[Union[int, str]], # type[int | str] + # validate input is a callable whose signature conforms with the + # subscripted argument types and return type... + l: Callable[[str, int], bool], # support for packing extra arguments if required, can be optionally typed... *args: Annotated[ Union[int, float, str], # int | float | str @@ -58,10 +62,14 @@ def public_function( # support for packing excess kwargs if required, can be optionally typed... # **kwargs: Union[int, float] ) -> dict[str, Any]: - 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} + return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "g":g, "h":h, "i":i, "l":l, "args":args, "j":j, "k":k} + +def conforming_callable(spam: str, foo: int) -> bool: + # conforms with the `Callable[[str, int], bool]` annotation of 'l' + return True public_function( - # NB parameters 'a' through 'i' can be passed positionally + # NB parameters 'a' through 'l' can be passed positionally "zero", # a 1.0, # b {"two": 2}, # c @@ -71,6 +79,7 @@ public_function( str, # g bool, # h, a subclass of int int, # i, one of the subscripted classes + conforming_callable, # l "10", # extra arg, will be coerced to int and packed 20, # extra arg, will be packed j="keyword_arg_j", @@ -88,6 +97,7 @@ returns: 'g': , 'h': , 'i': , + 'l': , 'args': (10, 20), 'j': 'keyword_arg_j', 'k': 1.0} @@ -104,6 +114,7 @@ public_function( g=str, # valid input h=str, # INVALID, str is not int or a subclass of int i=bool, # valid input + l=lambda spam: True, # INVALID, accepts 1 positional arg, not 2 j="valid input", ) ``` @@ -125,6 +136,9 @@ f h Takes a subclass of although received ''. + +l + Takes a callable that accepts 2 positional arguments, although the received callable ' at 0x...>' does not. ``` And if the inputs do not match the signature... ```python @@ -214,7 +228,6 @@ In short, if you only want to validate the type of function inputs then Pydantic positional-only arguments) will be ignored. Consequently valimp DOES allow intended positional-only arguments to be passed as keyword arguments. - - Validation of subscripted types in `collections.abc.Callable` (although Valimp will verify that the passed value is callable). `valimp` currently supports: * use of the following type annotations: @@ -226,7 +239,7 @@ In short, if you only want to validate the type of function inputs then Pydantic * typing.Literal * typing.Union ( `|` from 3.10 ) * typing.Optional ( ` | None` from 3.10) - * collections.abc.Callable, although validation of subscripted types is **not** supported + * collections.abc.Callable, including subscripted types, for example `collections.abc.Callable[[str, int], bool]`, to validate that an input is a callable whose signature conforms with the subscripted argument types and return type * `type`, including subscripted types, for example `type[int]`, to validate that an input is a subclass of the subscripted type * validation of container items for the following generic classes: * `list` diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index c2fa3f7..b0a78c7 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -1093,20 +1093,26 @@ }, { "cell_type": "markdown", - "id": "fcd08875-402c-4e74-8890-c343b6cd5e0f", + "id": "fdb7b2fb", "metadata": {}, "source": [ "### `collections.abc.Callable`\n", "\n", - "Valimp validates that inputs to parameters annotated with `collections.abc.Callable` are indeed callable. However, it will **not** validate any subscriptions.\n", + "Valimp validates that inputs to parameters annotated with `collections.abc.Callable` are indeed callable. If the annotation is subscripted then the input's signature is additionally validated against the subscripted argument types and return type.\n", "\n", - "Notice how the inputs in the following example do not conform with the subscriptions although the input is validated regardless. (The first argument to `collections.abc.Callable` takes a sequence of annotations describing the callable's arguments, the second argument takes the callable's return type.)" + "(The first argument to `collections.abc.Callable` takes a sequence of annotations describing the callable's arguments, the second argument takes the callable's return type.)\n", + "\n", + "Validation of subscriptions is undertaken as follows:\n", + "- The number of positional arguments accepted by the input is validated as accommodating the number of subscripted argument types (unless the arguments are subscripted as `...`).\n", + "- Where the input annotates a parameter or its return, that annotation is validated as matching the corresponding subscripted type. Any parameter or the return that the input does not annotate is not validated (it is treated as `typing.Any`).\n", + "\n", + "Validation of subscriptions is skipped (and validation passes) if the input's signature cannot be introspected, as can be the case for some built-in callables." ] }, { "cell_type": "code", - "execution_count": 31, - "id": "44232c3f-b399-492b-a66c-1ab4ce983cc2", + "id": "32ffc90e", + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -1121,42 +1127,102 @@ "):\n", " return\n", "\n", - "def some_func(a: float, b: float) -> float:\n", - " return a + b\n", + "def func_str(a: str) -> str:\n", + " return a\n", + "\n", + "def func_str_int(a: str, b: int) -> str:\n", + " return a\n", "\n", - "# inputs are validated even though they do not conform with the subscriptions\n", - "pf(some_func, some_func, some_func, some_func)" + "# all inputs conform with the subscriptions\n", + "pf(func_str, func_str, func_str_int, func_str_int)" ] }, { "cell_type": "markdown", - "id": "f306c6fd-4007-46c9-82c9-19d59e7b053f", + "id": "3cbc94f4", "metadata": {}, "source": [ - "Although an error will be raised if the input is not callable..." + "Unannotated callables, for example lambda functions, conform on the basis of arity alone (their parameters and return are treated as `typing.Any`)." ] }, { "cell_type": "code", + "id": "fb806256", "execution_count": null, - "id": "1b93e18e-7121-4fce-a958-d2048f7c280c", "metadata": {}, "outputs": [], "source": [ - "pf(3, \"can't call me\", some_func, some_func)" + "# the inputs conform as each accepts the required number of positional arguments\n", + "pf(lambda x: x, lambda x: x, lambda x, y: x, lambda: None)" ] }, { "cell_type": "markdown", - "id": "81377cab-1d9c-47ce-accb-6e56b4db09ca", + "id": "bfd2ca11", + "metadata": {}, + "source": [ + "An error is raised if an input's signature does not conform with the subscriptions. In the following example the input to `b` annotates its parameter as `int` rather than `str`, the input to `c` does not accept the required number of arguments and the input to `d` annotates its return as `int` rather than `str`." + ] + }, + { + "cell_type": "code", + "id": "79b37922", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def func_int(a: int) -> str:\n", + " return str(a)\n", + "\n", + "def returns_int(a: str, b: int) -> int:\n", + " return b\n", + "\n", + "pf(func_str_int, func_int, func_str, returns_int)" + ] + }, + { + "cell_type": "markdown", + "id": "be247603", "metadata": {}, "source": [ "```\n", - "---------------------------------------------------------------------------\n", - "InputsError Traceback (most recent call last)\n", - "Cell In[32], line 1\n", - "----> 1 pf(3, \"can't call me\", some_func, some_func)\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "b\n", + "\tTakes a callable with parameter 0 annotated as , although the received callable '' annotates the corresponding parameter 'a' as .\n", "\n", + "c\n", + "\tTakes a callable that accepts 2 positional arguments, although the received callable '' does not.\n", + "\n", + "d\n", + "\tTakes a callable with return annotated as , although the received callable '' annotates its return as .\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "f8509306", + "metadata": {}, + "source": [ + "An error is also raised if an input is not callable..." + ] + }, + { + "cell_type": "code", + "id": "66422a03", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pf(3, \"can't call me\", func_str_int, func_str_int)" + ] + }, + { + "cell_type": "markdown", + "id": "63f0e05f", + "metadata": {}, + "source": [ + "```\n", "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", "\n", "a\n", diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 83567e1..8a2b69a 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -174,9 +174,23 @@ class ADataclass: collections.abc.Callable. Example: f(param: collections.abc.Callable) - Subscriptions of Callable are ignored, i.e. the following will be - validated in the same way as the above unsubscripted example: + If Callable is subscripted then the input's signature will be + validated against the subscripted argument types and return type. For + example, the following will validate that 'param' receives a callable + that can be called with two positional arguments: f(param: collections.abc.Callable[[str, int], int]) + Validation of subscriptions is undertaken as follows: + - The number of positional arguments accepted by the input is + validated as accommodating the number of subscripted argument + types (unless the arguments are subscripted as `...`, for example + `collections.abc.Callable[..., int]`). + - Where the input annotates a parameter or its return, that + annotation is validated as matching the corresponding subscripted + type. Parameters and the return that are not annotated by the + input are not validated (they are treated as `typing.Any`). + Validation of subscriptions is skipped, and validation passes, if the + input's signature cannot be introspected (as can be the case for some + built-in callables). `type`. Example: f(param: type) @@ -431,6 +445,169 @@ def validates_as_subclass(obj: Any, hint: type[Any] | typing._Final) -> bool: return False +def get_callable_positional_params( + sig: inspect.Signature, +) -> tuple[list[inspect.Parameter], inspect.Parameter | None]: + """Get the positional parameters of a callable's signature. + + Parameters + ---------- + sig + Signature of the callable being introspected. + + Returns + ------- + tuple[list[inspect.Parameter], inspect.Parameter | None] + [0] Positional parameters (positional-only and + positional-or-keyword) in order. + + [1] Any variadic positional parameter (i.e. `*args`), or None + if the callable does not define one. + """ + positional = [] + var_positional = None + for param in sig.parameters.values(): + if param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ): + positional.append(param) + elif param.kind is inspect.Parameter.VAR_POSITIONAL: + var_positional = param + return positional, var_positional + + +def validates_against_callable_hint( # noqa: C901, PLR0911, PLR0912 + obj: Any, + hint_args: tuple[Any, ...], + rtrn_error: bool = True, # noqa: FBT001, FBT002 +) -> tuple[bool, TypeError | None]: + """Query if a callable conforms with a subscripted `Callable` hint. + + Validates the signature of `obj` against the subscripted argument + types and return type of a `collections.abc.Callable` hint. By the + time this function is called `obj` will already have been verified as + being callable. + + Validation is undertaken as follows: + - The number of positional arguments accepted by `obj` is + validated as accommodating the number of subscripted argument + types (unless the arguments are subscripted as `...`). + + - Where `obj` annotates a parameter or its return, that + annotation is validated as matching the corresponding subscripted + type. Parameters and the return that are not annotated by `obj` + are not validated (they are treated as `typing.Any`). + + Subscripted types cannot be verified, and validation passes, if the + signature of `obj` cannot be introspected (as can be the case for some + built-in callables). + + Parameters + ---------- + obj + Callable to validate against the subscripted hint. + + hint_args + Arguments of the subscripted `Callable` hint as returned by + `typing.get_args`. Takes the form `(parameters, return)` where + `parameters` is either a list of the expected argument types or + `Ellipsis` (for `Callable[..., ]`). + + rtrn_error + Whether to include an error in the return if validation fails. + + Returns + ------- + tuple[bool, TypeError | None] + [0] bool indicating if `obj` conforms with the subscripted hint. + + [1] None if `obj` conforms or `rtrn_error` is False, otherwise + error advising why validation failed. + """ + params_hint, return_hint = hint_args[0], hint_args[1] + + try: + sig = inspect.signature(obj) + except (ValueError, TypeError): + # signature cannot be introspected, hence subscripted types cannot + # be verified, for example as can be the case for some built-ins. + return VALIDATED + + try: + resolved = typing.get_type_hints(obj) + except Exception: # noqa: BLE001 + # annotations cannot be resolved (e.g. unresolvable forward + # references), fall back to the raw signature annotations. + resolved = {} + + def annotation_of(param: inspect.Parameter) -> Any: + ann = resolved.get(param.name, param.annotation) + return None if ann is inspect.Parameter.empty else ann + + if params_hint is not Ellipsis: + positional, var_positional = get_callable_positional_params(sig) + num_required = sum( + 1 for p in positional if p.default is inspect.Parameter.empty + ) + num_max = len(positional) + num_expected = len(params_hint) + has_required_kwonly = any( + p.kind is inspect.Parameter.KEYWORD_ONLY + and p.default is inspect.Parameter.empty + for p in sig.parameters.values() + ) + if var_positional is not None: + accepts = num_expected >= num_required + else: + accepts = num_required <= num_expected <= num_max + if has_required_kwonly: + accepts = False + if not accepts: + if not rtrn_error: + return FAILED_SIMPLE + plural = "s" if num_expected != 1 else "" + return False, TypeError( + f"Takes a callable that accepts {num_expected} positional" + f" argument{plural}, although the received callable '{obj}' does" + f" not." + ) + + for i, expected in enumerate(params_hint): + if expected is typing.Any or i >= len(positional): + # arguments absorbed by `*args` are not validated per-position + continue + param = positional[i] + actual = annotation_of(param) + if actual is None or actual is typing.Any: + continue + if actual != expected: + if not rtrn_error: + return FAILED_SIMPLE + return False, TypeError( + f"Takes a callable with parameter {i} annotated as {expected}," + f" although the received callable '{obj}' annotates the" + f" corresponding parameter '{param.name}' as {actual}." + ) + + if return_hint is not typing.Any: + actual_return = resolved.get("return", sig.return_annotation) + if ( + actual_return is not inspect.Signature.empty + and actual_return is not typing.Any + and actual_return != return_hint + ): + if not rtrn_error: + return FAILED_SIMPLE + return False, TypeError( + f"Takes a callable with return annotated as {return_hint}, although" + f" the received callable '{obj}' annotates its return as" + f" {actual_return}." + ) + + return VALIDATED + + def validates_against_hint( # noqa: C901, PLR0911, PLR0912 obj: Any, hint: type[Any] | typing._Final, @@ -540,8 +717,10 @@ def validates_against_hint( # noqa: C901, PLR0911, PLR0912 ) if origin is collections.abc.Callable: - # validation of any subscripted types is not currently supported - return VALIDATED + if not hint_args: + # unsubscripted, validated as callable by the isinstance check above + return VALIDATED + return validates_against_callable_hint(obj, hint_args, rtrn_error=rtrn_error) if origin is type: if not hint_args or validates_as_subclass(obj, hint_args[0]): diff --git a/tests/test_valimp.py b/tests/test_valimp.py index f033f04..5e331b9 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -23,6 +23,7 @@ # ruff: noqa: E741 # ambiguous-variable-name. Use 'l' here as a parameter. # ruff: noqa: PYI051 # reduntant-literal-union. Need to test for possible usage regardless. # ruff: noqa: ARG001 # unused-function-argument. Just the nature, want to test errors raised during parsing before args received, not interested in the args as received. +# ruff: noqa: ARG005 # unused-lambda-argument. Lambdas used to exercise callable signature validation, their arguments are not used. # ruff: noqa: E501 # line-too-long # In Python 3.14+ repr(typing.Union[X, Y]) changed from "typing.Union[X, Y]" to "X | Y". @@ -471,8 +472,8 @@ def dflt_values() -> abc.Iterator[dict[str, Any]]: yield dict(z=None, aa=4, bb=None, cc=True, kwonly_opt=None) -def _func(x: Any) -> Any: - return x +def _func(*args: Any) -> Any: + return args FUNC = _func @@ -895,7 +896,7 @@ def test_invalid_types(f, inst, f_with_packed, datacls, valid_args): {"not": "a sequence"}, # q ["foo", "bar"], # valid # r "not callable", # s - lambda x: x, # valid # t + lambda a, b: a, # valid # t lambda x: x, # valid # u lambda x: x, # valid # v ["list not in union"], # w @@ -914,6 +915,160 @@ def test_invalid_types(f, inst, f_with_packed, datacls, valid_args): ) +def test_callable_subscriptions(): + """Verify validation of subscripted `collections.abc.Callable` hints.""" + + @m.parse + def f( + a: abc.Callable, + b: abc.Callable[[str], str], + c: abc.Callable[[str, int], str], + d: abc.Callable[..., str], + ) -> tuple: + return a, b, c, d + + def good_b(x: str) -> str: + return x + + def good_c(x: str, y: int) -> str: + return x + + # all conform: matching annotations, matching arity, and lambda/`...`. + rtrn = f(good_b, good_b, good_c, lambda *args: "x") + assert rtrn == (good_b, good_b, good_c, lambda *args: "x") or rtrn[0] is good_b + + # unannotated callables conform where arity matches (treated as `Any`). + assert f(lambda x: x, lambda x: x, lambda x, y: x, lambda: "x") + + # a callable with `*args` conforms to any positional arity. + def variadic(*args: object) -> str: + return "x" + + assert f(variadic, variadic, variadic, variadic) + + # a callable with defaults conforms where its required arity is met. + def with_default(x: str, y: int = 0) -> str: + return x + + assert f(good_b, good_b, with_default, with_default) + + # callable instances are validated via their `__call__` signature. + class Caller: + def __call__(self, x: str) -> str: + return x + + assert f(Caller(), Caller(), good_c, Caller()) + + +def test_callable_subscriptions_invalid_arity(): + """Verify error if callable does not accept the subscripted arity.""" + + @m.parse + def f(a: abc.Callable[[str, int], str]) -> abc.Callable: + return a + + # too few positional arguments + with pytest.raises( + m.InputsError, + match="Takes a callable that accepts 2 positional arguments", + ): + f(lambda x: x) + + # too many required positional arguments + with pytest.raises( + m.InputsError, + match="Takes a callable that accepts 2 positional arguments", + ): + f(lambda x, y, z: x) + + # required keyword-only argument cannot be satisfied positionally + def kwonly(x: str, y: int, *, z: bool) -> str: + return x + + with pytest.raises( + m.InputsError, + match="Takes a callable that accepts 2 positional arguments", + ): + f(kwonly) + + +def test_callable_subscriptions_invalid_annotations(): + """Verify error if callable annotations do not match subscriptions.""" + + @m.parse + def f(a: abc.Callable[[str], str]) -> abc.Callable: + return a + + # parameter annotation mismatch + def bad_param(x: int) -> str: + return str(x) + + with pytest.raises( + m.InputsError, + match=re.escape("Takes a callable with parameter 0 annotated as "), + ): + f(bad_param) + + # return annotation mismatch + def bad_return(x: str) -> int: + return len(x) + + with pytest.raises( + m.InputsError, + match=re.escape("Takes a callable with return annotated as "), + ): + f(bad_return) + + +def test_callable_subscriptions_ellipsis_and_return(): + """Verify `...` skips arity but the return is still validated.""" + + @m.parse + def f(a: abc.Callable[..., str]) -> abc.Callable: + return a + + # `...` accepts any arity, including required keyword-only arguments + assert f(lambda: "x") + assert f(lambda x, y, z: "x") + + def kwonly(*, z: bool) -> str: + return "x" + + assert f(kwonly) + + # unannotated return conforms (treated as `Any`) + assert f(lambda x: x) + + # ...but a non-matching annotated return does not conform. + def bad_return(x: str) -> int: + return len(x) + + with pytest.raises( + m.InputsError, + match=re.escape("Takes a callable with return annotated as "), + ): + f(bad_return) + + +def test_callable_subscriptions_non_introspectable(): + """Verify validation passes where signature cannot be introspected.""" + + @m.parse + def f(a: abc.Callable[[str], str]) -> abc.Callable: + return a + + # `range` is a built-in whose signature cannot be introspected; valimp + # cannot verify the subscriptions and so validation passes. + try: + inspect.signature(range) + except (ValueError, TypeError): + introspectable = False + else: + introspectable = True + if not introspectable: + assert f(range) is range + + INVALID_MSG_SIG_SINGLE = re.escape( """Inputs to 'func' do not conform with the function signature: