From 3ec7eff23cd500aaba6048cdd97b11df0139f567 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 27 Dec 2023 12:03:41 -0700 Subject: [PATCH] Added test for `**kwargs` with Unpack TypedDict annotation. --- .../results/mypy/callables_kwargs.toml | 23 ++++ conformance/results/mypy/version.toml | 2 +- .../results/pyre/callables_kwargs.toml | 12 ++ conformance/results/pyre/version.toml | 2 +- .../results/pyright/callables_kwargs.toml | 30 +++++ conformance/results/pyright/version.toml | 2 +- .../results/pytype/callables_kwargs.toml | 48 +++++++ conformance/results/pytype/version.toml | 2 +- conformance/results/results.html | 12 +- conformance/tests/callables_kwargs.py | 123 ++++++++++++++++++ 10 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 conformance/results/mypy/callables_kwargs.toml create mode 100644 conformance/results/pyre/callables_kwargs.toml create mode 100644 conformance/results/pyright/callables_kwargs.toml create mode 100644 conformance/results/pytype/callables_kwargs.toml create mode 100644 conformance/tests/callables_kwargs.py diff --git a/conformance/results/mypy/callables_kwargs.toml b/conformance/results/mypy/callables_kwargs.toml new file mode 100644 index 000000000..2e22ca6fe --- /dev/null +++ b/conformance/results/mypy/callables_kwargs.toml @@ -0,0 +1,23 @@ +conformant = "Pass" +output = """ +callables_kwargs.py:22: note: "func1" defined here +callables_kwargs.py:43: error: Missing named argument "v1" for "func1" [call-arg] +callables_kwargs.py:43: error: Missing named argument "v3" for "func1" [call-arg] +callables_kwargs.py:48: error: Unexpected keyword argument "v4" for "func1" [call-arg] +callables_kwargs.py:49: error: Too many positional arguments for "func1" [misc] +callables_kwargs.py:55: error: Argument 1 to "func1" has incompatible type "**dict[str, str]"; expected "int" [arg-type] +callables_kwargs.py:58: error: Argument 1 to "func1" has incompatible type "**dict[str, object]"; expected "int" [arg-type] +callables_kwargs.py:58: error: Argument 1 to "func1" has incompatible type "**dict[str, object]"; expected "str" [arg-type] +callables_kwargs.py:60: error: "func1" gets multiple values for keyword argument "v1" [misc] +callables_kwargs.py:61: error: "func2" gets multiple values for keyword argument "v3" [misc] +callables_kwargs.py:61: error: Argument 1 to "func2" has incompatible type "int"; expected "str" [arg-type] +callables_kwargs.py:62: error: "func2" gets multiple values for keyword argument "v1" [misc] +callables_kwargs.py:98: error: Incompatible types in assignment (expression has type "Callable[[KwArg(TD2)], None]", variable has type "TDProtocol3") [assignment] +callables_kwargs.py:98: note: "TDProtocol3.__call__" has type "Callable[[NamedArg(int, 'v1'), NamedArg(int, 'v2'), NamedArg(str, 'v3')], None]" +callables_kwargs.py:99: error: Incompatible types in assignment (expression has type "Callable[[KwArg(TD2)], None]", variable has type "TDProtocol4") [assignment] +callables_kwargs.py:99: note: "TDProtocol4.__call__" has type "Callable[[NamedArg(int, 'v1')], None]" +callables_kwargs.py:100: error: Incompatible types in assignment (expression has type "Callable[[KwArg(TD2)], None]", variable has type "TDProtocol5") [assignment] +callables_kwargs.py:100: note: "TDProtocol5.__call__" has type "Callable[[Arg(int, 'v1'), Arg(str, 'v3')], None]" +callables_kwargs.py:109: error: Overlap between argument names and ** TypedDict items: "v1" [misc] +callables_kwargs.py:121: error: Unpack item in ** argument must be a TypedDict [misc] +""" diff --git a/conformance/results/mypy/version.toml b/conformance/results/mypy/version.toml index 4cab399e4..bbedc933b 100644 --- a/conformance/results/mypy/version.toml +++ b/conformance/results/mypy/version.toml @@ -1,2 +1,2 @@ version = "mypy 1.8.0" -test_duration = 0.33256983757019043 +test_duration = 0.4829740524291992 diff --git a/conformance/results/pyre/callables_kwargs.toml b/conformance/results/pyre/callables_kwargs.toml new file mode 100644 index 000000000..64b1f1bc5 --- /dev/null +++ b/conformance/results/pyre/callables_kwargs.toml @@ -0,0 +1,12 @@ +conformant = "Unsupported" +note = """ +Does not understand Unpack in the context of **kwargs annotation. +""" +output = """ +callables_kwargs.py:22:20 Undefined or invalid type [11]: Annotation `Unpack` is not defined as a type. +callables_kwargs.py:49:4 Too many arguments [19]: Call `func1` expects 1 positional argument, 4 were provided. +callables_kwargs.py:59:12 Incompatible parameter type [6]: In call `func2`, for 1st positional argument, expected `str` but got `object`. +callables_kwargs.py:61:10 Incompatible parameter type [6]: In call `func2`, for 1st positional argument, expected `str` but got `int`. +callables_kwargs.py:62:18 Incompatible parameter type [6]: In call `func2`, for 2nd positional argument, expected `str` but got `object`. +callables_kwargs.py:121:20 Invalid type variable [34]: The type variable `Variable[T (bound to callables_kwargs.TD2)]` isn't present in the function's parameters. +""" diff --git a/conformance/results/pyre/version.toml b/conformance/results/pyre/version.toml index fe0a4319a..d995d70eb 100644 --- a/conformance/results/pyre/version.toml +++ b/conformance/results/pyre/version.toml @@ -1,2 +1,2 @@ version = "pyre 0.9.19" -test_duration = 1.4521031379699707 +test_duration = 1.5891530513763428 diff --git a/conformance/results/pyright/callables_kwargs.toml b/conformance/results/pyright/callables_kwargs.toml new file mode 100644 index 000000000..651eee9ea --- /dev/null +++ b/conformance/results/pyright/callables_kwargs.toml @@ -0,0 +1,30 @@ +conformant = "Pass" +output = """ +callables_kwargs.py:26:5 - error: Could not access item in TypedDict +  "v2" is not a required key in "*TD2", so access may result in runtime exception (reportTypedDictNotRequiredAccess) +callables_kwargs.py:43:5 - error: Arguments missing for parameters "v1", "v3" (reportGeneralTypeIssues) +callables_kwargs.py:48:32 - error: No parameter named "v4" (reportGeneralTypeIssues) +callables_kwargs.py:49:11 - error: Expected 0 positional arguments (reportGeneralTypeIssues) +callables_kwargs.py:55:13 - error: Argument of type "str" cannot be assigned to parameter "v1" of type "int" in function "func1" +  "str" is incompatible with "int" (reportGeneralTypeIssues) +callables_kwargs.py:60:19 - error: Unable to match unpacked TypedDict argument to parameters +  Parameter "v1" is already assigned (reportGeneralTypeIssues) +callables_kwargs.py:61:16 - error: Unable to match unpacked TypedDict argument to parameters +  Parameter "v3" is already assigned (reportGeneralTypeIssues) +callables_kwargs.py:62:19 - error: Unable to match unpacked TypedDict argument to parameters +  Parameter "v1" is already assigned (reportGeneralTypeIssues) +callables_kwargs.py:98:19 - error: Expression of type "(**kwargs: **TD2) -> None" cannot be assigned to declared type "TDProtocol3" +  Type "(**kwargs: **TD2) -> None" cannot be assigned to type "(*, v1: int, v2: int, v3: str) -> None" +    Keyword parameter "v2" of type "int" cannot be assigned to type "str" +      "int" is incompatible with "str" (reportGeneralTypeIssues) +callables_kwargs.py:99:19 - error: Expression of type "(**kwargs: **TD2) -> None" cannot be assigned to declared type "TDProtocol4" +  Type "(**kwargs: **TD2) -> None" cannot be assigned to type "(*, v1: int) -> None" +    Keyword parameter "v3" is missing in destination (reportGeneralTypeIssues) +callables_kwargs.py:100:19 - error: Expression of type "(**kwargs: **TD2) -> None" cannot be assigned to declared type "TDProtocol5" +  Type "(**kwargs: **TD2) -> None" cannot be assigned to type "(v1: int, v3: str) -> None" +    Function accepts too many positional parameters; expected 0 but received 2 +      Keyword parameter "v1" is missing in destination +      Keyword parameter "v3" is missing in destination (reportGeneralTypeIssues) +callables_kwargs.py:109:30 - error: Typed dictionary overlaps with keyword parameter: v1 (reportGeneralTypeIssues) +callables_kwargs.py:121:21 - error: Expected TypedDict type argument for Unpack (reportGeneralTypeIssues) +""" diff --git a/conformance/results/pyright/version.toml b/conformance/results/pyright/version.toml index 9904221b9..95f168410 100644 --- a/conformance/results/pyright/version.toml +++ b/conformance/results/pyright/version.toml @@ -1,2 +1,2 @@ version = "pyright 1.1.343" -test_duration = 0.8812670707702637 +test_duration = 0.8890669345855713 diff --git a/conformance/results/pytype/callables_kwargs.toml b/conformance/results/pytype/callables_kwargs.toml new file mode 100644 index 000000000..29d66b1b7 --- /dev/null +++ b/conformance/results/pytype/callables_kwargs.toml @@ -0,0 +1,48 @@ +conformant = "Unsupported" +note = """ +Does not understand Unpack in the context of **kwargs annotation. +""" +output = """ +File "callables_kwargs.py", line 10, in : typing.NotRequired not supported yet [not-supported-yet] +File "callables_kwargs.py", line 10, in : typing.Required not supported yet [not-supported-yet] +File "callables_kwargs.py", line 10, in : typing.Unpack not supported yet [not-supported-yet] +File "callables_kwargs.py", line 24, in func1: Unpack[TD2] [assert-type] + Expected: int + Actual: Unpack[TD2] +File "callables_kwargs.py", line 30, in func1: Unpack[TD2] [assert-type] + Expected: str + Actual: Unpack[TD2] +File "callables_kwargs.py", line 33, in func1: Unpack[TD2] [assert-type] + Expected: str + Actual: Unpack[TD2] +File "callables_kwargs.py", line 39, in func2: Dict[str, Unpack[TD1]] [assert-type] + Expected: TD1 + Actual: Dict[str, Unpack[TD1]] +File "callables_kwargs.py", line 44, in func3: Function func1 was called with the wrong arguments [wrong-arg-types] + Expected: (v1: Unpack[TD2], ...) + Actually passed: (v1: int, ...) +File "callables_kwargs.py", line 46, in func3: Function TD2.__init__ was called with the wrong arguments [wrong-arg-types] + Expected: (*, v1: Required[int], ...) + Actually passed: (v1: int, ...) +File "callables_kwargs.py", line 48, in func3: Function func1 was called with the wrong arguments [wrong-arg-types] + Expected: (v1: Unpack[TD2], ...) + Actually passed: (v1: int, ...) +File "callables_kwargs.py", line 49, in func3: Function func1 expects 0 arg(s), got 3 [wrong-arg-count] + Expected: (**kwargs) + Actually passed: (_, _, _) +File "callables_kwargs.py", line 55, in func3: Function func1 was called with the wrong arguments [wrong-arg-types] + Expected: (**kwargs: Mapping[str, Unpack[TD2]]) + Actually passed: (kwargs: Dict[str, str]) +File "callables_kwargs.py", line 58, in func3: Function func1 was called with the wrong arguments [wrong-arg-types] + Expected: (v1: Unpack[TD2], ...) + Actually passed: (v1: int, ...) +File "callables_kwargs.py", line 60, in func3: Function func1 was called with the wrong arguments [wrong-arg-types] + Expected: (v1: Unpack[TD2], ...) + Actually passed: (v1: int, ...) +File "callables_kwargs.py", line 61, in func3: Function func2 was called with the wrong arguments [wrong-arg-types] + Expected: (v3: str, ...) + Actually passed: (v3: int, ...) +File "callables_kwargs.py", line 62, in func3: Function func2 was called with the wrong arguments [wrong-arg-types] + Expected: (v3, v1: Unpack[TD1], ...) + Actually passed: (v1: int, ...) +""" diff --git a/conformance/results/pytype/version.toml b/conformance/results/pytype/version.toml index 23f3f9b50..9bff92248 100644 --- a/conformance/results/pytype/version.toml +++ b/conformance/results/pytype/version.toml @@ -1,2 +1,2 @@ version = "pytype 2023.12.18" -test_duration = 40.140138149261475 +test_duration = 41.63518166542053 diff --git a/conformance/results/results.html b/conformance/results/results.html index 6177117c8..f514293e2 100644 --- a/conformance/results/results.html +++ b/conformance/results/results.html @@ -127,7 +127,7 @@

Python Type System Conformance Test Results

-
mypy 1.8.0(0.33sec) +
mypy 1.8.0(0.48sec)
@@ -153,6 +153,7 @@

Python Type System Conformance Test Results

+
Callables
     callables_annotationPass
     callables_kwargsPass
     callables_protocolPass
@@ -187,7 +188,7 @@

Python Type System Conformance Test Results

     narrowing_typeguardPass
-
pyright 1.1.343(0.88sec) +
pyright 1.1.343(0.89sec)
@@ -213,6 +214,7 @@

Python Type System Conformance Test Results

+
Callables
     callables_annotationPass
     callables_kwargsPass
     callables_protocolPartialDoes not report type incompatibility for callback protocol with positional-only parameters.
@@ -247,7 +249,7 @@

Python Type System Conformance Test Results

     narrowing_typeguardPass
-
pyre 0.9.19(1.45sec) +
pyre 0.9.19(1.59sec)
@@ -273,6 +275,7 @@

Python Type System Conformance Test Results

+
Callables
     callables_annotationPartialDoes not evaluate correct type for `*args: int` parameter.
Does not reject illegal form `Callable[[...], int]`.
     callables_kwargsUnsupported
     callables_protocolPartialDoes not correctly handle callback protocol that declares attributes in all functions.
Does not report type incompatibility for callback protocol with positional-only parameters.
Incorrectly reports type compatibility error with callback that has *args and **kwargs.
Does not report type incompatibility for callback missing a default argument for positional parameter.
Does not report type incompatibility for callback missing a default argument for keyword parameter.
@@ -307,7 +310,7 @@

Python Type System Conformance Test Results

     narrowing_typeguardPartialDoes not support `tuple` in `assert_type` call.
Does not reject TypeGuard method with too few parameters.
-
pytype 2023.12.18(40.14sec) +
pytype 2023.12.18(41.64sec)
@@ -333,6 +336,7 @@

Python Type System Conformance Test Results

+
Callables
     callables_annotationPass
     callables_kwargsUnsupported
     callables_protocolUnsupportedDoes not properly handle type compatibility checks with callback protocols.
diff --git a/conformance/tests/callables_kwargs.py b/conformance/tests/callables_kwargs.py new file mode 100644 index 000000000..070a45b71 --- /dev/null +++ b/conformance/tests/callables_kwargs.py @@ -0,0 +1,123 @@ +""" +Tests the use of an unpacked TypedDict for annotating **kwargs. +""" + +# Specification: https://typing.readthedocs.io/en/latest/spec/callables.html#unpack-for-keyword-arguments + +# This sample tests the handling of Unpack[TypedDict] when used with +# a **kwargs parameter in a function signature. + +from typing import Protocol, TypeVar, TypedDict, NotRequired, Required, Unpack, assert_type + + +class TD1(TypedDict): + v1: Required[int] + v2: NotRequired[str] + + +class TD2(TD1): + v3: Required[str] + + +def func1(**kwargs: Unpack[TD2]) -> None: + v1 = kwargs["v1"] + assert_type(v1, int) + + kwargs["v2"] # Type error: v2 may not be present + + if "v2" in kwargs: + v2 = kwargs["v2"] + assert_type(v2, str) + + v3 = kwargs["v3"] + assert_type(v3, str) + + +def func2(v3: str, **kwargs: Unpack[TD1]) -> None: + # > When Unpack is used, type checkers treat kwargs inside the function + # > body as a TypedDict. + assert_type(kwargs, TD1) + + +def func3() -> None: + func1() # Type error: missing required keyword args + func1(v1=1, v2="", v3="5") # OK + + td2 = TD2(v1=2, v3="4") + func1(**td2) # OK + func1(v1=1, v2="", v3="5", v4=5) # Type error: v4 is not in TD2 + func1(1, "", "5") # Type error: args not passed by position + + # > Passing a dictionary of type dict[str, object] as a **kwargs argument + # > to a function that has **kwargs annotated with Unpack must generate a + # > type checker error. + my_dict: dict[str, str] = {} + func1(**my_dict) # Type error: untyped dict + + d1 = {"v1": 2, "v3": "4", "v4": 4} + func1(**d1) # OK or Type error (spec allows either) + func2(**td2) # OK + func1(v1=2, **td2) # Type error: v1 is already specified + func2(1, **td2) # Type error: v1 is already specified + func2(v1=1, **td2) # Type error: v1 is already specified + + +class TDProtocol1(Protocol): + def __call__(self, *, v1: int, v3: str) -> None: + ... + + +class TDProtocol2(Protocol): + def __call__(self, *, v1: int, v3: str, v2: str = "") -> None: + ... + + +class TDProtocol3(Protocol): + def __call__(self, *, v1: int, v2: int, v3: str) -> None: + ... + + +class TDProtocol4(Protocol): + def __call__(self, *, v1: int) -> None: + ... + + +class TDProtocol5(Protocol): + def __call__(self, v1: int, v3: str) -> None: + ... + + +class TDProtocol6(Protocol): + def __call__(self, **kwargs: Unpack[TD2]) -> None: + ... + +# Specification: https://typing.readthedocs.io/en/latest/spec/callables.html#assignment + +v1: TDProtocol1 = func1 # OK +v2: TDProtocol2 = func1 # OK +v3: TDProtocol3 = func1 # Type error: v2 is wrong type +v4: TDProtocol4 = func1 # Type error: v3 is missing +v5: TDProtocol5 = func1 # Type error: params are positional +v6: TDProtocol6 = func1 # OK + + +def func4(v1: int, /, **kwargs: Unpack[TD2]) -> None: + ... + + +# Type error: parameter v1 overlaps with the TypedDict. +def func5(v1: int, **kwargs: Unpack[TD2]) -> None: + ... + + +T = TypeVar("T", bound=TD2) + +# > TypedDict is the only permitted heterogeneous type for typing **kwargs. +# > Therefore, in the context of typing **kwargs, using Unpack with types other +# > than TypedDict should not be allowed and type checkers should generate +# > errors in such cases. + +# Type error: unpacked value must be a TypedDict, not a TypeVar bound to TypedDict. +def func6(**kwargs: Unpack[T]) -> None: + ... +