Skip to content

Commit 0ce09a1

Browse files
authored
Implement support for closed TypedDicts (PEP 728) (#21382)
Implement support for the closed keyword on TypedDicts (part of [PEP 728][]). Additionally, fix some preexisting issues that I came across while updating the logic. * Fixes #7435 * Fixes #7981 * Fixes #8714 * Fixes #12143 * Fixes #20401 * Partially addresses #18176 [PEP 728]: https://peps.python.org/pep-0728/
1 parent 4c8f994 commit 0ce09a1

27 files changed

Lines changed: 2431 additions & 256 deletions

docs/source/typed_dict.rst

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ arbitrarily complex types. For example, you can define nested
8181
``TypedDict``\s and containers with ``TypedDict`` items.
8282
Unlike most other types, mypy uses structural compatibility checking
8383
(or structural subtyping) with ``TypedDict``\s. A ``TypedDict`` object with
84-
extra items is compatible with (a subtype of) a narrower
84+
extra items can be compatible with (a subtype of) a narrower
8585
``TypedDict``, assuming item types are compatible (*totality* also affects
86-
subtyping, as discussed below).
86+
subtyping, as does *closing*, as discussed below).
8787

8888
A ``TypedDict`` object is not a subtype of the regular ``dict[...]``
8989
type (and vice versa), since :py:class:`dict` allows arbitrary keys to be
@@ -276,6 +276,84 @@ vary :ref:`covariantly <variance-of-generics>`:
276276
m: Movie = {"name": "Jaws", "year": 1975}
277277
process_entry(m) # OK
278278
279+
You can override a read-only item with a compatible subtype, make a
280+
read-only item mutable, and inherit from multiple parents with compatible
281+
definitions:
282+
283+
.. code-block:: python
284+
285+
from collections.abc import Collection, Sequence
286+
287+
class Competition(TypedDict):
288+
hosts: ReadOnly[Collection[str]]
289+
entries: ReadOnly[Sequence[Entry]]
290+
291+
class MovieShow(TypedDict):
292+
entries: list[Movie]
293+
294+
class Oscars(Competition, MovieShow):
295+
hosts: set[str]
296+
297+
Defining ``hosts`` as a mutable ``set[str]`` item works as this is compatible
298+
with the read-only ``Collection[str]`` definition in ``Competition``.
299+
``entries`` will be of type ``list[Movie]``, taken from the ``MovieShow`` type,
300+
as it is the only non-readonly definition, and is compatible with the definition
301+
in ``Competition``.
302+
303+
If an item is only defined in supertypes, and is always read-only, mypy takes
304+
the definition from the first parent in the inheritance order, and raises an
305+
error if any other parent definition is incompatible:
306+
307+
.. code-block:: python
308+
309+
class NameIds(TypedDict):
310+
ids: ReadOnly[Collection[str]]
311+
312+
class OrderedIds(TypedDict):
313+
ids: ReadOnly[Sequence[int | str]]
314+
315+
class OrderedNameIds(NameIds, OrderedIds):
316+
pass # Error! Parent definitions incompatible
317+
318+
In this example, the definition of ``ids`` will be taken from ``NameIds``,
319+
which would not be compatible with the definition in ``OrderedIds``; reordering
320+
the parents would not solve the problem. Instead, you will need to make a
321+
compatible definition explicitly:
322+
323+
.. code-block:: python
324+
325+
class OrderedNameIds(NameIds, OrderedIds):
326+
ids: ReadOnly[Sequence[str]]
327+
328+
Closing
329+
-------
330+
331+
You can use the ``closed`` keyword, introduced to ``TypedDict`` in Python
332+
3.15 (and available via ``typing_extensions.TypedDict`` in older
333+
versions) to prevent structural subtypes from adding extra keys to a
334+
type (:pep:`728`):
335+
336+
.. code-block:: python
337+
338+
HasName = TypedDict("HasName", {"name": str})
339+
HasOnlyName = TypedDict("HasOnlyName", {"name": str}, closed=True)
340+
Movie = TypedDict("Movie", {"name": str, "year": int})
341+
342+
movie: Movie = {"name": "Nimona", "year": 2023}
343+
has_name: HasName = movie # OK: type is open
344+
has_only_name: HasOnlyName = movie # Error: type is closed
345+
346+
This allows the typechecker to determine that certain operations are safe,
347+
when they otherwise wouldn't be due to the potential presence of unknown
348+
keys.
349+
350+
The ``closed`` keyword can also be used in class-based syntax:
351+
352+
.. code-block:: python
353+
354+
class HasOnlyName(TypedDict, closed=True):
355+
name: str
356+
279357
Unions of TypedDicts
280358
--------------------
281359

@@ -289,6 +367,40 @@ need to give each TypedDict the same key where each value has a unique
289367
:ref:`Literal type <literal_types>`. Then, check that key to distinguish
290368
between your TypedDicts.
291369

370+
Alternatively, you can implement tagged unions with single-key wrapper dictionaries:
371+
372+
.. code-block:: python
373+
374+
class Book(TypedDict):
375+
name: str
376+
length: int
377+
...
378+
379+
class DVD(TypedDict):
380+
name: str
381+
length: int
382+
...
383+
384+
TaggedBook = TypedDict('TaggedBook', {'book': Book}, closed=True)
385+
TaggedDVD = TypedDict('TaggedDVD', {'dvd': DVD}, closed=True)
386+
type Inventory = TaggedBook | TaggedDVD
387+
388+
def print_length(inventory: Inventory) -> None:
389+
if "book" in inventory:
390+
print(inventory["book"]["length"], 'pages')
391+
else:
392+
print(inventory["dvd"]["length"], 'minutes')
393+
394+
Here, the ``closed`` keyword is necessary to allow the ``if`` guard to safely
395+
narrow the types; without it, there could be a structural subtype of ``TaggedDVD``
396+
that contains a ``book`` field of arbitrary type.
397+
398+
.. note::
399+
400+
Applying ``@final`` to a TypedDict is a legacy way of marking it as closed
401+
for the purposes of type narrowing. It was never fully implemented and is
402+
now superseded; it may be removed in future.
403+
292404
Inline TypedDict types
293405
----------------------
294406

mypy/checker.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2813,6 +2813,8 @@ def visit_class_def(self, defn: ClassDef) -> None:
28132813
self.check_multiple_inheritance(typ)
28142814
self.check_metaclass_compatibility(typ)
28152815
self.check_final_deletable(typ)
2816+
if typ.typeddict_type:
2817+
self.check_typeddict_inheritance(defn)
28162818

28172819
if defn.decorators:
28182820
sig: Type = type_object_type(defn.info)
@@ -3219,6 +3221,44 @@ def check_metaclass_compatibility(self, typ: TypeInfo) -> None:
32193221
if explanation:
32203222
self.note(explanation, typ, code=codes.METACLASS)
32213223

3224+
def check_typeddict_inheritance(self, defn: ClassDef) -> None:
3225+
"""Ensure that the final definition of a TypedDict is compatible with its base classes."""
3226+
assert defn.info.typeddict_type
3227+
td = defn.info.typeddict_type
3228+
data = defn.info.typeddict_data
3229+
if data is None or not data.ready:
3230+
return
3231+
for base, base_items in data.bases:
3232+
assert base.typeddict_type
3233+
for field_name, base_type in base_items.items():
3234+
field_type = td.items[field_name]
3235+
assert field_type
3236+
is_readonly = field_name in base.typeddict_type.readonly_keys
3237+
if is_readonly:
3238+
is_compatible = is_subtype(field_type, base_type)
3239+
else:
3240+
is_compatible = is_equivalent(field_type, base_type)
3241+
if not is_compatible:
3242+
source = data.field_sources[field_name]
3243+
if source.base is None:
3244+
self.fail(
3245+
f'Definition of field "{field_name}" incompatible with base class '
3246+
f'"{base.name}"',
3247+
source.ctx,
3248+
)
3249+
else:
3250+
self.fail(
3251+
f'Incompatible definitions of field "{field_name}" in base classes '
3252+
f'"{base.name}" and "{source.base.name}"',
3253+
source.ctx,
3254+
)
3255+
if field_name in td.readonly_keys:
3256+
self.note(
3257+
f'This can be resolved by redeclaring the field "{field_name}" '
3258+
f"with a mutually compatible type",
3259+
source.ctx,
3260+
)
3261+
32223262
def visit_import_from(self, node: ImportFrom) -> None:
32233263
for name, _ in node.names:
32243264
if (sym := self.globals.get(name)) is not None:
@@ -6393,7 +6433,8 @@ def conditional_types_for_iterable(
63936433
) -> tuple[Type, Type]:
63946434
"""
63956435
Narrows the type of `iterable_type` based on the type of `item_type`.
6396-
For now, we only support narrowing unions of TypedDicts based on left operand being literal string(s).
6436+
For now, we only support narrowing unions of TypedDicts, and TypeVars with TypedDict
6437+
bounds, based on left operand being literal string(s).
63976438
"""
63986439
if_types: list[Type] = []
63996440
else_types: list[Type] = []
@@ -6407,16 +6448,30 @@ def conditional_types_for_iterable(
64076448
item_str_literals = try_getting_str_literals_from_type(item_type)
64086449

64096450
for possible_iterable_type in possible_iterable_types:
6410-
if item_str_literals and isinstance(possible_iterable_type, TypedDictType):
6451+
bound = (
6452+
get_proper_type(possible_iterable_type.upper_bound)
6453+
if isinstance(possible_iterable_type, TypeVarType)
6454+
else possible_iterable_type
6455+
)
6456+
6457+
if item_str_literals and isinstance(bound, TypedDictType):
64116458
for key in item_str_literals:
6412-
if key in possible_iterable_type.required_keys:
6459+
if key in bound.required_keys:
64136460
if_types.append(possible_iterable_type)
6414-
elif (
6415-
key in possible_iterable_type.items or not possible_iterable_type.is_final
6461+
elif key in bound.items and isinstance(
6462+
get_proper_type(bound.items[key]), UninhabitedType
64166463
):
6417-
if_types.append(possible_iterable_type)
6464+
# If an item is explicitly declared uninhabited, we can exclude it from
6465+
# if_types; see testOperatorContainsNarrowsTypedDicts_closed
6466+
else_types.append(possible_iterable_type)
6467+
elif key not in bound.items and (bound.is_closed or bound.is_final):
6468+
# If an item is missing and the type is closed, we can exclude it from
6469+
# if_types; see testOperatorContainsNarrowsTypedDicts_closed
6470+
# We also support "final" as a legacy way of expressing "closed" in this
6471+
# specific case; see testOperatorContainsNarrowsTypedDicts_final
64186472
else_types.append(possible_iterable_type)
64196473
else:
6474+
if_types.append(possible_iterable_type)
64206475
else_types.append(possible_iterable_type)
64216476
else:
64226477
if_types.append(possible_iterable_type)

mypy/checkexpr.py

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

824824
for item_name_expr, item_arg in kwargs:
825825
if item_name_expr:
@@ -843,22 +843,30 @@ def validate_typeddict_kwargs(
843843
result[literal_value] = [item_arg]
844844
always_present_keys.add(literal_value)
845845
else:
846-
last_star_found = item_arg
847-
if not self.validate_star_typeddict_item(
846+
is_valid, is_open = self.validate_star_typeddict_item(
848847
item_arg, callee, result, always_present_keys
849-
):
848+
)
849+
if not is_valid:
850850
return None
851-
if self.chk.options.extra_checks and last_star_found is not None:
851+
if is_open:
852+
last_open_star_found = item_arg
853+
if self.chk.options.extra_checks and last_open_star_found is not None:
854+
if callee.is_closed:
855+
self.chk.fail(
856+
"Cannot unpack item that may contain extra keys into a closed TypedDict",
857+
last_open_star_found,
858+
code=codes.TYPEDDICT_ITEM,
859+
)
852860
absent_keys = []
853861
for key in callee.items:
854862
if key not in callee.required_keys and key not in result:
855863
absent_keys.append(key)
856864
if absent_keys:
857-
# Having an optional key not explicitly declared by a ** unpacked
865+
# Having an optional key not explicitly declared by a ** unpacked open
858866
# TypedDict is unsafe, it may be an (incompatible) subtype at runtime.
859867
# TODO: catch the cases where a declared key is overridden by a subsequent
860868
# ** item without it (and not again overridden with complete ** item).
861-
self.msg.non_required_keys_absent_with_star(absent_keys, last_star_found)
869+
self.msg.non_required_keys_absent_with_star(absent_keys, last_open_star_found)
862870
return result, always_present_keys
863871

864872
def validate_star_typeddict_item(
@@ -867,14 +875,18 @@ def validate_star_typeddict_item(
867875
callee: TypedDictType,
868876
result: dict[str, list[Expression]],
869877
always_present_keys: set[str],
870-
) -> bool:
878+
) -> tuple[bool, bool]:
871879
"""Update keys/expressions from a ** expression in TypedDict constructor.
872880
873-
Note `result` and `always_present_keys` are updated in place. Return true if the
874-
expression `item_arg` may valid in `callee` TypedDict context.
881+
Note `result` and `always_present_keys` are updated in place.
882+
883+
First tuple item returned is true if the expression `item_arg` may valid
884+
in `callee` TypedDict context. Second tuple item returned is true if the
885+
expression may contain other keys not explicitly declared.
875886
"""
876887
inferred = get_proper_type(self.accept(item_arg, type_context=callee))
877-
possible_tds = []
888+
any_fallback = False
889+
possible_tds: list[TypedDictType] = []
878890
if isinstance(inferred, TypedDictType):
879891
possible_tds = [inferred]
880892
elif isinstance(inferred, UnionType):
@@ -883,10 +895,14 @@ def validate_star_typeddict_item(
883895
possible_tds.append(item)
884896
elif not self.valid_unpack_fallback_item(item):
885897
self.msg.unsupported_target_for_star_typeddict(item, item_arg)
886-
return False
898+
return False, True
899+
else:
900+
any_fallback = True
887901
elif not self.valid_unpack_fallback_item(inferred):
888902
self.msg.unsupported_target_for_star_typeddict(inferred, item_arg)
889-
return False
903+
return False, True
904+
else:
905+
any_fallback = True
890906
all_keys: set[str] = set()
891907
for td in possible_tds:
892908
all_keys |= td.items.keys()
@@ -915,7 +931,8 @@ def validate_star_typeddict_item(
915931
# If this key is not required at least in some item of a union
916932
# it may not shadow previous item, so we need to type check both.
917933
result[key].append(arg)
918-
return True
934+
all_closed = all(t.is_closed for t in possible_tds)
935+
return True, any_fallback or not all_closed
919936

920937
def valid_unpack_fallback_item(self, typ: ProperType) -> bool:
921938
if isinstance(typ, AnyType):

mypy/copytype.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def visit_tuple_type(self, t: TupleType) -> ProperType:
107107

108108
def visit_typeddict_type(self, t: TypedDictType) -> ProperType:
109109
return self.copy_common(
110-
t, TypedDictType(t.items, t.required_keys, t.readonly_keys, t.fallback)
110+
t, TypedDictType(t.items, t.required_keys, t.readonly_keys, t.is_closed, t.fallback)
111111
)
112112

113113
def visit_literal_type(self, t: LiteralType) -> ProperType:

mypy/expandtype.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ def _possible_callable_kwargs(cls, repl: Parameters, dict_type: Instance) -> Pro
336336
return dict_type
337337
kwargs = {}
338338
required_names = set()
339-
extra_items: Type = UninhabitedType()
339+
extra_items: Type | None = None
340340
for kind, name, type in zip(repl.arg_kinds, repl.arg_names, repl.arg_types):
341341
if kind == ArgKind.ARG_NAMED and name is not None:
342342
kwargs[name] = type
@@ -346,10 +346,11 @@ def _possible_callable_kwargs(cls, repl: Parameters, dict_type: Instance) -> Pro
346346
extra_items = type
347347
elif not kind.is_star() and name is not None:
348348
kwargs[name] = type
349-
if not kwargs:
349+
if not kwargs and extra_items is not None:
350350
return Instance(dict_type.type, [dict_type.args[0], extra_items])
351-
# TODO: when PEP 728 is implemented, pass extra_items below.
352-
return TypedDictType(kwargs, required_names, set(), fallback=dict_type)
351+
# TODO: when PEP 728 `extra_items` is implemented, pass extra_items below.
352+
is_closed = extra_items is None
353+
return TypedDictType(kwargs, required_names, set(), is_closed, fallback=dict_type)
353354

354355
def visit_type_var_tuple(self, t: TypeVarTupleType) -> Type:
355356
# Sometimes solver may need to expand a type variable with (a copy of) itself

mypy/exprtotype.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ def expr_to_unanalyzed_type(
277277
value, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified
278278
)
279279
result = TypedDictType(
280-
items, set(), set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column
280+
items, set(), set(), False, Instance(MISSING_FALLBACK, ()), expr.line, expr.column
281281
)
282282
result.extra_items_from = extra_items_from
283283
return result

mypy/fastparse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2143,7 +2143,7 @@ def visit_Dict(self, n: ast3.Dict) -> Type:
21432143
continue
21442144
return self.invalid_type(n)
21452145
items[item_name.value] = self.visit(value)
2146-
result = TypedDictType(items, set(), set(), _dummy_fallback, n.lineno, n.col_offset)
2146+
result = TypedDictType(items, set(), set(), False, _dummy_fallback, n.lineno, n.col_offset)
21472147
result.extra_items_from = extra_items_from
21482148
return result
21492149

0 commit comments

Comments
 (0)