Skip to content

Commit c71fdb0

Browse files
a12kAaron Wieczorekhauntsaninja
authored
gh-20512: Fix specialization leak in generic TypedDict.update() (#20517)
Fixes gh-20512 This PR fixes a bug where calling .update() on a specialized generic TypedDict like Group[int] would fail type checking because the plugin reverted to the unspecialized definition (Group[ValT]). After a lot of dead ends this one wasn't as straightforward as I thought initially. After spending a lot of time in `checkmember.py` and `checkexpr.py`I finally found the issue in `mypy/plugins/default.py`. The call got the anonymous version of the TypedDict from the TypeInfo, which pointed back to the original generic declaration. This caused specialized types to be replaced by their original TypeVar placeholders during the signature construction for update(). Whew! The fix basically uses `ctx.type` (the specialized type of TypedDict being updated) as the source for the items, then applies the anonymous fallback and sets `required_keys` to an empty set to keep the correct behavior of the update method. Added a test in `check-typeddict.test` which seemed like the right place. All tests pass, as well as the original repro script from the linked issue. --------- Co-authored-by: Aaron Wieczorek <woz@Aarons-MacBook-Pro.local> Co-authored-by: hauntsaninja <hauntsaninja@gmail.com>
1 parent c41dbd0 commit c71fdb0

File tree

5 files changed

+41
-11
lines changed

5 files changed

+41
-11
lines changed

mypy/constraints.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -816,7 +816,7 @@ def visit_instance(self, template: Instance) -> list[Constraint]:
816816
if isinstance(actual, Overloaded) and actual.fallback is not None:
817817
actual = actual.fallback
818818
if isinstance(actual, TypedDictType):
819-
actual = actual.as_anonymous().fallback
819+
actual = actual.create_anonymous_fallback()
820820
if isinstance(actual, LiteralType):
821821
actual = actual.fallback
822822
if isinstance(actual, Instance):

mypy/meet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1263,7 +1263,7 @@ def typed_dict_mapping_overlap(
12631263
key_type, value_type = get_proper_types(other.args)
12641264

12651265
# TODO: is there a cleaner way to get str_type here?
1266-
fallback = typed.as_anonymous().fallback
1266+
fallback = typed.create_anonymous_fallback()
12671267
str_type = fallback.type.bases[0].args[0] # typing._TypedDict inherits Mapping[str, object]
12681268

12691269
# Special case: a TypedDict with no required keys overlaps with an empty dict.

mypy/plugins/default.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,9 @@ def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
479479
arg_type = get_proper_type(signature.arg_types[0])
480480
if not isinstance(arg_type, TypedDictType):
481481
return signature
482-
arg_type = arg_type.as_anonymous()
483-
arg_type = arg_type.copy_modified(required_keys=set())
482+
arg_type = ctx.type.copy_modified(
483+
fallback=arg_type.create_anonymous_fallback(), required_keys=set()
484+
)
484485
if ctx.args and ctx.args[0]:
485486
if signature.name in _TP_DICT_MUTATING_METHODS:
486487
# If we want to mutate this object in place, we need to set this flag,

mypy/types.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3080,11 +3080,11 @@ def is_final(self) -> bool:
30803080
def is_anonymous(self) -> bool:
30813081
return self.fallback.type.fullname in TPDICT_FB_NAMES
30823082

3083-
def as_anonymous(self) -> TypedDictType:
3083+
def create_anonymous_fallback(self) -> Instance:
30843084
if self.is_anonymous():
3085-
return self
3085+
return self.fallback
30863086
assert self.fallback.type.typeddict_type is not None
3087-
return self.fallback.type.typeddict_type.as_anonymous()
3087+
return self.fallback.type.typeddict_type.create_anonymous_fallback()
30883088

30893089
def copy_modified(
30903090
self,
@@ -3110,10 +3110,6 @@ def copy_modified(
31103110
required_keys &= set(item_names)
31113111
return TypedDictType(items, required_keys, readonly_keys, fallback, self.line, self.column)
31123112

3113-
def create_anonymous_fallback(self) -> Instance:
3114-
anonymous = self.as_anonymous()
3115-
return anonymous.fallback
3116-
31173113
def names_are_wider_than(self, other: TypedDictType) -> bool:
31183114
return len(other.items.keys() - self.items.keys()) == 0
31193115

test-data/unit/check-typeddict.test

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3011,6 +3011,39 @@ reveal_type(s3) # N: Revealed type is "TypedDict('__main__.Sub3', {'x': Any, 'y
30113011
[builtins fixtures/dict.pyi]
30123012
[typing fixtures/typing-typeddict.pyi]
30133013

3014+
[case testGenericTypedDictUpdate]
3015+
from typing import TypedDict, Generic, TypeVar
3016+
3017+
T = TypeVar("T")
3018+
3019+
class Group(TypedDict, Generic[T]):
3020+
a: T
3021+
3022+
value: Group[int] = {"a": 1}
3023+
3024+
def func(value2: Group[int]) -> None:
3025+
value.update(value2)
3026+
value.update({"a": 2})
3027+
value.update({"a": "string"}) # E: Incompatible types (expression has type "str", TypedDict item "a" has type "int")
3028+
3029+
S = TypeVar("S")
3030+
3031+
class MultiField(TypedDict, Generic[T, S]):
3032+
x: T
3033+
y: S
3034+
3035+
mf1: MultiField[int, str] = {"x": 1, "y": "a"}
3036+
mf2: MultiField[int, str] = {"x": 2, "y": "b"}
3037+
mf1.update(mf2)
3038+
3039+
mf3: MultiField[str, str] = {"x": "test", "y": "c"}
3040+
mf1.update(mf3) # E: Argument 1 to "update" of "TypedDict" has incompatible type "MultiField[str, str]"; expected "TypedDict({'x': int, 'y': str})"
3041+
3042+
mf1.update({"x": 3})
3043+
mf1.update({"y": "d"})
3044+
[builtins fixtures/dict.pyi]
3045+
[typing fixtures/typing-typeddict.pyi]
3046+
30143047
[case testTypedDictAttributeOnClassObject]
30153048
from typing import TypedDict
30163049

0 commit comments

Comments
 (0)