Skip to content

Commit bd9fee9

Browse files
author
Alice Purcell
committed
Implement closed support in unpacking
Unpacking a TypedDict with an undeclared key to a TypedDict with that key declared as not required is acceptable if the former is closed. Unpacking an open TypedDict into a closed TypedDict is never safe.
1 parent ee92008 commit bd9fee9

2 files changed

Lines changed: 87 additions & 15 deletions

File tree

mypy/checkexpr.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -850,8 +850,8 @@ def validate_typeddict_kwargs(
850850
result = defaultdict(list)
851851
# Keys that are guaranteed to be present no matter what (e.g. for all items of a union)
852852
always_present_keys = set()
853-
# Indicates latest encountered ** unpack among items.
854-
last_star_found = None
853+
# Indicates latest encountered ** unpack of a non-closed type among items.
854+
last_open_star_found = None
855855

856856
for item_name_expr, item_arg in kwargs:
857857
if item_name_expr:
@@ -875,22 +875,30 @@ def validate_typeddict_kwargs(
875875
result[literal_value] = [item_arg]
876876
always_present_keys.add(literal_value)
877877
else:
878-
last_star_found = item_arg
879-
if not self.validate_star_typeddict_item(
878+
is_valid, is_open = self.validate_star_typeddict_item(
880879
item_arg, callee, result, always_present_keys
881-
):
880+
)
881+
if not is_valid:
882882
return None
883-
if self.chk.options.extra_checks and last_star_found is not None:
883+
if is_open:
884+
last_open_star_found = item_arg
885+
if self.chk.options.extra_checks and last_open_star_found is not None:
886+
if callee.is_closed:
887+
self.chk.fail(
888+
"Cannot unpack item that may contain extra keys into a closed TypedDict",
889+
last_open_star_found,
890+
code=codes.TYPEDDICT_ITEM,
891+
)
884892
absent_keys = []
885893
for key in callee.items:
886894
if key not in callee.required_keys and key not in result:
887895
absent_keys.append(key)
888896
if absent_keys:
889-
# Having an optional key not explicitly declared by a ** unpacked
897+
# Having an optional key not explicitly declared by a ** unpacked open
890898
# TypedDict is unsafe, it may be an (incompatible) subtype at runtime.
891899
# TODO: catch the cases where a declared key is overridden by a subsequent
892900
# ** item without it (and not again overridden with complete ** item).
893-
self.msg.non_required_keys_absent_with_star(absent_keys, last_star_found)
901+
self.msg.non_required_keys_absent_with_star(absent_keys, last_open_star_found)
894902
return result, always_present_keys
895903

896904
def validate_star_typeddict_item(
@@ -899,14 +907,18 @@ def validate_star_typeddict_item(
899907
callee: TypedDictType,
900908
result: dict[str, list[Expression]],
901909
always_present_keys: set[str],
902-
) -> bool:
910+
) -> tuple[bool, bool]:
903911
"""Update keys/expressions from a ** expression in TypedDict constructor.
904912
905-
Note `result` and `always_present_keys` are updated in place. Return true if the
906-
expression `item_arg` may valid in `callee` TypedDict context.
913+
Note `result` and `always_present_keys` are updated in place.
914+
915+
First tuple item returned is true if the expression `item_arg` may valid
916+
in `callee` TypedDict context. Second tuple item returned is true if the
917+
expression may contain other keys not explicitly declared.
907918
"""
908919
inferred = get_proper_type(self.accept(item_arg, type_context=callee))
909-
possible_tds = []
920+
any_fallback = False
921+
possible_tds: list[TypedDictType] = []
910922
if isinstance(inferred, TypedDictType):
911923
possible_tds = [inferred]
912924
elif isinstance(inferred, UnionType):
@@ -915,10 +927,14 @@ def validate_star_typeddict_item(
915927
possible_tds.append(item)
916928
elif not self.valid_unpack_fallback_item(item):
917929
self.msg.unsupported_target_for_star_typeddict(item, item_arg)
918-
return False
930+
return False, True
931+
else:
932+
any_fallback = True
919933
elif not self.valid_unpack_fallback_item(inferred):
920934
self.msg.unsupported_target_for_star_typeddict(inferred, item_arg)
921-
return False
935+
return False, True
936+
else:
937+
any_fallback = True
922938
all_keys: set[str] = set()
923939
for td in possible_tds:
924940
all_keys |= td.items.keys()
@@ -947,7 +963,8 @@ def validate_star_typeddict_item(
947963
# If this key is not required at least in some item of a union
948964
# it may not shadow previous item, so we need to type check both.
949965
result[key].append(arg)
950-
return True
966+
all_closed = all(t.is_closed for t in possible_tds)
967+
return True, any_fallback or not all_closed
951968

952969
def valid_unpack_fallback_item(self, typ: ProperType) -> bool:
953970
if isinstance(typ, AnyType):

test-data/unit/check-typeddict.test

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5274,6 +5274,61 @@ def func(t: T):
52745274
[builtins fixtures/dict.pyi]
52755275
[typing fixtures/typing-typeddict.pyi]
52765276

5277+
[case testTypedDictUnpackFromClosedMissingKey]
5278+
# flags: --extra-checks
5279+
from typing import TypedDict
5280+
from typing_extensions import Never, NotRequired
5281+
D1 = TypedDict("D1", {"a": int, "b": str}, closed=True)
5282+
D2 = TypedDict("D2", {"a": int, "b": str, "c": int})
5283+
D3 = TypedDict("D3", {"a": int, "b": str, "c": NotRequired[int]})
5284+
d1: D1
5285+
d2: D2 = {**d1} # E: Missing key "c" for TypedDict "D2"
5286+
d3: D3 = {**d1}
5287+
[builtins fixtures/dict.pyi]
5288+
[typing fixtures/typing-typeddict.pyi]
5289+
5290+
[case testTypedDictUnpackIntoClosed]
5291+
# flags: --extra-checks
5292+
from typing import Any, Mapping, TypedDict, Union
5293+
from typing_extensions import Never, NotRequired
5294+
D1 = TypedDict("D1", {"a": int, "b": str}, closed=True)
5295+
D2 = TypedDict("D2", {"a": int, "b": str})
5296+
D3 = TypedDict("D3", {"c": int, "d": str}, closed=True)
5297+
D4 = TypedDict("D4", {"c": int, "d": str})
5298+
D5 = TypedDict("D5", {"a": int, "b": str, "c": int, "d": str}, closed=True)
5299+
d1: D1
5300+
d2: D2
5301+
d3: D3
5302+
d4: D4
5303+
d5: D5
5304+
m: Mapping[Any, Any]
5305+
u34: Union[D3, D4]
5306+
u35: Union[D3, D5]
5307+
u5m: Union[D5, Mapping[Any, Any]]
5308+
d5 = {**d1, **d3}
5309+
d5 = {
5310+
**d1,
5311+
**d4, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5312+
}
5313+
d5 = {
5314+
**d2, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5315+
**d3,
5316+
}
5317+
d5 = {
5318+
**d1,
5319+
**u34, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5320+
}
5321+
d5 = {**d1, **u35}
5322+
d5 = {
5323+
**m, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5324+
**d5,
5325+
}
5326+
d5 = {
5327+
**u5m, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5328+
}
5329+
[builtins fixtures/dict.pyi]
5330+
[typing fixtures/typing-typeddict.pyi]
5331+
52775332

52785333
[case testTypedDictFinalAndClassVar]
52795334
from typing import TypedDict, Final, ClassVar

0 commit comments

Comments
 (0)