diff --git a/README.md b/README.md index d5f9d64..0a418ff 100644 --- a/README.md +++ b/README.md @@ -37,35 +37,44 @@ def public_function( Coerce(str), Parser(lambda name, obj, _: obj + f"_{name}") ], + # validate input is a class (rather than an instance) + g: type, + # validate input is subclass of a specific class (or that class itself) ... + h: type[int], + # ... 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[ 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, "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 'f' 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 + 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 - 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: @@ -76,9 +85,12 @@ returns: 'd': 3, 'e': 'four_e_zero', 'f': '5_f', + '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 @@ -89,7 +101,10 @@ public_function( d=3.2, # valid input e="valid input", f=5.0, # INVALID, not a str or an int - g="valid input", + g=str, # valid input + h=str, # INVALID, str is not int or a subclass of int + i=bool, # valid input + j="valid input", ) ``` raises: @@ -107,6 +122,9 @@ c f Takes input that conforms with <(, )> although received '5.0' of type . + +h + Takes a subclass of although received ''. ``` And if the inputs do not match the signature... ```python @@ -115,10 +133,9 @@ 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', '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 ) ``` @@ -130,9 +147,9 @@ 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', '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: @@ -210,6 +227,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 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 2c133b0..c2fa3f7 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", @@ -1166,6 +1167,96 @@ "```" ] }, + { + "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 (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": 33, + "id": "0fb179b8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(dict, __main__.Dog, str)" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "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 ''.\n", + "\n", + "c\n", + "\tTakes a subclass of typing.Union[int, str] although received ''.\n", + "```" + ] + }, { "cell_type": "markdown", "id": "6af1bf68-31d9-496a-9233-7e51a1622199", @@ -1180,7 +1271,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 35, "id": "f9041474-ec0a-4488-8e80-ddcbbcbe6069", "metadata": {}, "outputs": [], @@ -1263,7 +1354,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 37, "id": "c6162215-5749-4be5-a8d2-683ac53748f0", "metadata": {}, "outputs": [], @@ -1292,7 +1383,7 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": 38, "id": "12a4aca1-005f-469c-b895-e8473ac50e86", "metadata": {}, "outputs": [ @@ -1302,7 +1393,7 @@ "'spam'" ] }, - "execution_count": 36, + "execution_count": 38, "metadata": {}, "output_type": "execute_result" } @@ -1323,7 +1414,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 39, "id": "bb10d385-cf0e-412f-ae74-2a98aa6aea95", "metadata": {}, "outputs": [], @@ -1379,7 +1470,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": 41, "id": "50470b59-5421-4036-8c93-d80624bac11d", "metadata": {}, "outputs": [], @@ -1396,7 +1487,7 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": 42, "id": "a56eb1f1-14d7-41d7-8b40-23c54f73b88a", "metadata": {}, "outputs": [ @@ -1406,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" } @@ -1425,7 +1516,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 43, "id": "e9dead85-ba39-425f-9858-9565230e2a1b", "metadata": {}, "outputs": [], @@ -1442,7 +1533,7 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 44, "id": "6880feb1-5e76-430a-9bee-24ad32ff85bb", "metadata": {}, "outputs": [ @@ -1452,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" } @@ -1507,7 +1598,7 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": 46, "id": "1eca3fca-eef8-425d-998d-d0deef4fc3ff", "metadata": {}, "outputs": [], @@ -1576,7 +1667,7 @@ }, { "cell_type": "code", - "execution_count": 46, + "execution_count": 48, "id": "eff9ce77-2c87-40ca-81c9-4faa54be0997", "metadata": {}, "outputs": [], @@ -1673,7 +1764,7 @@ }, { "cell_type": "code", - "execution_count": 49, + "execution_count": 51, "id": "aa10cb66-5db5-425a-ba4e-fad8e7e8d3eb", "metadata": {}, "outputs": [], @@ -1694,7 +1785,7 @@ }, { "cell_type": "code", - "execution_count": 50, + "execution_count": 52, "id": "8aba30cd-bf06-4b86-b9fd-958bb226d29f", "metadata": {}, "outputs": [ @@ -1709,7 +1800,7 @@ " 'args': (1.0, 2.0, 3.3, None)}" ] }, - "execution_count": 50, + "execution_count": 52, "metadata": {}, "output_type": "execute_result" } @@ -1783,7 +1874,7 @@ }, { "cell_type": "code", - "execution_count": 51, + "execution_count": 53, "id": "872b9027-3aff-4901-859b-f8b1163b5a54", "metadata": {}, "outputs": [ @@ -1793,7 +1884,7 @@ "{'a': 'input_a', 'b': 'input_b_suffix'}" ] }, - "execution_count": 51, + "execution_count": 53, "metadata": {}, "output_type": "execute_result" } @@ -1824,7 +1915,7 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": 54, "id": "3639b3c6-eb1f-493c-a62c-47b36e460fcb", "metadata": {}, "outputs": [ @@ -1838,7 +1929,7 @@ " 'kwargs': {'kw_xtra': \"I'm kw_xtra_kw_xtra\"}}" ] }, - "execution_count": 52, + "execution_count": 54, "metadata": {}, "output_type": "execute_result" } @@ -1920,7 +2011,7 @@ }, { "cell_type": "code", - "execution_count": 54, + "execution_count": 56, "id": "aa1c9d23-908c-4b07-a5b1-59bc043c6d7c", "metadata": {}, "outputs": [], @@ -2023,7 +2114,7 @@ }, { "cell_type": "code", - "execution_count": 57, + "execution_count": 59, "id": "bf0921b4-a961-450e-b719-f7bdea866c66", "metadata": {}, "outputs": [], @@ -2041,7 +2132,7 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": 60, "id": "2e85bfd6-05c9-48c1-ada4-82f684476311", "metadata": {}, "outputs": [ @@ -2051,7 +2142,7 @@ "(10, 10)" ] }, - "execution_count": 58, + "execution_count": 60, "metadata": {}, "output_type": "execute_result" } @@ -2073,7 +2164,7 @@ }, { "cell_type": "code", - "execution_count": 59, + "execution_count": 61, "id": "5e8d4eba-bcf8-4c11-978a-49dcba926250", "metadata": {}, "outputs": [], @@ -2090,7 +2181,7 @@ }, { "cell_type": "code", - "execution_count": 60, + "execution_count": 62, "id": "4c50f083-b166-4a62-bb13-5ceec8ddca81", "metadata": {}, "outputs": [ @@ -2100,7 +2191,7 @@ "8" ] }, - "execution_count": 60, + "execution_count": 62, "metadata": {}, "output_type": "execute_result" } @@ -2111,7 +2202,7 @@ }, { "cell_type": "code", - "execution_count": 61, + "execution_count": 63, "id": "05f9433c-64af-48b3-bb70-80cbcec9c03d", "metadata": {}, "outputs": [], @@ -2129,7 +2220,7 @@ }, { "cell_type": "code", - "execution_count": 62, + "execution_count": 64, "id": "7f00520f-0c4f-48e1-8f65-fd4cde8c2202", "metadata": {}, "outputs": [], @@ -2208,7 +2299,7 @@ }, { "cell_type": "code", - "execution_count": 64, + "execution_count": 66, "id": "0171f026-b3d0-434c-bd19-118a829c07a3", "metadata": {}, "outputs": [], @@ -2238,7 +2329,7 @@ }, { "cell_type": "code", - "execution_count": 65, + "execution_count": 67, "id": "b5fc576a-1c65-467c-bbb2-a67b412d3297", "metadata": {}, "outputs": [ @@ -2248,7 +2339,7 @@ "(2.2, None)" ] }, - "execution_count": 65, + "execution_count": 67, "metadata": {}, "output_type": "execute_result" } @@ -2259,7 +2350,7 @@ }, { "cell_type": "code", - "execution_count": 66, + "execution_count": 68, "id": "e05c6d24-60a5-4ec1-9e1b-b98f9f8ee094", "metadata": {}, "outputs": [ @@ -2269,7 +2360,7 @@ "(2.2, 3.3)" ] }, - "execution_count": 66, + "execution_count": 68, "metadata": {}, "output_type": "execute_result" } @@ -2280,7 +2371,7 @@ }, { "cell_type": "code", - "execution_count": 67, + "execution_count": 69, "id": "78cc5f4e-ac44-42bb-ada0-106dd023803f", "metadata": {}, "outputs": [ @@ -2290,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 24f0588..83567e1 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 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: + 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, @@ -495,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 @@ -507,6 +543,16 @@ def validates_against_hint( # noqa: C901, PLR0911, PLR0912 # validation of any subscripted types is not currently supported return VALIDATED + if origin is type: + if not hint_args or validates_as_subclass(obj, hint_args[0]): + return VALIDATED + if not rtrn_error: + return FAILED_SIMPLE + 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}" + ) + 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..f033f04 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", } @@ -2035,3 +2036,78 @@ 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_valid_type(): + """Verify validation of `type` and subscripted `type` annotations.""" + + @m.parse + def f( + a: type, + 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, g_meta, h, i, j + + # bare `type` takes any class + 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, 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) + + +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 ''. + +b + Takes a subclass of although received ''. + +c + Takes a subclass of typing.Union[int, str] although received ''. + +d + Takes type although received '3' of type .""" +) + + +def test_invalid_inputs_type(): + """Verify errors raised for invalid `type` annotation inputs.""" + + @m.parse + def f( + a: type[int], + b: type[_Animal], + c: type[Union[int, str]], + d: type[int], + ) -> tuple: + return a, b, c, d + + # 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)