-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
WIP: Enable Unpack for kwargs #15612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
4066eff
762f50d
b622dfb
bf2510a
2cd2bf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3935,8 +3935,24 @@ def check_simple_assignment( | |
f"{lvalue_name} has type", | ||
notes=notes, | ||
) | ||
|
||
if isinstance(rvalue_type, CallableType) and isinstance(lvalue_type, CallableType): | ||
self.check_compliance_with_pep692_assignment_rules( | ||
rvalue_type, lvalue_type, context | ||
) | ||
|
||
return rvalue_type | ||
|
||
def check_compliance_with_pep692_assignment_rules( | ||
self, source: CallableType, destination: CallableType, context: Context | ||
) -> None: | ||
# Destination contains kwargs and source doesn't. | ||
if destination.unpack_kwargs and not source.is_kw_arg: | ||
self.fail( | ||
"Incompatible function signatures - variable contains **kwargs and the expression does not. See PEP 692 for more details.", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Introducing a subtle difference between two ways to define function with keyword-only args looks like a bandaid for an unrelated problem (see below). I think |
||
context, | ||
) | ||
|
||
def check_member_assignment( | ||
self, instance_type: Type, attribute_type: Type, rvalue: Expression, context: Context | ||
) -> tuple[Type, Type, bool]: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -756,6 +756,19 @@ def foo(arg: bool, **kwargs: Unpack[Person]) -> None: ... | |
reveal_type(foo) # N: Revealed type is "def (arg: builtins.bool, **kwargs: Unpack[TypedDict('__main__.Person', {'name': builtins.str, 'age': builtins.int})])" | ||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackKwargsTypeInsideOfTheFunction] | ||
from typing import assert_type | ||
from typing_extensions import TypedDict, Unpack | ||
|
||
class Person(TypedDict): | ||
name: str | ||
age: int | ||
|
||
def foo(**kwargs: Unpack[Person]): | ||
assert_type(kwargs, Person) | ||
|
||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackOutsideOfKwargs] | ||
from typing_extensions import Unpack, TypedDict | ||
class Person(TypedDict): | ||
|
@@ -785,6 +798,8 @@ class Person(TypedDict): | |
age: int | ||
def foo(name: str, **kwargs: Unpack[Person]) -> None: # E: Overlap between argument names and ** TypedDict items: "name" | ||
... | ||
def bar(name:str, /, **kwargs: Unpack[Person]) -> None: # OK | ||
... | ||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackWithDuplicateKeywordKwargs] | ||
|
@@ -843,6 +858,42 @@ def bar(**kwargs: Unpack[Square]): | |
bar(side=12) | ||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackTypedDictRequired] | ||
from typing import Required, TypedDict | ||
from typing_extensions import Unpack | ||
|
||
class Movie(TypedDict, total=False): | ||
name: Required[str] | ||
year: Required[int] | ||
based_on: str | ||
|
||
def foo(**kwargs: Unpack[Movie]): ... | ||
|
||
foo(name="Life of Brian", year=1979) # OK | ||
foo(name="Harry Potter and the Philosopher's Stone", year=2001, based_on="book") # OK | ||
|
||
foo(name="Harry Potter and the Philosopher's Stone", based_on="book") # E: Missing named argument "year" for "foo" | ||
[builtins fixtures/dict.pyi] | ||
[typing fixtures/typing-typeddict.pyi] | ||
|
||
[case testUnpackTypedDictNotRequired] | ||
from typing import NotRequired, TypedDict | ||
from typing_extensions import Unpack | ||
|
||
class Movie(TypedDict, total=True): | ||
name: str | ||
year: int | ||
based_on: NotRequired[str] | ||
|
||
def foo(**kwargs: Unpack[Movie]): ... | ||
|
||
foo(name="Life of Brian", year=1979) # OK | ||
foo(name="Harry Potter and the Philosopher's Stone", year=2001, based_on="book") # OK | ||
|
||
foo(name="Harry Potter and the Philosopher's Stone", based_on="book") # E: Missing named argument "year" for "foo" | ||
[builtins fixtures/dict.pyi] | ||
[typing fixtures/typing-typeddict.pyi] | ||
|
||
[case testUnpackUnexpectedKeyword] | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
|
@@ -1066,3 +1117,166 @@ class C: | |
class D: | ||
def __init__(self, **kwds: Unpack[int, str]) -> None: ... # E: Unpack[...] requires exactly one type argument | ||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackKwargsAssignmentWhenSourceIsUntyped] | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
class Movie(TypedDict): | ||
name: str | ||
year: int | ||
|
||
def src(**kwargs): ... | ||
def dest(**kwargs: Unpack[Movie]): ... | ||
|
||
dest = src # OK | ||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackKwargsAssignmentWhenSourceHasTraditionallyTypedKwargs] | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
class Vehicle: | ||
... | ||
|
||
class Car(Vehicle): | ||
... | ||
|
||
class Motorcycle(Vehicle): | ||
... | ||
|
||
def src(**kwargs: Vehicle): ... | ||
|
||
class Vehicles(TypedDict): | ||
car: Car | ||
moto: Motorcycle | ||
|
||
def dest(**kwargs: Unpack[Vehicles]): ... | ||
|
||
dest = src # OK | ||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackKwargsAssignmentWhenDestinationHasTraditionallyTypedKwargs] | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
class Vehicle: | ||
... | ||
|
||
class Car(Vehicle): | ||
... | ||
|
||
class Motorcycle(Vehicle): | ||
... | ||
|
||
def dest(**kwargs: Vehicle): ... | ||
|
||
class Vehicles(TypedDict): | ||
car: Car | ||
moto: Motorcycle | ||
|
||
def src(**kwargs: Unpack[Vehicles]): ... | ||
|
||
dest = src # E: Incompatible types in assignment (expression has type "Callable[[KwArg(Vehicles)], Any]", variable has type "Callable[[KwArg(Vehicle)], Any]") | ||
[builtins fixtures/dict.pyi] | ||
|
||
[case testUnpackKwargsAssignmentWhenOnlySourceContainsKwargs] | ||
from typing import NotRequired | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
class Animal(TypedDict): | ||
name: str | ||
|
||
class Dog(Animal): | ||
breed: str | ||
|
||
class Example(TypedDict): | ||
animal: Animal | ||
string: str | ||
number: NotRequired[int] | ||
|
||
def src(**kwargs: Unpack[Example]): ... | ||
def dest(*, animal: Dog, string: str, number: int = ...): ... | ||
|
||
dest = src # OK | ||
|
||
def dest_with_positional_args(animal: Dog, string: str, number: int = ...): ... | ||
|
||
dest_with_positional_args = src # E: Incompatible types in assignment (expression has type "Callable[[KwArg(Example)], Any]", variable has type "Callable[[Dog, str, int], Any]") | ||
[builtins fixtures/dict.pyi] | ||
[typing fixtures/typing-typeddict.pyi] | ||
|
||
[case testUnpackKwargsAssignmentWhenOnlyDestinationContainsKwargs] | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
class Animal(TypedDict): | ||
name: str | ||
|
||
class Dog(Animal): | ||
breed: str | ||
|
||
def dest(**kwargs: Unpack[Animal]): ... | ||
def src(name: str): ... | ||
|
||
dog: Dog = {"name": "Daisy", "breed": "Labrador"} | ||
animal: Animal = dog | ||
|
||
dest = src # E: Incompatible function signatures - variable contains **kwargs and the expression does not. See PEP 692 for more details. | ||
|
||
[builtins fixtures/dict.pyi] | ||
[typing fixtures/typing-typeddict.pyi] | ||
|
||
[case testUnpackKwargsAssignmentWhenBothSidesContainKwargsTypedWithUnpack] | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
class Animal(TypedDict): | ||
name: str | ||
|
||
class Dog(Animal): | ||
breed: str | ||
|
||
def accept_animal(**kwargs: Unpack[Animal]): ... | ||
def accept_dog(**kwargs: Unpack[Dog]): ... | ||
|
||
accept_dog = accept_animal # OK | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is obviously not OK, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right, when I was writing this I was thinking more in terms of "will this cause a runtime error or not?" - but in terms of "is this a subtype of that?" it's not correct. Something I'll need to revisit. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that it is true, but only if we look at it from the normalized signature point of view. My thinking is that what matters is what is happening later in the function itself. If the function contains It becomes very cumbersome in case of "normal" function signatures (without There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is nothing cumbersome in reducing the PEP to just one paragraph that would explain that
Btw a comment on the last point, subtyping relations are quite counter-intuitive when you think about them in terms of packed TypedDicts. Consider this example: class A(TypedDict, total=False):
x: int
class B(TypedDict, total=False):
x: int
y: int
def takes_a(**kwargs: Unpack[A]) -> None: ...
def takes_b(**kwargs: Unpack[B]) -> None: ...
if bool():
takes_a = takes_b # mypy now says it is OK
else:
takes_b = takes_a # mypy now gives an error for this If you would think in terms of subtyping between TypedDicts, you might conclude the opposite relations, because Finally, the simple logic of always expanding is more future-proof. For example, if we would decide to add default types for TypedDicts, it would naturally translate to trailing homogeneous There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for a detailed response! There's a lot to unpack here (pun intended), but I think what you're saying makes sense. |
||
|
||
def also_accept_animal(**kwargs: Unpack[Animal]): ... | ||
def also_accept_dog(**kwargs: Unpack[Dog]): ... | ||
|
||
also_accept_animal = also_accept_dog # E: Incompatible types in assignment (expression has type "Callable[[KwArg(Dog)], Any]", variable has type "Callable[[KwArg(Animal)], Any]") | ||
|
||
[builtins fixtures/dict.pyi] | ||
[typing fixtures/typing-typeddict.pyi] | ||
|
||
[case testPassingKwargsFromFunctionToFunction] | ||
from typing_extensions import Unpack, TypedDict | ||
|
||
class Animal(TypedDict): | ||
name: str | ||
|
||
class Dog(Animal): | ||
breed: str | ||
|
||
def takes_name(name: str): | ||
... | ||
|
||
dog: Dog = {"name": "Daisy", "breed": "Labrador"} | ||
animal: Animal = dog | ||
|
||
def foo(**kwargs: Unpack[Animal]): | ||
return kwargs["name"] | ||
|
||
def bar(**kwargs: Unpack[Animal]): | ||
takes_name(**kwargs) | ||
|
||
def baz(animal: Animal): | ||
takes_name(**animal) | ||
|
||
def spam(**kwargs: Unpack[Animal]): | ||
baz(kwargs) | ||
|
||
foo(**animal) # OK! foo only expects and uses keywords of 'Animal'. | ||
|
||
bar(**animal) # E: This will fail at runtime because 'breed' keyword will be passed to 'takes_name' as well. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trying to support this is futile (not just in mypy, in any reasonable type checker), since There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, makes sense - probably another thing that PEP has to be amended for. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense in your opinion to report an error here instead:
? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will break a ton of existing code, unless you will make this check apply only when |
||
|
||
spam(**animal) # E: Again, 'breed' keyword will be eventually passed to 'takes_name'. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There core issue here has nothing to do with from typing import TypedDict
class Point(TypedDict):
x: int
y: int
def foo(*, x: int, y: int) -> None: ...
p: Point
foo(**p) which is unsafe because |
||
|
||
[builtins fixtures/dict.pyi] | ||
[typing fixtures/typing-typeddict.pyi] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checking this only for assignments is a bad idea. What about other situations involving subtyping like passing argument to a function, overload selection, type variable bound checks etc.