Skip to content

Commit 2d1333c

Browse files
committed
Delay subtype validation to TypeChecker
Calling is_subtype during semantic analysis is not legal; verifying type compatibility of a TypedDict subtype field definition with its supertypes needs to be done in TypeChecker. To avoid duplicating all the logic for determining sources, add a non-persisted field_constraints field to TypedDictType.
1 parent 9279a01 commit 2d1333c

3 files changed

Lines changed: 87 additions & 50 deletions

File tree

mypy/checker.py

Lines changed: 18 additions & 0 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,22 @@ 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+
for constraint in td.field_constraints:
3229+
field_name = constraint.field_name
3230+
field_type = td.items[field_name]
3231+
if constraint.is_readonly:
3232+
is_compatible = is_subtype(field_type, constraint.field_type)
3233+
else:
3234+
is_compatible = is_equivalent(field_type, constraint.field_type)
3235+
if not is_compatible:
3236+
self.fail(constraint.failure_message, constraint.ctx)
3237+
if constraint.failure_note:
3238+
self.note(constraint.failure_note, constraint.ctx)
3239+
32223240
def visit_import_from(self, node: ImportFrom) -> None:
32233241
for name, _ in node.names:
32243242
if (sym := self.globals.get(name)) is not None:

mypy/semanal_typeddict.py

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@
4040
require_bool_literal_argument,
4141
)
4242
from mypy.state import state
43-
from mypy.subtypes import is_equivalent, is_subtype
4443
from mypy.typeanal import check_for_explicit_any, has_any_from_unimported_type
4544
from mypy.types import (
4645
TPDICT_NAMES,
4746
AnyType,
4847
ReadOnlyType,
4948
RequiredType,
5049
Type,
50+
TypedDictFieldConstraint,
5151
TypedDictType,
5252
TypeOfAny,
5353
TypeVarLikeType,
@@ -182,7 +182,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
182182
new_field_sources, new_statements = self.analyze_typeddict_classdef_fields(defn)
183183
if new_field_sources is None:
184184
return True, None # Defer
185-
field_types, required_keys, readonly_keys, is_closed, any_placeholders = (
185+
field_types, required_keys, readonly_keys, is_closed, constraints = (
186186
self.resolve_field_inheritance(bases_info, new_field_sources, is_closed, defn)
187187
)
188188
info = self.build_typeddict_typeinfo(
@@ -193,7 +193,7 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
193193
is_closed,
194194
defn.line,
195195
existing_info,
196-
analysis_incomplete=any_placeholders,
196+
constraints=constraints,
197197
)
198198
defn.analyzed = TypedDictExpr(info)
199199
defn.analyzed.line = defn.line
@@ -266,45 +266,16 @@ def primary_source(self, sources: list[FieldSource]) -> FieldSource:
266266
mutable_sources = (s for s in reversed(sources) if not s.is_readonly)
267267
return next(mutable_sources, sources[-1])
268268

269-
def verify_field_compatibility(
269+
def verify_requiredness_compatibility(
270270
self,
271271
field_name: str,
272272
source: FieldSource,
273-
field_type: Type,
274-
is_readonly: bool,
275273
is_required: bool,
276274
primary_source_base_name: str | None,
277275
ctx: Context,
278276
) -> None:
279-
"""Verify compatibility of the final child type field with a base class source.
280-
281-
Verifies type compatibility, requiredness, and mutability.
282-
"""
283-
if has_placeholder(field_type) or has_placeholder(source.field_type):
284-
# Cannot verify type compatibility yet, but analysis will be deferred
285-
# and rerun later
286-
is_type_compatible = True
287-
elif source.is_readonly:
288-
is_type_compatible = is_subtype(field_type, source.field_type)
289-
else:
290-
is_type_compatible = is_equivalent(field_type, source.field_type)
291-
if not is_type_compatible:
292-
if primary_source_base_name is None:
293-
self.fail(
294-
f'Definition of field "{field_name}" incompatible with base class "{source.base_name}"',
295-
ctx,
296-
)
297-
else:
298-
self.fail(
299-
f'Incompatible definitions of field "{field_name}" in base classes "{source.base_name}" and "{primary_source_base_name}"',
300-
ctx,
301-
)
302-
if is_readonly:
303-
self.note(
304-
f'This can be resolved by redeclaring the field "{field_name}" with a mutually compatible type',
305-
ctx,
306-
)
307-
elif source.is_required and not is_required:
277+
"""Verify requiredness compatibility of the final child type field with a base class source."""
278+
if source.is_required and not is_required:
308279
if primary_source_base_name is None:
309280
self.fail(
310281
f'Field "{field_name}" is required in base class "{source.base_name}"', ctx
@@ -325,6 +296,36 @@ def verify_field_compatibility(
325296
ctx,
326297
)
327298

299+
def field_compatibility_constraint(
300+
self,
301+
field_name: str,
302+
source: FieldSource,
303+
is_readonly: bool,
304+
primary_source_base_name: str | None,
305+
ctx: Context,
306+
) -> TypedDictFieldConstraint:
307+
"""Information needed to verify compatibility of the final child type field with a base class source.
308+
309+
This cannot be done during semantic analysis, as it is not safe to check subtyping.
310+
"""
311+
failure_note = None
312+
if primary_source_base_name is None:
313+
failure_message = f'Definition of field "{field_name}" incompatible with base class "{source.base_name}"'
314+
else:
315+
failure_message = f'Incompatible definitions of field "{field_name}" in base classes "{source.base_name}" and "{primary_source_base_name}"'
316+
if is_readonly:
317+
failure_note = f'This can be resolved by redeclaring the field "{field_name}" with a mutually compatible type'
318+
319+
constraint = TypedDictFieldConstraint(
320+
field_name=field_name,
321+
field_type=source.field_type,
322+
is_readonly=source.is_readonly,
323+
failure_message=failure_message,
324+
failure_note=failure_note,
325+
ctx=ctx,
326+
)
327+
return constraint
328+
328329
def verify_field_against_closed_bases(
329330
self,
330331
field_name: str,
@@ -353,17 +354,13 @@ def resolve_field_inheritance(
353354
child_field_sources: dict[str, FieldSource],
354355
child_is_closed: bool | None,
355356
ctx: Context,
356-
) -> tuple[dict[str, Type], set[str], set[str], bool, bool]:
357-
"""Determine field types, requiredness, readonlyness, and closedness.
358-
359-
Additionally returns if any placeholders were seen, as they will prevent full
360-
analysis, but may not result in placeholders in the final type.
361-
"""
357+
) -> tuple[dict[str, Type], set[str], set[str], bool, list[TypedDictFieldConstraint]]:
358+
"""Determine field types, requiredness, readonlyness, closedness, and delayed constraints."""
362359
field_sources = self.field_sources_in_reverse_mro(bases, child_field_sources)
363360
field_types: dict[str, Type] = {}
364361
required_keys: set[str] = set()
365362
readonly_keys: set[str] = set()
366-
any_placeholders = False
363+
constraints: list[TypedDictFieldConstraint] = []
367364
closed_bases = [
368365
(base_info, base_fields.keys())
369366
for (base_info, base_fields) in bases
@@ -401,18 +398,22 @@ def resolve_field_inheritance(
401398
readonly_keys.add(field_name)
402399

403400
for source in sources:
404-
if has_placeholder(source.field_type):
405-
any_placeholders = True
406401
if source is not primary_source:
407-
self.verify_field_compatibility(
402+
self.verify_requiredness_compatibility(
408403
field_name,
409404
source,
410-
field_types[field_name],
411-
is_readonly,
412405
is_required,
413406
primary_source.base_name,
414407
primary_source.child_field_ctx or ctx,
415408
)
409+
constraint = self.field_compatibility_constraint(
410+
field_name,
411+
source,
412+
is_readonly,
413+
primary_source.base_name,
414+
primary_source.child_field_ctx or ctx,
415+
)
416+
constraints.append(constraint)
416417
self.verify_field_against_closed_bases(
417418
field_name,
418419
closed_bases,
@@ -421,7 +422,7 @@ def resolve_field_inheritance(
421422
)
422423

423424
is_closed = bool(closed_bases) if child_is_closed is None else child_is_closed
424-
return field_types, required_keys, readonly_keys, is_closed, any_placeholders
425+
return field_types, required_keys, readonly_keys, is_closed, constraints
425426

426427
def _parse_typeddict_base(self, base: Expression, ctx: Context) -> TypeInfo:
427428
if isinstance(base, RefExpr):
@@ -814,7 +815,7 @@ def build_typeddict_typeinfo(
814815
is_closed: bool,
815816
line: int,
816817
existing_info: TypeInfo | None,
817-
analysis_incomplete: bool = False,
818+
constraints: list[TypedDictFieldConstraint] | None = None,
818819
) -> TypeInfo:
819820
# Prefer typing then typing_extensions if available.
820821
fallback = (
@@ -827,7 +828,11 @@ def build_typeddict_typeinfo(
827828
typeddict_type = TypedDictType(
828829
item_types, required_keys, readonly_keys, is_closed, fallback
829830
)
830-
if has_placeholder(typeddict_type) or analysis_incomplete:
831+
if constraints:
832+
typeddict_type.field_constraints.extend(constraints)
833+
if has_placeholder(typeddict_type) or any(
834+
has_placeholder(c.field_type) for c in typeddict_type.field_constraints
835+
):
831836
typeddict_type.analysis_incomplete = True
832837
self.api.process_placeholder(
833838
None, "TypedDict item", info, force_progress=typeddict_type != info.typeddict_type

mypy/types.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2924,6 +2924,15 @@ def slice(
29242924
return TupleType(slice_items, fallback, self.line, self.column, self.implicit)
29252925

29262926

2927+
class TypedDictFieldConstraint(NamedTuple):
2928+
field_name: str
2929+
field_type: Type
2930+
is_readonly: bool
2931+
failure_message: str
2932+
failure_note: str | None
2933+
ctx: mypy.nodes.Context
2934+
2935+
29272936
class TypedDictItem(NamedTuple):
29282937
"""Type, mutability and requiredness of an item in a TypedDict.
29292938
@@ -2973,6 +2982,7 @@ class TypedDictType(ProperType):
29732982
"extra_items_from",
29742983
"to_be_mutated",
29752984
"analysis_incomplete",
2985+
"field_constraints",
29762986
)
29772987

29782988
items: dict[str, Type] # item_name -> item_type
@@ -2983,6 +2993,9 @@ class TypedDictType(ProperType):
29832993
extra_items_from: list[ProperType] # only used during semantic analysis
29842994
to_be_mutated: bool # only used in a plugin for `.update`, `|=`, etc
29852995
analysis_incomplete: bool # a placeholder type prevented complete analysis checking
2996+
field_constraints: list[
2997+
TypedDictFieldConstraint
2998+
] # delayed constraints to validate later in checking
29862999

29873000
def __init__(
29883001
self,
@@ -3005,6 +3018,7 @@ def __init__(
30053018
self.extra_items_from = []
30063019
self.to_be_mutated = False
30073020
self.analysis_incomplete = False
3021+
self.field_constraints = []
30083022

30093023
def accept(self, visitor: TypeVisitor[T]) -> T:
30103024
return visitor.visit_typeddict_type(self)

0 commit comments

Comments
 (0)