Skip to content
Next Next commit
Playing with the old idea
  • Loading branch information
ilevkivskyi committed Aug 21, 2022
commit 8f51fe4b2f2bdf4d36550f98e27f3b0a3875f5b5
37 changes: 37 additions & 0 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@
TypeVarLikeType,
TypeVarType,
UnboundType,
UnpackType,
get_proper_type,
get_proper_types,
invalid_recursive_alias,
Expand Down Expand Up @@ -830,6 +831,8 @@ def analyze_func_def(self, defn: FuncDef) -> None:
self.defer(defn)
return
assert isinstance(result, ProperType)
if isinstance(result, CallableType):
result = self.unpack_callable_kwargs(defn, result)
defn.type = result
self.add_type_alias_deps(analyzer.aliases_used)
self.check_function_signature(defn)
Expand Down Expand Up @@ -872,6 +875,40 @@ def analyze_func_def(self, defn: FuncDef) -> None:
defn.type = defn.type.copy_modified(ret_type=ret_type)
self.wrapped_coro_return_types[defn] = defn.type

def unpack_callable_kwargs(self, defn: FuncDef, typ: CallableType) -> CallableType:
if not typ.arg_kinds or typ.arg_kinds[-1] is not ArgKind.ARG_STAR2:
return typ
last_type = get_proper_type(typ.arg_types[-1])
if not isinstance(last_type, UnpackType):
return typ
last_type = get_proper_type(last_type.type)
if not isinstance(last_type, TypedDictType):
self.fail("Unpack item in ** argument must be a TypedDict", defn)
new_arg_types = typ.arg_types[:-1] + [AnyType(TypeOfAny.from_error)]
return typ.copy_modified(new_arg_types=new_arg_types)
overlap = set(typ.arg_names) & set(last_type.items)
# It is OK for TypedDict to have a key named 'kwargs'.
overlap.discard(typ.arg_names[-1])
if overlap:
overlapped = ", ".join([f'"{name}"' for name in overlap])
self.fail(f"Overlap between argument names and ** TypedDict items: {overlapped}", defn)
new_arg_types = typ.arg_types[:-1] + [AnyType(TypeOfAny.from_error)]
return typ.copy_modified(new_arg_types=new_arg_types)
# OK, everything looks right now, unpack the new arguments.
extra_kinds = [
ArgKind.ARG_NAMED if name in last_type.required_keys else ArgKind.ARG_NAMED_OPT
for name in last_type.items
]
new_arg_kinds = typ.arg_kinds[:-1] + extra_kinds
new_arg_names = typ.arg_names[:-1] + list(last_type.items)
new_arg_types = typ.arg_types[:-1] + list(last_type.items.values())
if last_type.fallback.type.special_alias is not None:
# This is a named TypedDict, need to add fine-grained dependency.
self.add_type_alias_deps({last_type.fallback.type.special_alias.fullname})
return typ.copy_modified(
arg_kinds=new_arg_kinds, arg_names=new_arg_names, arg_types=new_arg_types
)

def prepare_method_signature(self, func: FuncDef, info: TypeInfo) -> None:
"""Check basic signature validity and tweak annotation of self/cls argument."""
# Only non-static methods are special.
Expand Down
2 changes: 1 addition & 1 deletion mypy/typeanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ
elif fullname in ("typing.Unpack", "typing_extensions.Unpack"):
# We don't want people to try to use this yet.
if not self.options.enable_incomplete_features:
self.fail('"Unpack" is not supported by mypy yet', t)
self.fail('"Unpack" is not supported yet, use --enable-incomplete-features', t)
return AnyType(TypeOfAny.from_error)
return UnpackType(self.anal_type(t.args[0]), line=t.line, column=t.column)
return None
Expand Down
96 changes: 96 additions & 0 deletions test-data/unit/check-varargs.test
Original file line number Diff line number Diff line change
Expand Up @@ -760,3 +760,99 @@ bar(*good3)
bar(*bad1) # E: Argument 1 to "bar" has incompatible type "*I[str]"; expected "float"
bar(*bad2) # E: List or tuple expected as variadic arguments
[builtins fixtures/dict.pyi]

-- Keyword arguments unpacking

[case testUnpackOutsideOfKwargs]
from typing_extensions import Unpack, TypedDict
class Person(TypedDict):
name: str
age: int

x: Unpack[Person]
def foo(x: Unpack[Person]) -> None:
...
def bar(x: int, *args: Unpack[Person]) -> None:
...
def baz(**kwargs: Unpack[Person]) -> None:
...
[builtins fixtures/dict.pyi]

[case testUnpackWithoutTypedDict]
from typing_extensions import Unpack

def foo(**kwargs: Unpack[dict]) -> None:
...
[builtins fixtures/dict.pyi]

[case testUnpackTypedDictTotality]
from typing_extensions import Unpack, TypedDict

class Circle(TypedDict, total=True):
radius: int
color: str
x: int
y: int

def foo(**kwargs: Unpack[Circle]):
...
foo(x=0, y=0, color='orange')

class Square(TypedDict, total=False):
side: int
color: str

def bar(**kwargs: Unpack[Square]):
...
bar(side=12)
[builtins fixtures/dict.pyi]

[case testUnpackUnexpectedKeyword]
from typing_extensions import Unpack, TypedDict

class Person(TypedDict, total=False):
name: str
age: int

def foo(**kwargs: Unpack[Person]) -> None:
...
foo(name='John', age=42, department='Sales')
foo(name='Jennifer', age=38)
[builtins fixtures/dict.pyi]

[case testUnpackKeywordTypes]
from typing_extensions import Unpack, TypedDict

class Person(TypedDict):
name: str
age: int

def foo(**kwargs: Unpack[Person]):
...
foo(name='John', age='42')
foo(name='Jennifer', age=38)
[builtins fixtures/dict.pyi]

[case testFunctionBodyWithUnpackedKwargs]
from typing_extensions import Unpack, TypedDict

class Person(TypedDict):
name: str
age: int

def foo(**kwargs: Unpack[Person]) -> int:
name: str = kwargs['name']
age: str = kwargs['age']
department: str = kwargs['department']
return kwargs['age']
[builtins fixtures/dict.pyi]

[case testUnpackWithDuplicateKeywords]
from typing_extensions import Unpack, TypedDict

class Person(TypedDict):
name: str
age: int
def foo(name: str, **kwargs: Unpack[Person]) -> None:
...
[builtins fixtures/dict.pyi]
Copy link
Copy Markdown
Member

@sobolevn sobolevn Aug 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some other cases that I think are worth covering:

  1. When TypedDict has kwargs argument (because it is special cased in the implementation)
  2. When TypedDict has invalid chars for aguments, like T = TypedDict("T", {"@": int})
  3. When TypedDict is empty, no keys
  4. When TypedDict has a key that overrides other arguments like T = TypedDict("T", {"a": int}) and def some(a: str, **kwargs: Unpack[T]): ... (and similar cases like def some(a: str, /, **kwargs: Unpack[T]) and def some(*, a: str, **kwargs: Unpack[T]))
  5. We can also check overrides with compatible / incompatible typed dics
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the tests (and also fixed error messages for incompatible overrides). Btw on non-identifier keys, Python allows them:

>>> def foo(**a):
...     print(a)
... 
>>> foo(**{"@": 1})
{'@': 1}

Note I already had a test for named argument overlap, so I added the same one for positional-only (which should be allowed).