From ba02cfa898b2254d90f5d73cd9b76bc63b09d9ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:35:29 +0000 Subject: [PATCH 1/6] Support the `type` annotation Add validation support for the `type` annotation, including subscripted forms such as `type[int]` and `type[Union[int, str]]`. A subscripted `type` validates that the input is a subclass of the subscripted type (which may be a union). The unsubscripted `type` annotation already validated that an input was a class. Add tests covering valid and invalid inputs, together with use of `Coerce` and `Parser`. Update the README and tutorial notebook to demonstrate and confirm support for the `type` annotation. https://claude.ai/code/session_01Ast6azhb3Aj7tSFqa4Sx7G --- README.md | 49 ++++++++++++++++++++ docs/tutorials/tutorial.ipynb | 79 ++++++++++++++++++++++++++++++++ src/valimp/valimp.py | 47 ++++++++++++++++++++ tests/test_valimp.py | 84 +++++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+) diff --git a/README.md b/README.md index d5f9d64..b3cd20a 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,54 @@ output: {'a': "I'm a and will appear at the end of b", 'b': "33 b I'm a and will appear at the end of b"} ``` +Validate that an input is a class (rather than an instance) with the `type` annotation. Subscript `type` to validate that the input is a subclass of a specific type... +```python +from valimp import parse + +class Animal: + pass + +class Dog(Animal): + pass + +@parse +def public_function( + # validate that input is any class + a: type, + # validate that input is a subclass of 'Animal' (including 'Animal' itself) + b: type[Animal], + # the subscripted type can be a union + c: type[Union[int, str]], +) -> tuple: + return a, b, c + +public_function( + dict, # a, valid, any class + Dog, # b, valid, a subclass of Animal + str, # c, valid, str is in the union +) # returns (dict, Dog, str) +``` +And if there are invalid inputs... +```python +public_function( + 3, # INVALID, an instance, not a class + int, # INVALID, not a subclass of Animal + float, # INVALID, not in the union +) +``` +raises: +``` +InputsError: The following inputs to 'public_function' do not conform with the corresponding type annotation: + +a + Takes type although received '3' of type . + +b + Takes a subclass of although received '' of type . + +c + Takes a subclass of typing.Union[int, str] although received '' of type . +``` ## Installation `$ pip install valimp` @@ -210,6 +258,7 @@ In short, if you only want to validate the type of function inputs then Pydantic * typing.Union ( `|` from 3.10 ) * typing.Optional ( ` | None` from 3.10) * collections.abc.Callable, although validation of subscripted types is **not** supported + * `type`, including subscripted, 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` * `dict` diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index 2c133b0..f1e6a24 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -1166,6 +1166,85 @@ "```" ] }, + { + "cell_type": "markdown", + "id": "5622fe47", + "metadata": {}, + "source": [ + "### `type`\n", + "\n", + "Valimp validates that inputs to parameters annotated with `type` are classes (as opposed to instances). If `type` is subscripted then the input is additionally validated as being a subclass of the subscripted type. The subscripted type can be a union, in which case the input is validated as being a subclass of one of the union members." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0fb179b8", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Union\n", + "\n", + "class Animal:\n", + " pass\n", + "\n", + "class Dog(Animal):\n", + " pass\n", + "\n", + "@parse\n", + "def pf(\n", + " a: type, # any class\n", + " b: type[Animal], # a subclass of Animal (Animal itself also valid)\n", + " c: type[Union[int, str]], # a subclass of int or str\n", + ") -> tuple:\n", + " return a, b, c\n", + "\n", + "# valid inputs\n", + "pf(dict, Dog, str)" + ] + }, + { + "cell_type": "markdown", + "id": "15404077", + "metadata": {}, + "source": [ + "Although an error is raised if an input is not a class, or is not a subclass of the subscripted type..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "721fbb69", + "metadata": {}, + "outputs": [], + "source": [ + "pf(3, int, float)" + ] + }, + { + "cell_type": "markdown", + "id": "9423904d", + "metadata": {}, + "source": [ + "```\n", + "---------------------------------------------------------------------------\n", + "InputsError Traceback (most recent call last)\n", + "Cell In[33], line 1\n", + "----> 1 pf(3, int, float)\n", + "\n", + "InputsError: The following inputs to 'pf' do not conform with the corresponding type annotation:\n", + "\n", + "a\n", + "\tTakes type although received '3' of type .\n", + "\n", + "b\n", + "\tTakes a subclass of although received '' of type .\n", + "\n", + "c\n", + "\tTakes a subclass of typing.Union[int, str] although received '' of type .\n", + "```" + ] + }, { "cell_type": "markdown", "id": "6af1bf68-31d9-496a-9233-7e51a1622199", diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 24f0588..bdde81d 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -178,6 +178,17 @@ class ADataclass: validated in the same way as the above unsubscripted example: f(param: collections.abc.Callable[[str, int], int]) + `type`. Example: + f(param: type) + If the type is subscripted then the input will be validated as being a + subclass of the subscripted type. For example, the following will + validate that 'param' receives a subclass of int (including int + itself): + f(param: type[int]) + The subscripted argument can be a union, in which case the input will + be validated as being a subclass of one of the union members: + f(param: type[Union[int, str]]) + NO_ITEM_VALIDATION Validation of the type of items in a container can be skipped for any parameter by using `typing.Annotated` to define the annotation and @@ -395,6 +406,31 @@ def __init__( STRICT_LITERAL = "dlkj3ow62" +def validates_as_subclass(obj: Any, hint: type[Any] | typing._Final) -> bool: + """Query if a class conforms with the argument to a `type` hint. + + Parameters + ---------- + obj + Class to validate as conforming with `hint`. + + hint + Argument to a `type` hint, for example `int` for the hint + `type[int]`. Can be a single type, `typing.Any` or a union of + types. + """ + if hint is typing.Any: + return True + if typing.get_origin(hint) is typing.Union or ( + hasattr(types, "UnionType") and isinstance(hint, types.UnionType) + ): + return any(validates_as_subclass(obj, arg) for arg in typing.get_args(hint)) + try: + return issubclass(obj, hint) + except TypeError: + return False + + def validates_against_hint( # noqa: C901, PLR0911, PLR0912 obj: Any, hint: type[Any] | typing._Final, @@ -507,6 +543,17 @@ def validates_against_hint( # noqa: C901, PLR0911, PLR0912 # validation of any subscripted types is not currently supported return VALIDATED + if origin is type: + # `obj` already validated as being a class (isinstance(obj, type)) + if not hint_args or validates_as_subclass(obj, hint_args[0]): + return VALIDATED + if not rtrn_error: + return FAILED_SIMPLE + return False, TypeError( + f"Takes a subclass of {hint_args[0]} although received '{obj}'" + f" of type {type(obj)}." + ) + if origin is tuple and hint_args[-1] is not Ellipsis and len(obj) != len(hint_args): if not rtrn_error: return FAILED_SIMPLE diff --git a/tests/test_valimp.py b/tests/test_valimp.py index 9fb41aa..8fc0e67 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2035,3 +2035,87 @@ def f_int(a: int) -> int: # nor should a complex be accepted where a float is annotated. with pytest.raises(m.InputsError, match=r"Takes type "): f_float(complex_in) + + +class _Animal: + pass + + +class _Dog(_Animal): + pass + + +def test_type_annotation_valid(): + """Verify validation of `type` and subscripted `type` annotations.""" + + @m.parse + def f( + a: type, + b: type[int], + c: type[_Animal], + d: type[Union[int, str]], + e: typing.Type[int], # noqa: UP006 + f_meta: Annotated[type[int], "some_meta"], + g: Optional[type[int]], + ) -> tuple: + return a, b, c, d, e, f_meta, g + + # bare `type` takes any class + rtrn = f(dict, int, _Animal, str, int, int, None) + assert rtrn == (dict, int, _Animal, str, int, int, None) + + # subclasses (including the class itself) are valid + rtrn = f(list, bool, _Dog, int, int, int, int) + assert rtrn == (list, bool, _Dog, int, int, int, int) + + +def test_type_annotation_invalid(): + """Verify errors raised for invalid `type` annotation inputs.""" + + @m.parse + def f( + a: type[int], + b: type[_Animal], + c: type[Union[int, str]], + ) -> tuple: + return a, b, c + + # a class that is not a subclass of the subscripted type + msg_a = re.escape("a\n\tTakes a subclass of although received") + with pytest.raises(m.InputsError, match=msg_a): + f(str, _Animal, int) + + msg_b = re.escape( + "b\n\tTakes a subclass of although received" + ) + with pytest.raises(m.InputsError, match=msg_b): + f(int, int, int) + + msg_c = _msg_re("c\n\tTakes a subclass of typing.Union[int, str] although received") + with pytest.raises(m.InputsError, match=msg_c): + f(int, _Animal, float) + + # an instance (not a class) is not valid for a `type` annotation + msg_inst = re.escape("Takes type although received '3'") + with pytest.raises(m.InputsError, match=msg_inst): + f(3, _Animal, int) + + +def test_type_annotation_coerce_parse(): + """Verify Coerce and Parser work with a `type` annotation.""" + + @m.parse + def f_coerce(a: Annotated[type[int], m.Coerce(str)]) -> str: + return a + + # the class is coerced (here to its string representation) + assert f_coerce(int) == str(int) + + @m.parse + def f_parser( + a: Annotated[type[_Animal], m.Parser(lambda _n, obj, _p: obj())], + ) -> _Animal: + return a + + # parser receives the class and here instantiates it + assert isinstance(f_parser(_Dog), _Dog) From 855c3e6c8fe590ef9f27bde460a60c94987dc318 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:37:13 +0000 Subject: [PATCH 2/6] Fix type annotation test for Python 3.14 Union repr Python 3.14 changed repr(typing.Union[int, str]) to "int | str". Add the "int, str" mapping to the test helper so the expected error message regex matches on both older Pythons and 3.14. https://claude.ai/code/session_01Ast6azhb3Aj7tSFqa4Sx7G --- tests/test_valimp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_valimp.py b/tests/test_valimp.py index 8fc0e67..a590630 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -29,6 +29,7 @@ _UNION_TYPE_REPRS = { "str, int, set": "str | int | set", "int, float": "int | float", + "int, str": "int | str", } From 65fa411059135495c75d6ac31ada53a3d6619c29 Mon Sep 17 00:00:00 2001 From: Marcus Read Date: Tue, 2 Jun 2026 16:54:46 +0100 Subject: [PATCH 3/6] Manual revisions --- README.md | 2 +- docs/tutorials/tutorial.ipynb | 90 ++++++++++++++++++++--------------- src/valimp/valimp.py | 11 ++--- tests/test_valimp.py | 73 ++++++++++++++-------------- 4 files changed, 92 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index b3cd20a..566b367 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ In short, if you only want to validate the type of function inputs then Pydantic * typing.Union ( `|` from 3.10 ) * typing.Optional ( ` | None` from 3.10) * collections.abc.Callable, although validation of subscripted types is **not** supported - * `type`, including subscripted, for example `type[int]`, to validate that an input is a subclass of the subscripted 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` * `dict` diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index f1e6a24..dcb99c0 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -24,6 +24,7 @@ " - [`valimp.NO_ITEM_VALIDATION`](#valimp.NO_ITEM_VALIDATION)\n", " - [Nested containers](#Nested-containers)\n", " - [`collections.abc.Callable`](#collections.abc.Callable)\n", + " - [`type`](#type)\n", " - [`typing.Literal`](#typing.Literal)\n", " - [*args and **kwargs](#*args-and-**kwargs)\n", "- [Signature validation](#Signature-validation)\n", @@ -1173,15 +1174,26 @@ "source": [ "### `type`\n", "\n", - "Valimp validates that inputs to parameters annotated with `type` are classes (as opposed to instances). If `type` is subscripted then the input is additionally validated as being a subclass of the subscripted type. The subscripted type can be a union, in which case the input is validated as being a subclass of one of the union members." + "Valimp validates that inputs to parameters annotated with `type` are classes (as opposed to instances). If `type` is subscripted then the input is additionally validated as being a subclass of the subscripted type (or the subscripted type itself). The subscripted type can be a union, in which case the input is validated as being a subclass of one of the union members (of one of the union members themselves)." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 33, "id": "0fb179b8", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "(dict, __main__.Dog, str)" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from typing import Union\n", "\n", @@ -1259,7 +1271,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 35, "id": "f9041474-ec0a-4488-8e80-ddcbbcbe6069", "metadata": {}, "outputs": [], @@ -1342,7 +1354,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 37, "id": "c6162215-5749-4be5-a8d2-683ac53748f0", "metadata": {}, "outputs": [], @@ -1371,7 +1383,7 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 38, "id": "12a4aca1-005f-469c-b895-e8473ac50e86", "metadata": {}, "outputs": [ @@ -1381,7 +1393,7 @@ "'spam'" ] }, - "execution_count": 36, + "execution_count": 38, "metadata": {}, "output_type": "execute_result" } @@ -1402,7 +1414,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 39, "id": "bb10d385-cf0e-412f-ae74-2a98aa6aea95", "metadata": {}, "outputs": [], @@ -1458,7 +1470,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 41, "id": "50470b59-5421-4036-8c93-d80624bac11d", "metadata": {}, "outputs": [], @@ -1475,7 +1487,7 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 42, "id": "a56eb1f1-14d7-41d7-8b40-23c54f73b88a", "metadata": {}, "outputs": [ @@ -1485,7 +1497,7 @@ "(3, (4, 5.0, 'six'), False, {'kw_extra0': 'extra0', 'kw_extra1': [1, 1]})" ] }, - "execution_count": 40, + "execution_count": 42, "metadata": {}, "output_type": "execute_result" } @@ -1504,7 +1516,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 43, "id": "e9dead85-ba39-425f-9858-9565230e2a1b", "metadata": {}, "outputs": [], @@ -1521,7 +1533,7 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 44, "id": "6880feb1-5e76-430a-9bee-24ad32ff85bb", "metadata": {}, "outputs": [ @@ -1531,7 +1543,7 @@ "(3, (4, 5.0), False, {'kw_b': True, 'kw_c': False})" ] }, - "execution_count": 42, + "execution_count": 44, "metadata": {}, "output_type": "execute_result" } @@ -1586,7 +1598,7 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": 46, "id": "1eca3fca-eef8-425d-998d-d0deef4fc3ff", "metadata": {}, "outputs": [], @@ -1655,7 +1667,7 @@ }, { "cell_type": "code", - "execution_count": 46, + "execution_count": 48, "id": "eff9ce77-2c87-40ca-81c9-4faa54be0997", "metadata": {}, "outputs": [], @@ -1752,7 +1764,7 @@ }, { "cell_type": "code", - "execution_count": 49, + "execution_count": 51, "id": "aa10cb66-5db5-425a-ba4e-fad8e7e8d3eb", "metadata": {}, "outputs": [], @@ -1773,7 +1785,7 @@ }, { "cell_type": "code", - "execution_count": 50, + "execution_count": 52, "id": "8aba30cd-bf06-4b86-b9fd-958bb226d29f", "metadata": {}, "outputs": [ @@ -1788,7 +1800,7 @@ " 'args': (1.0, 2.0, 3.3, None)}" ] }, - "execution_count": 50, + "execution_count": 52, "metadata": {}, "output_type": "execute_result" } @@ -1862,7 +1874,7 @@ }, { "cell_type": "code", - "execution_count": 51, + "execution_count": 53, "id": "872b9027-3aff-4901-859b-f8b1163b5a54", "metadata": {}, "outputs": [ @@ -1872,7 +1884,7 @@ "{'a': 'input_a', 'b': 'input_b_suffix'}" ] }, - "execution_count": 51, + "execution_count": 53, "metadata": {}, "output_type": "execute_result" } @@ -1903,7 +1915,7 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": 54, "id": "3639b3c6-eb1f-493c-a62c-47b36e460fcb", "metadata": {}, "outputs": [ @@ -1917,7 +1929,7 @@ " 'kwargs': {'kw_xtra': \"I'm kw_xtra_kw_xtra\"}}" ] }, - "execution_count": 52, + "execution_count": 54, "metadata": {}, "output_type": "execute_result" } @@ -1999,7 +2011,7 @@ }, { "cell_type": "code", - "execution_count": 54, + "execution_count": 56, "id": "aa1c9d23-908c-4b07-a5b1-59bc043c6d7c", "metadata": {}, "outputs": [], @@ -2102,7 +2114,7 @@ }, { "cell_type": "code", - "execution_count": 57, + "execution_count": 59, "id": "bf0921b4-a961-450e-b719-f7bdea866c66", "metadata": {}, "outputs": [], @@ -2120,7 +2132,7 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": 60, "id": "2e85bfd6-05c9-48c1-ada4-82f684476311", "metadata": {}, "outputs": [ @@ -2130,7 +2142,7 @@ "(10, 10)" ] }, - "execution_count": 58, + "execution_count": 60, "metadata": {}, "output_type": "execute_result" } @@ -2152,7 +2164,7 @@ }, { "cell_type": "code", - "execution_count": 59, + "execution_count": 61, "id": "5e8d4eba-bcf8-4c11-978a-49dcba926250", "metadata": {}, "outputs": [], @@ -2169,7 +2181,7 @@ }, { "cell_type": "code", - "execution_count": 60, + "execution_count": 62, "id": "4c50f083-b166-4a62-bb13-5ceec8ddca81", "metadata": {}, "outputs": [ @@ -2179,7 +2191,7 @@ "8" ] }, - "execution_count": 60, + "execution_count": 62, "metadata": {}, "output_type": "execute_result" } @@ -2190,7 +2202,7 @@ }, { "cell_type": "code", - "execution_count": 61, + "execution_count": 63, "id": "05f9433c-64af-48b3-bb70-80cbcec9c03d", "metadata": {}, "outputs": [], @@ -2208,7 +2220,7 @@ }, { "cell_type": "code", - "execution_count": 62, + "execution_count": 64, "id": "7f00520f-0c4f-48e1-8f65-fd4cde8c2202", "metadata": {}, "outputs": [], @@ -2287,7 +2299,7 @@ }, { "cell_type": "code", - "execution_count": 64, + "execution_count": 66, "id": "0171f026-b3d0-434c-bd19-118a829c07a3", "metadata": {}, "outputs": [], @@ -2317,7 +2329,7 @@ }, { "cell_type": "code", - "execution_count": 65, + "execution_count": 67, "id": "b5fc576a-1c65-467c-bbb2-a67b412d3297", "metadata": {}, "outputs": [ @@ -2327,7 +2339,7 @@ "(2.2, None)" ] }, - "execution_count": 65, + "execution_count": 67, "metadata": {}, "output_type": "execute_result" } @@ -2338,7 +2350,7 @@ }, { "cell_type": "code", - "execution_count": 66, + "execution_count": 68, "id": "e05c6d24-60a5-4ec1-9e1b-b98f9f8ee094", "metadata": {}, "outputs": [ @@ -2348,7 +2360,7 @@ "(2.2, 3.3)" ] }, - "execution_count": 66, + "execution_count": 68, "metadata": {}, "output_type": "execute_result" } @@ -2359,7 +2371,7 @@ }, { "cell_type": "code", - "execution_count": 67, + "execution_count": 69, "id": "78cc5f4e-ac44-42bb-ada0-106dd023803f", "metadata": {}, "outputs": [ @@ -2369,7 +2381,7 @@ "(2.2, 3.0)" ] }, - "execution_count": 67, + "execution_count": 69, "metadata": {}, "output_type": "execute_result" } diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index bdde81d..4bda2f6 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -180,10 +180,10 @@ class ADataclass: `type`. Example: f(param: type) - If the type is subscripted then the input will be validated as being a - subclass of the subscripted type. For example, the following will - validate that 'param' receives a subclass of int (including int - itself): + If type is subscripted then the input will be validated as being a + subclass of the subscripted type or the subscripted type itself. For + example, the following will validate that 'param' receives a subclass + of `int` or `int` itself: f(param: type[int]) The subscripted argument can be a union, in which case the input will be validated as being a subclass of one of the union members: @@ -531,7 +531,7 @@ def validates_against_hint( # noqa: C901, PLR0911, PLR0912 f" although received '{obj}'." ) - # validate object is instance of the origin 'type' + # validate object is instance of the origin 'type' (if it isn't then raise) if not isinstance(obj, origin): if not rtrn_error: return FAILED_SIMPLE @@ -544,7 +544,6 @@ def validates_against_hint( # noqa: C901, PLR0911, PLR0912 return VALIDATED if origin is type: - # `obj` already validated as being a class (isinstance(obj, type)) if not hint_args or validates_as_subclass(obj, hint_args[0]): return VALIDATED if not rtrn_error: diff --git a/tests/test_valimp.py b/tests/test_valimp.py index a590630..b95ccdd 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2046,31 +2046,36 @@ class _Dog(_Animal): pass -def test_type_annotation_valid(): +def test_valid_type(): """Verify validation of `type` and subscripted `type` annotations.""" @m.parse def f( a: type, - b: type[int], - c: type[_Animal], - d: type[Union[int, str]], - e: typing.Type[int], # noqa: UP006 - f_meta: Annotated[type[int], "some_meta"], - g: Optional[type[int]], + b: type[Any], + c: type[int], + d: type[_Animal], + e: type[Union[int, str]], + f: typing.Type[int], # noqa: UP006 + g_meta: Annotated[type[int], "some_meta"], + h: Optional[type[int]], + i: Annotated[type[int], m.Coerce(str)], + j: Annotated[type[_Animal], m.Parser(lambda _n, obj, _p: obj())], ) -> tuple: - return a, b, c, d, e, f_meta, g + return a, b, c, d, e, f, g_meta, h, i, j # bare `type` takes any class - rtrn = f(dict, int, _Animal, str, int, int, None) - assert rtrn == (dict, int, _Animal, str, int, int, None) + rtrn = f(dict, set, int, _Animal, str, int, int, None, int, _Animal) + assert rtrn[:-1] == (dict, set, int, _Animal, str, int, int, None, str(int)) + assert isinstance(rtrn[-1], _Animal) # subclasses (including the class itself) are valid - rtrn = f(list, bool, _Dog, int, int, int, int) - assert rtrn == (list, bool, _Dog, int, int, int, int) + rtrn = f(list, tuple, bool, _Dog, int, int, int, int, bool, _Dog) + assert rtrn[:-1] == (list, tuple, bool, _Dog, int, int, int, int, str(bool)) + assert isinstance(rtrn[-1], _Dog) -def test_type_annotation_invalid(): +def test_invalid_inputs_type(): """Verify errors raised for invalid `type` annotation inputs.""" @m.parse @@ -2078,45 +2083,37 @@ def f( a: type[int], b: type[_Animal], c: type[Union[int, str]], + d: type[int], ) -> tuple: return a, b, c # a class that is not a subclass of the subscripted type msg_a = re.escape("a\n\tTakes a subclass of although received") with pytest.raises(m.InputsError, match=msg_a): - f(str, _Animal, int) + f(str, _Animal, int, int) msg_b = re.escape( "b\n\tTakes a subclass of although received" ) with pytest.raises(m.InputsError, match=msg_b): - f(int, int, int) + f(int, int, int, int) msg_c = _msg_re("c\n\tTakes a subclass of typing.Union[int, str] although received") with pytest.raises(m.InputsError, match=msg_c): - f(int, _Animal, float) + f(int, _Animal, float, int) # an instance (not a class) is not valid for a `type` annotation - msg_inst = re.escape("Takes type although received '3'") + msg_inst = re.escape("d\n\tTakes type although received '3'") with pytest.raises(m.InputsError, match=msg_inst): - f(3, _Animal, int) - - -def test_type_annotation_coerce_parse(): - """Verify Coerce and Parser work with a `type` annotation.""" - - @m.parse - def f_coerce(a: Annotated[type[int], m.Coerce(str)]) -> str: - return a - - # the class is coerced (here to its string representation) - assert f_coerce(int) == str(int) - - @m.parse - def f_parser( - a: Annotated[type[_Animal], m.Parser(lambda _n, obj, _p: obj())], - ) -> _Animal: - return a - - # parser receives the class and here instantiates it - assert isinstance(f_parser(_Dog), _Dog) + f(int, _Animal, int, 3) + + # AIDEV-TODO: complete the match_all expression below to reflect the full expected + # error message - this will include each of the four matches noted above, allowing + # for those four 'with pytest.raises...' checks to be removed in favour of this + # single check. The message to match should be written to a global constant in the + # same way as the `test_invalid_inputs_dict_items` test uses the global constant + # `INVALID_MSG_DICT_ITEMS` for this purpose (and other tests use similarly arranged + # global constants). + match_all = "" + with pytest.raises(m.InputsError, match=match_all): + f(str, int, float, 3) From e051d1f836bd54bb55a57ec6be8b8a53aa4f788c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 16:00:33 +0000 Subject: [PATCH 4/6] Consolidate type test and document type in main README example Complete the consolidated error-message check for invalid `type` inputs via a new INVALID_MSG_TYPE constant, replacing the individual assertions. Document the `type` annotation by extending the main `public_function` README example with `type`, `type[int]` and `type[Union[int, str]]` parameters (one also featured in the invalid-inputs example), and remove the standalone `type` section. https://claude.ai/code/session_01Ast6azhb3Aj7tSFqa4Sx7G --- README.md | 75 +++++++++++++------------------------------- tests/test_valimp.py | 54 ++++++++++++++----------------- 2 files changed, 46 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 566b367..097580a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ def public_function( Coerce(str), Parser(lambda name, obj, _: obj + f"_{name}") ], + # validate that input is a class (rather than an instance) + i: type, + # validate that input is int or a subclass of int + j: type[int], + # the subscripted type can be a union + k: type[Union[int, str]], # type[int | str] # support for packing extra arguments if required, can be optionally typed... *args: Annotated[ Union[int, float, str], # int | float | str @@ -52,16 +58,19 @@ 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, "args",args, "g":g, "h":h} + return {"a":a, "b":b, "c":c, "d":d, "e":e, "f":f, "i":i, "j":j, "k":k, "args":args, "g":g, "h":h} public_function( - # NB parameters 'a' through 'f' could be passed positionally + # NB parameters 'a' through 'k' could be passed positionally "zero", # a 1.0, # b {"two": 2}, # c 3.3, # d, will be coerced from float to int, i.e. to 3 "four", # e, will be parsed to "four_e_zero" 5, # f, will be coerced to str and then parsed to "5_f" + str, # i, valid, any class + int, # j, valid, int itself is valid + bool, # k, valid, bool is a subclass of int (a member of the union) "10", # extra arg, will be coerced to int and packed 20, # extra arg, will be packed g="keyword_arg_g", @@ -76,6 +85,9 @@ returns: 'd': 3, 'e': 'four_e_zero', 'f': '5_f', + 'i': , + 'j': , + 'k': , 'args': (10, 20), 'g': 'keyword_arg_g', 'h': 1.0} @@ -89,6 +101,9 @@ public_function( d=3.2, # valid input e="valid input", f=5.0, # INVALID, not a str or an int + i=str, # valid input, any class + j=str, # INVALID, str is not int or a subclass of int + k=bool, # valid input, bool is a subclass of int g="valid input", ) ``` @@ -107,6 +122,9 @@ c f Takes input that conforms with <(, )> although received '5.0' of type . + +j + Takes a subclass of although received '' of type . ``` And if the inputs do not match the signature... ```python @@ -115,8 +133,7 @@ public_function( "invalid input", # invalid (not int or float), included in errors {"two": 2}, 3.2, - # no argument passed for required positional arg 'e' - # no argument passed for required positional arg 'f' + # no argument passed for required positional args 'e', 'f', 'i', 'j' and 'k' a="a again", # passing multiple values for parameter 'a' # no argument passed for required keyword arg 'g' not_a_kwarg="not a kwarg", # including an unexpected kwarg @@ -130,7 +147,7 @@ Got multiple values for argument: 'a'. Got unexpected keyword argument: 'not_a_kwarg'. -Missing 2 positional arguments: 'e' and 'f'. +Missing 5 positional arguments: 'e', 'f', 'i', 'j' and 'k'. Missing 1 keyword-only argument: 'g'. @@ -163,54 +180,6 @@ output: {'a': "I'm a and will appear at the end of b", 'b': "33 b I'm a and will appear at the end of b"} ``` -Validate that an input is a class (rather than an instance) with the `type` annotation. Subscript `type` to validate that the input is a subclass of a specific type... -```python -from valimp import parse - -class Animal: - pass - -class Dog(Animal): - pass - -@parse -def public_function( - # validate that input is any class - a: type, - # validate that input is a subclass of 'Animal' (including 'Animal' itself) - b: type[Animal], - # the subscripted type can be a union - c: type[Union[int, str]], -) -> tuple: - return a, b, c - -public_function( - dict, # a, valid, any class - Dog, # b, valid, a subclass of Animal - str, # c, valid, str is in the union -) # returns (dict, Dog, str) -``` -And if there are invalid inputs... -```python -public_function( - 3, # INVALID, an instance, not a class - int, # INVALID, not a subclass of Animal - float, # INVALID, not in the union -) -``` -raises: -``` -InputsError: The following inputs to 'public_function' do not conform with the corresponding type annotation: - -a - Takes type although received '3' of type . - -b - Takes a subclass of although received '' of type . - -c - Takes a subclass of typing.Union[int, str] although received '' of type . -``` ## Installation `$ pip install valimp` diff --git a/tests/test_valimp.py b/tests/test_valimp.py index b95ccdd..8d86a44 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2075,6 +2075,23 @@ def f( assert isinstance(rtrn[-1], _Dog) +INVALID_MSG_TYPE = _msg_re( + """The following inputs to 'f' do not conform with the corresponding type annotation: + +a + Takes a subclass of although received '' of type . + +b + Takes a subclass of although received '' of type . + +c + Takes a subclass of typing.Union[int, str] although received '' of type . + +d + Takes type although received '3' of type .""" +) + + def test_invalid_inputs_type(): """Verify errors raised for invalid `type` annotation inputs.""" @@ -2085,35 +2102,12 @@ def f( c: type[Union[int, str]], d: type[int], ) -> tuple: - return a, b, c + return a, b, c, d - # a class that is not a subclass of the subscripted type - msg_a = re.escape("a\n\tTakes a subclass of although received") - with pytest.raises(m.InputsError, match=msg_a): - f(str, _Animal, int, int) - - msg_b = re.escape( - "b\n\tTakes a subclass of although received" - ) - with pytest.raises(m.InputsError, match=msg_b): - f(int, int, int, int) - - msg_c = _msg_re("c\n\tTakes a subclass of typing.Union[int, str] although received") - with pytest.raises(m.InputsError, match=msg_c): - f(int, _Animal, float, int) - - # an instance (not a class) is not valid for a `type` annotation - msg_inst = re.escape("d\n\tTakes type although received '3'") - with pytest.raises(m.InputsError, match=msg_inst): - f(int, _Animal, int, 3) - - # AIDEV-TODO: complete the match_all expression below to reflect the full expected - # error message - this will include each of the four matches noted above, allowing - # for those four 'with pytest.raises...' checks to be removed in favour of this - # single check. The message to match should be written to a global constant in the - # same way as the `test_invalid_inputs_dict_items` test uses the global constant - # `INVALID_MSG_DICT_ITEMS` for this purpose (and other tests use similarly arranged - # global constants). - match_all = "" - with pytest.raises(m.InputsError, match=match_all): + # a, a class that is not a subclass of the subscripted type + # b, a class that is not a subclass of the subscripted custom type + # c, a class that is not a subclass of any member of the subscripted union + # d, an instance (not a class) is not valid for a `type` annotation + regex = re.compile("^" + INVALID_MSG_TYPE + "$") + with pytest.raises(m.InputsError, match=regex): f(str, int, float, 3) From 62e1a661cc27678416e7e3bba3100308c31f8d9d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 16:26:29 +0000 Subject: [PATCH 5/6] Refine type error message and alphabetise README example params Only append " of type {type(obj)}." to the `type` subclass-failure TypeError when it adds information, i.e. when the received class has a metaclass other than `type`. For an ordinary class the suffix is redundant (the repr already identifies the class) and is omitted. Rename the README example's `type` parameters to g/h/i and the existing keyword-only parameters to j/k so that all parameter names read in alphabetical order. Update the README, tutorial and tests to reflect the revised message, and add a test covering the metaclass case where the type is noted. https://claude.ai/code/session_01Ast6azhb3Aj7tSFqa4Sx7G --- README.md | 54 +++++++++++++++++------------------ docs/tutorials/tutorial.ipynb | 4 +-- src/valimp/valimp.py | 6 ++-- tests/test_valimp.py | 34 ++++++++++++++++++++-- 4 files changed, 64 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 097580a..d2bb248 100644 --- a/README.md +++ b/README.md @@ -38,43 +38,43 @@ def public_function( Parser(lambda name, obj, _: obj + f"_{name}") ], # validate that input is a class (rather than an instance) - i: type, + g: type, # validate that input is int or a subclass of int - j: type[int], + h: type[int], # the subscripted type can be a union - k: type[Union[int, str]], # type[int | str] + i: type[Union[int, str]], # type[int | str] # support for packing extra arguments if required, can be optionally typed... *args: Annotated[ Union[int, float, str], # int | float | str Coerce(int) ], # support for optional types - g: Optional[str], # str | None + j: Optional[str], # str | None # define default values dynamically with reference to earlier inputs - h: Annotated[ + k: Annotated[ Optional[float], # float | None Parser(lambda _, obj, params: params["b"] if obj is None else obj) ] = None, # 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, "i":i, "j":j, "k":k, "args":args, "g":g, "h":h} + 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 'k' could be passed positionally + # NB parameters 'a' through 'i' could be passed positionally "zero", # a 1.0, # b {"two": 2}, # c 3.3, # d, will be coerced from float to int, i.e. to 3 "four", # e, will be parsed to "four_e_zero" 5, # f, will be coerced to str and then parsed to "5_f" - str, # i, valid, any class - int, # j, valid, int itself is valid - bool, # k, valid, bool is a subclass of int (a member of the union) + str, # g, valid, any class + int, # h, valid, int itself is valid + bool, # i, valid, bool is a subclass of int (a member of the union) "10", # extra arg, will be coerced to int and packed 20, # extra arg, will be packed - g="keyword_arg_g", - # h, not passed, will be assigned dynamically as parameter b (i.e. 1.0) + j="keyword_arg_j", + # k, not passed, will be assigned dynamically as parameter b (i.e. 1.0) ) ``` returns: @@ -85,12 +85,12 @@ returns: 'd': 3, 'e': 'four_e_zero', 'f': '5_f', - 'i': , - 'j': , - 'k': , + 'g': , + 'h': , + 'i': , 'args': (10, 20), - 'g': 'keyword_arg_g', - 'h': 1.0} + 'j': 'keyword_arg_j', + 'k': 1.0} ``` And if there are invalid inputs... ```python @@ -101,10 +101,10 @@ public_function( d=3.2, # valid input e="valid input", f=5.0, # INVALID, not a str or an int - i=str, # valid input, any class - j=str, # INVALID, str is not int or a subclass of int - k=bool, # valid input, bool is a subclass of int - g="valid input", + g=str, # valid input, any class + h=str, # INVALID, str is not int or a subclass of int + i=bool, # valid input, bool is a subclass of int + j="valid input", ) ``` raises: @@ -123,8 +123,8 @@ c f Takes input that conforms with <(, )> although received '5.0' of type . -j - Takes a subclass of although received '' of type . +h + Takes a subclass of although received ''. ``` And if the inputs do not match the signature... ```python @@ -133,9 +133,9 @@ public_function( "invalid input", # invalid (not int or float), included in errors {"two": 2}, 3.2, - # no argument passed for required positional args 'e', 'f', 'i', 'j' and 'k' + # 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 'g' + # no argument passed for required keyword arg 'j' not_a_kwarg="not a kwarg", # including an unexpected kwarg ) ``` @@ -147,9 +147,9 @@ Got multiple values for argument: 'a'. Got unexpected keyword argument: 'not_a_kwarg'. -Missing 5 positional arguments: 'e', 'f', 'i', 'j' and 'k'. +Missing 5 positional arguments: 'e', 'f', 'g', 'h' and 'i'. -Missing 1 keyword-only argument: 'g'. +Missing 1 keyword-only argument: 'j'. The following inputs to 'public_function' do not conform with the corresponding type annotation: diff --git a/docs/tutorials/tutorial.ipynb b/docs/tutorials/tutorial.ipynb index dcb99c0..c2fa3f7 100644 --- a/docs/tutorials/tutorial.ipynb +++ b/docs/tutorials/tutorial.ipynb @@ -1250,10 +1250,10 @@ "\tTakes type although received '3' of type .\n", "\n", "b\n", - "\tTakes a subclass of although received '' of type .\n", + "\tTakes a subclass of although received ''.\n", "\n", "c\n", - "\tTakes a subclass of typing.Union[int, str] although received '' of type .\n", + "\tTakes a subclass of typing.Union[int, str] although received ''.\n", "```" ] }, diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 4bda2f6..121b2b7 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -548,9 +548,11 @@ def validates_against_hint( # noqa: C901, PLR0911, PLR0912 return VALIDATED if not rtrn_error: return FAILED_SIMPLE + # `obj` is a class (passed the isinstance check above), so only note + # its type if that adds information, i.e. if it's not simply `type`. + suffix = "." if type(obj) is type else f" of type {type(obj)}." return False, TypeError( - f"Takes a subclass of {hint_args[0]} although received '{obj}'" - f" of type {type(obj)}." + f"Takes a subclass of {hint_args[0]} although received '{obj}'{suffix}" ) if origin is tuple and hint_args[-1] is not Ellipsis and len(obj) != len(hint_args): diff --git a/tests/test_valimp.py b/tests/test_valimp.py index 8d86a44..69ee93a 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2079,13 +2079,13 @@ def f( """The following inputs to 'f' do not conform with the corresponding type annotation: a - Takes a subclass of although received '' of type . + Takes a subclass of although received ''. b - Takes a subclass of although received '' of type . + Takes a subclass of although received ''. c - Takes a subclass of typing.Union[int, str] although received '' of type . + Takes a subclass of typing.Union[int, str] although received ''. d Takes type although received '3' of type .""" @@ -2111,3 +2111,31 @@ def f( regex = re.compile("^" + INVALID_MSG_TYPE + "$") with pytest.raises(m.InputsError, match=regex): f(str, int, float, 3) + + +def test_invalid_inputs_type_metaclass(): + """Verify `type` error notes the type when it's not simply `type`. + + The message only includes the received object's type when that adds + information, i.e. when the received class has a metaclass other than + `type`. + """ + + class Meta(type): + pass + + class WithMeta(metaclass=Meta): + pass + + @m.parse + def f(a: type[int]) -> type: + return a + + # the received object is a class, so the subclass check is reached, and + # as its metaclass is not `type` the message notes its type + msg = re.escape( + "a\n\tTakes a subclass of although received" + f" '{WithMeta}' of type {Meta}." + ) + with pytest.raises(m.InputsError, match=msg): + f(WithMeta) From 8d026367693cbc3aac456b409f79a35759bbd86e Mon Sep 17 00:00:00 2001 From: Marcus Read Date: Tue, 2 Jun 2026 17:54:23 +0100 Subject: [PATCH 6/6] Manual revisions --- README.md | 22 +++++++++++----------- src/valimp/valimp.py | 2 -- tests/test_valimp.py | 28 ---------------------------- 3 files changed, 11 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index d2bb248..0a418ff 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ def public_function( Coerce(str), Parser(lambda name, obj, _: obj + f"_{name}") ], - # validate that input is a class (rather than an instance) + # validate input is a class (rather than an instance) g: type, - # validate that input is int or a subclass of int + # validate input is subclass of a specific class (or that class itself) ... h: type[int], - # the subscripted type can be a union + # ... or of specific classes... i: type[Union[int, str]], # type[int | str] # support for packing extra arguments if required, can be optionally typed... *args: Annotated[ @@ -61,16 +61,16 @@ 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' could be passed positionally + # NB parameters 'a' through 'i' can be passed positionally "zero", # a 1.0, # b {"two": 2}, # c 3.3, # d, will be coerced from float to int, i.e. to 3 "four", # e, will be parsed to "four_e_zero" 5, # f, will be coerced to str and then parsed to "5_f" - str, # g, valid, any class - int, # h, valid, int itself is valid - bool, # i, valid, bool is a subclass of int (a member of the union) + str, # g + bool, # h, a subclass of int + int, # i, one of the subscripted classes "10", # extra arg, will be coerced to int and packed 20, # extra arg, will be packed j="keyword_arg_j", @@ -86,8 +86,8 @@ returns: 'e': 'four_e_zero', 'f': '5_f', 'g': , - 'h': , - 'i': , + 'h': , + 'i': , 'args': (10, 20), 'j': 'keyword_arg_j', 'k': 1.0} @@ -101,9 +101,9 @@ public_function( d=3.2, # valid input e="valid input", f=5.0, # INVALID, not a str or an int - g=str, # valid input, any class + g=str, # valid input h=str, # INVALID, str is not int or a subclass of int - i=bool, # valid input, bool is a subclass of int + i=bool, # valid input j="valid input", ) ``` diff --git a/src/valimp/valimp.py b/src/valimp/valimp.py index 121b2b7..83567e1 100644 --- a/src/valimp/valimp.py +++ b/src/valimp/valimp.py @@ -548,8 +548,6 @@ def validates_against_hint( # noqa: C901, PLR0911, PLR0912 return VALIDATED if not rtrn_error: return FAILED_SIMPLE - # `obj` is a class (passed the isinstance check above), so only note - # its type if that adds information, i.e. if it's not simply `type`. suffix = "." if type(obj) is type else f" of type {type(obj)}." return False, TypeError( f"Takes a subclass of {hint_args[0]} although received '{obj}'{suffix}" diff --git a/tests/test_valimp.py b/tests/test_valimp.py index 69ee93a..f033f04 100644 --- a/tests/test_valimp.py +++ b/tests/test_valimp.py @@ -2111,31 +2111,3 @@ def f( regex = re.compile("^" + INVALID_MSG_TYPE + "$") with pytest.raises(m.InputsError, match=regex): f(str, int, float, 3) - - -def test_invalid_inputs_type_metaclass(): - """Verify `type` error notes the type when it's not simply `type`. - - The message only includes the received object's type when that adds - information, i.e. when the received class has a metaclass other than - `type`. - """ - - class Meta(type): - pass - - class WithMeta(metaclass=Meta): - pass - - @m.parse - def f(a: type[int]) -> type: - return a - - # the received object is a class, so the subclass check is reached, and - # as its metaclass is not `type` the message notes its type - msg = re.escape( - "a\n\tTakes a subclass of although received" - f" '{WithMeta}' of type {Meta}." - ) - with pytest.raises(m.InputsError, match=msg): - f(WithMeta)