Skip to content

Commit 34df265

Browse files
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 f550466 commit 34df265

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
@@ -820,8 +820,8 @@ def validate_typeddict_kwargs(
820820
result = defaultdict(list)
821821
# Keys that are guaranteed to be present no matter what (e.g. for all items of a union)
822822
always_present_keys = set()
823-
# Indicates latest encountered ** unpack among items.
824-
last_star_found = None
823+
# Indicates latest encountered ** unpack of a non-closed type among items.
824+
last_open_star_found = None
825825

826826
for item_name_expr, item_arg in kwargs:
827827
if item_name_expr:
@@ -845,22 +845,30 @@ def validate_typeddict_kwargs(
845845
result[literal_value] = [item_arg]
846846
always_present_keys.add(literal_value)
847847
else:
848-
last_star_found = item_arg
849-
if not self.validate_star_typeddict_item(
848+
is_valid, is_open = self.validate_star_typeddict_item(
850849
item_arg, callee, result, always_present_keys
851-
):
850+
)
851+
if not is_valid:
852852
return None
853-
if self.chk.options.extra_checks and last_star_found is not None:
853+
if is_open:
854+
last_open_star_found = item_arg
855+
if self.chk.options.extra_checks and last_open_star_found is not None:
856+
if callee.is_closed:
857+
self.chk.fail(
858+
"Cannot unpack item that may contain extra keys into a closed TypedDict",
859+
last_open_star_found,
860+
code=codes.TYPEDDICT_ITEM,
861+
)
854862
absent_keys = []
855863
for key in callee.items:
856864
if key not in callee.required_keys and key not in result:
857865
absent_keys.append(key)
858866
if absent_keys:
859-
# Having an optional key not explicitly declared by a ** unpacked
867+
# Having an optional key not explicitly declared by a ** unpacked open
860868
# TypedDict is unsafe, it may be an (incompatible) subtype at runtime.
861869
# TODO: catch the cases where a declared key is overridden by a subsequent
862870
# ** item without it (and not again overridden with complete ** item).
863-
self.msg.non_required_keys_absent_with_star(absent_keys, last_star_found)
871+
self.msg.non_required_keys_absent_with_star(absent_keys, last_open_star_found)
864872
return result, always_present_keys
865873

866874
def validate_star_typeddict_item(
@@ -869,14 +877,18 @@ def validate_star_typeddict_item(
869877
callee: TypedDictType,
870878
result: dict[str, list[Expression]],
871879
always_present_keys: set[str],
872-
) -> bool:
880+
) -> tuple[bool, bool]:
873881
"""Update keys/expressions from a ** expression in TypedDict constructor.
874882
875-
Note `result` and `always_present_keys` are updated in place. Return true if the
876-
expression `item_arg` may valid in `callee` TypedDict context.
883+
Note `result` and `always_present_keys` are updated in place.
884+
885+
First tuple item returned is true if the expression `item_arg` may valid
886+
in `callee` TypedDict context. Second tuple item returned is true if the
887+
expression may contain other keys not explicitly declared.
877888
"""
878889
inferred = get_proper_type(self.accept(item_arg, type_context=callee))
879-
possible_tds = []
890+
any_fallback = False
891+
possible_tds: list[TypedDictType] = []
880892
if isinstance(inferred, TypedDictType):
881893
possible_tds = [inferred]
882894
elif isinstance(inferred, UnionType):
@@ -885,10 +897,14 @@ def validate_star_typeddict_item(
885897
possible_tds.append(item)
886898
elif not self.valid_unpack_fallback_item(item):
887899
self.msg.unsupported_target_for_star_typeddict(item, item_arg)
888-
return False
900+
return False, True
901+
else:
902+
any_fallback = True
889903
elif not self.valid_unpack_fallback_item(inferred):
890904
self.msg.unsupported_target_for_star_typeddict(inferred, item_arg)
891-
return False
905+
return False, True
906+
else:
907+
any_fallback = True
892908
all_keys: set[str] = set()
893909
for td in possible_tds:
894910
all_keys |= td.items.keys()
@@ -917,7 +933,8 @@ def validate_star_typeddict_item(
917933
# If this key is not required at least in some item of a union
918934
# it may not shadow previous item, so we need to type check both.
919935
result[key].append(arg)
920-
return True
936+
all_closed = all(t.is_closed for t in possible_tds)
937+
return True, any_fallback or not all_closed
921938

922939
def valid_unpack_fallback_item(self, typ: ProperType) -> bool:
923940
if isinstance(typ, AnyType):

test-data/unit/check-typeddict.test

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

5562+
[case testTypedDictUnpackFromClosedMissingKey]
5563+
# flags: --extra-checks
5564+
from typing import TypedDict
5565+
from typing_extensions import Never, NotRequired
5566+
D1 = TypedDict("D1", {"a": int, "b": str}, closed=True)
5567+
D2 = TypedDict("D2", {"a": int, "b": str, "c": int})
5568+
D3 = TypedDict("D3", {"a": int, "b": str, "c": NotRequired[int]})
5569+
d1: D1
5570+
d2: D2 = {**d1} # E: Missing key "c" for TypedDict "D2"
5571+
d3: D3 = {**d1}
5572+
[builtins fixtures/dict.pyi]
5573+
[typing fixtures/typing-typeddict.pyi]
5574+
5575+
[case testTypedDictUnpackIntoClosed]
5576+
# flags: --extra-checks
5577+
from typing import Any, Mapping, TypedDict, Union
5578+
from typing_extensions import Never, NotRequired
5579+
D1 = TypedDict("D1", {"a": int, "b": str}, closed=True)
5580+
D2 = TypedDict("D2", {"a": int, "b": str})
5581+
D3 = TypedDict("D3", {"c": int, "d": str}, closed=True)
5582+
D4 = TypedDict("D4", {"c": int, "d": str})
5583+
D5 = TypedDict("D5", {"a": int, "b": str, "c": int, "d": str}, closed=True)
5584+
d1: D1
5585+
d2: D2
5586+
d3: D3
5587+
d4: D4
5588+
d5: D5
5589+
m: Mapping[Any, Any]
5590+
u34: Union[D3, D4]
5591+
u35: Union[D3, D5]
5592+
u5m: Union[D5, Mapping[Any, Any]]
5593+
d5 = {**d1, **d3}
5594+
d5 = {
5595+
**d1,
5596+
**d4, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5597+
}
5598+
d5 = {
5599+
**d2, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5600+
**d3,
5601+
}
5602+
d5 = {
5603+
**d1,
5604+
**u34, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5605+
}
5606+
d5 = {**d1, **u35}
5607+
d5 = {
5608+
**m, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5609+
**d5,
5610+
}
5611+
d5 = {
5612+
**u5m, # E: Cannot unpack item that may contain extra keys into a closed TypedDict
5613+
}
5614+
[builtins fixtures/dict.pyi]
5615+
[typing fixtures/typing-typeddict.pyi]
5616+
55625617

55635618
[case testTypedDictFinalAndClassVar]
55645619
from typing import TypedDict, Final, ClassVar

0 commit comments

Comments
 (0)