Skip to content

Commit 6726d77

Browse files
Add ReadOnly support for TypedDicts (#17644)
Refs #17264 I will add docs in a separate PR. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1a2c8e2 commit 6726d77

25 files changed

+652
-74
lines changed

docs/source/error_code_list.rst

+24
Original file line numberDiff line numberDiff line change
@@ -1217,6 +1217,30 @@ If the code being checked is not syntactically valid, mypy issues a
12171217
syntax error. Most, but not all, syntax errors are *blocking errors*:
12181218
they can't be ignored with a ``# type: ignore`` comment.
12191219

1220+
.. _code-typeddict-readonly-mutated:
1221+
1222+
ReadOnly key of a TypedDict is mutated [typeddict-readonly-mutated]
1223+
-------------------------------------------------------------------
1224+
1225+
Consider this example:
1226+
1227+
.. code-block:: python
1228+
1229+
from datetime import datetime
1230+
from typing import TypedDict
1231+
from typing_extensions import ReadOnly
1232+
1233+
class User(TypedDict):
1234+
username: ReadOnly[str]
1235+
last_active: datetime
1236+
1237+
user: User = {'username': 'foobar', 'last_active': datetime.now()}
1238+
user['last_active'] = datetime.now() # ok
1239+
user['username'] = 'other' # error: ReadOnly TypedDict key "key" TypedDict is mutated [typeddict-readonly-mutated]
1240+
1241+
`PEP 705 <https://peps.python.org/pep-0705>`_ specifies
1242+
how ``ReadOnly`` special form works for ``TypedDict`` objects.
1243+
12201244
.. _code-misc:
12211245

12221246
Miscellaneous checks [misc]

mypy/checkexpr.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,10 @@ def check_typeddict_call_with_kwargs(
986986
always_present_keys: set[str],
987987
) -> Type:
988988
actual_keys = kwargs.keys()
989+
if callee.to_be_mutated:
990+
assigned_readonly_keys = actual_keys & callee.readonly_keys
991+
if assigned_readonly_keys:
992+
self.msg.readonly_keys_mutated(assigned_readonly_keys, context=context)
989993
if not (
990994
callee.required_keys <= always_present_keys and actual_keys <= callee.items.keys()
991995
):
@@ -4349,7 +4353,7 @@ def visit_index_with_type(
43494353
else:
43504354
return self.nonliteral_tuple_index_helper(left_type, index)
43514355
elif isinstance(left_type, TypedDictType):
4352-
return self.visit_typeddict_index_expr(left_type, e.index)
4356+
return self.visit_typeddict_index_expr(left_type, e.index)[0]
43534357
elif isinstance(left_type, FunctionLike) and left_type.is_type_obj():
43544358
if left_type.type_object().is_enum:
43554359
return self.visit_enum_index_expr(left_type.type_object(), e.index, e)
@@ -4530,7 +4534,7 @@ def union_tuple_fallback_item(self, left_type: TupleType) -> Type:
45304534

45314535
def visit_typeddict_index_expr(
45324536
self, td_type: TypedDictType, index: Expression, setitem: bool = False
4533-
) -> Type:
4537+
) -> tuple[Type, set[str]]:
45344538
if isinstance(index, StrExpr):
45354539
key_names = [index.value]
45364540
else:
@@ -4553,17 +4557,17 @@ def visit_typeddict_index_expr(
45534557
key_names.append(key_type.value)
45544558
else:
45554559
self.msg.typeddict_key_must_be_string_literal(td_type, index)
4556-
return AnyType(TypeOfAny.from_error)
4560+
return AnyType(TypeOfAny.from_error), set()
45574561

45584562
value_types = []
45594563
for key_name in key_names:
45604564
value_type = td_type.items.get(key_name)
45614565
if value_type is None:
45624566
self.msg.typeddict_key_not_found(td_type, key_name, index, setitem)
4563-
return AnyType(TypeOfAny.from_error)
4567+
return AnyType(TypeOfAny.from_error), set()
45644568
else:
45654569
value_types.append(value_type)
4566-
return make_simplified_union(value_types)
4570+
return make_simplified_union(value_types), set(key_names)
45674571

45684572
def visit_enum_index_expr(
45694573
self, enum_type: TypeInfo, index: Expression, context: Context

mypy/checkmember.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -1185,9 +1185,12 @@ def analyze_typeddict_access(
11851185
if isinstance(mx.context, IndexExpr):
11861186
# Since we can get this during `a['key'] = ...`
11871187
# it is safe to assume that the context is `IndexExpr`.
1188-
item_type = mx.chk.expr_checker.visit_typeddict_index_expr(
1188+
item_type, key_names = mx.chk.expr_checker.visit_typeddict_index_expr(
11891189
typ, mx.context.index, setitem=True
11901190
)
1191+
assigned_readonly_keys = typ.readonly_keys & key_names
1192+
if assigned_readonly_keys:
1193+
mx.msg.readonly_keys_mutated(assigned_readonly_keys, context=mx.context)
11911194
else:
11921195
# It can also be `a.__setitem__(...)` direct call.
11931196
# In this case `item_type` can be `Any`,

mypy/checkpattern.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ def get_mapping_item_type(
498498
with self.msg.filter_errors() as local_errors:
499499
result: Type | None = self.chk.expr_checker.visit_typeddict_index_expr(
500500
mapping_type, key
501-
)
501+
)[0]
502502
has_local_errors = local_errors.has_new_errors()
503503
# If we can't determine the type statically fall back to treating it as a normal
504504
# mapping

mypy/copytype.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ def visit_tuple_type(self, t: TupleType) -> ProperType:
107107
return self.copy_common(t, TupleType(t.items, t.partial_fallback, implicit=t.implicit))
108108

109109
def visit_typeddict_type(self, t: TypedDictType) -> ProperType:
110-
return self.copy_common(t, TypedDictType(t.items, t.required_keys, t.fallback))
110+
return self.copy_common(
111+
t, TypedDictType(t.items, t.required_keys, t.readonly_keys, t.fallback)
112+
)
111113

112114
def visit_literal_type(self, t: LiteralType) -> ProperType:
113115
return self.copy_common(t, LiteralType(value=t.value, fallback=t.fallback))

mypy/errorcodes.py

+3
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ def __hash__(self) -> int:
185185
ANNOTATION_UNCHECKED = ErrorCode(
186186
"annotation-unchecked", "Notify about type annotations in unchecked functions", "General"
187187
)
188+
TYPEDDICT_READONLY_MUTATED = ErrorCode(
189+
"typeddict-readonly-mutated", "TypedDict's ReadOnly key is mutated", "General"
190+
)
188191
POSSIBLY_UNDEFINED: Final[ErrorCode] = ErrorCode(
189192
"possibly-undefined",
190193
"Warn about variables that are defined only in some execution paths",

mypy/exprtotype.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ def expr_to_unanalyzed_type(
244244
value, options, allow_new_syntax, expr
245245
)
246246
result = TypedDictType(
247-
items, set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column
247+
items, set(), set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column
248248
)
249249
result.extra_items_from = extra_items_from
250250
return result

mypy/fastparse.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2130,7 +2130,7 @@ def visit_Dict(self, n: ast3.Dict) -> Type:
21302130
continue
21312131
return self.invalid_type(n)
21322132
items[item_name.value] = self.visit(value)
2133-
result = TypedDictType(items, set(), _dummy_fallback, n.lineno, n.col_offset)
2133+
result = TypedDictType(items, set(), set(), _dummy_fallback, n.lineno, n.col_offset)
21342134
result.extra_items_from = extra_items_from
21352135
return result
21362136

mypy/join.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -631,10 +631,13 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType:
631631
)
632632
}
633633
fallback = self.s.create_anonymous_fallback()
634+
all_keys = set(items.keys())
634635
# We need to filter by items.keys() since some required keys present in both t and
635636
# self.s might be missing from the join if the types are incompatible.
636-
required_keys = set(items.keys()) & t.required_keys & self.s.required_keys
637-
return TypedDictType(items, required_keys, fallback)
637+
required_keys = all_keys & t.required_keys & self.s.required_keys
638+
# If one type has a key as readonly, we mark it as readonly for both:
639+
readonly_keys = (t.readonly_keys | t.readonly_keys) & all_keys
640+
return TypedDictType(items, required_keys, readonly_keys, fallback)
638641
elif isinstance(self.s, Instance):
639642
return join_types(self.s, t.fallback)
640643
else:

mypy/meet.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -1017,7 +1017,8 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType:
10171017
items = dict(item_list)
10181018
fallback = self.s.create_anonymous_fallback()
10191019
required_keys = t.required_keys | self.s.required_keys
1020-
return TypedDictType(items, required_keys, fallback)
1020+
readonly_keys = t.readonly_keys | self.s.readonly_keys
1021+
return TypedDictType(items, required_keys, readonly_keys, fallback)
10211022
elif isinstance(self.s, Instance) and is_subtype(t, self.s):
10221023
return t
10231024
else:
@@ -1139,6 +1140,9 @@ def typed_dict_mapping_overlap(
11391140
- TypedDict(x=str, y=str, total=False) doesn't overlap with Dict[str, int]
11401141
- TypedDict(x=int, y=str, total=False) overlaps with Dict[str, str]
11411142
1143+
* A TypedDict with at least one ReadOnly[] key does not overlap
1144+
with Dict or MutableMapping, because they assume mutable data.
1145+
11421146
As usual empty, dictionaries lie in a gray area. In general, List[str] and List[str]
11431147
are considered non-overlapping despite empty list belongs to both. However, List[int]
11441148
and List[Never] are considered overlapping.
@@ -1159,6 +1163,12 @@ def typed_dict_mapping_overlap(
11591163
assert isinstance(right, TypedDictType)
11601164
typed, other = right, left
11611165

1166+
mutable_mapping = next(
1167+
(base for base in other.type.mro if base.fullname == "typing.MutableMapping"), None
1168+
)
1169+
if mutable_mapping is not None and typed.readonly_keys:
1170+
return False
1171+
11621172
mapping = next(base for base in other.type.mro if base.fullname == "typing.Mapping")
11631173
other = map_instance_to_supertype(other, mapping)
11641174
key_type, value_type = get_proper_types(other.args)

mypy/messages.py

+17-3
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,17 @@ def invalid_index_type(
926926
code=code,
927927
)
928928

929+
def readonly_keys_mutated(self, keys: set[str], context: Context) -> None:
930+
if len(keys) == 1:
931+
suffix = "is"
932+
else:
933+
suffix = "are"
934+
self.fail(
935+
"ReadOnly {} TypedDict {} mutated".format(format_key_list(sorted(keys)), suffix),
936+
code=codes.TYPEDDICT_READONLY_MUTATED,
937+
context=context,
938+
)
939+
929940
def too_few_arguments(
930941
self, callee: CallableType, context: Context, argument_names: Sequence[str | None] | None
931942
) -> None:
@@ -2613,10 +2624,13 @@ def format_literal_value(typ: LiteralType) -> str:
26132624
return format(typ.fallback)
26142625
items = []
26152626
for item_name, item_type in typ.items.items():
2616-
modifier = "" if item_name in typ.required_keys else "?"
2627+
modifier = ""
2628+
if item_name not in typ.required_keys:
2629+
modifier += "?"
2630+
if item_name in typ.readonly_keys:
2631+
modifier += "="
26172632
items.append(f"{item_name!r}{modifier}: {format(item_type)}")
2618-
s = f"TypedDict({{{', '.join(items)}}})"
2619-
return s
2633+
return f"TypedDict({{{', '.join(items)}}})"
26202634
elif isinstance(typ, LiteralType):
26212635
return f"Literal[{format_literal_value(typ)}]"
26222636
elif isinstance(typ, UnionType):

mypy/plugins/default.py

+19-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from functools import partial
4-
from typing import Callable
4+
from typing import Callable, Final
55

66
import mypy.errorcodes as codes
77
from mypy import message_registry
@@ -372,6 +372,10 @@ def typed_dict_setdefault_callback(ctx: MethodContext) -> Type:
372372
)
373373
return AnyType(TypeOfAny.from_error)
374374

375+
assigned_readonly_keys = ctx.type.readonly_keys & set(keys)
376+
if assigned_readonly_keys:
377+
ctx.api.msg.readonly_keys_mutated(assigned_readonly_keys, context=ctx.context)
378+
375379
default_type = ctx.arg_types[1][0]
376380

377381
value_types = []
@@ -415,13 +419,16 @@ def typed_dict_delitem_callback(ctx: MethodContext) -> Type:
415419
return AnyType(TypeOfAny.from_error)
416420

417421
for key in keys:
418-
if key in ctx.type.required_keys:
422+
if key in ctx.type.required_keys or key in ctx.type.readonly_keys:
419423
ctx.api.msg.typeddict_key_cannot_be_deleted(ctx.type, key, ctx.context)
420424
elif key not in ctx.type.items:
421425
ctx.api.msg.typeddict_key_not_found(ctx.type, key, ctx.context)
422426
return ctx.default_return_type
423427

424428

429+
_TP_DICT_MUTATING_METHODS: Final = frozenset({"update of TypedDict", "__ior__ of TypedDict"})
430+
431+
425432
def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
426433
"""Try to infer a better signature type for methods that update `TypedDict`.
427434
@@ -436,10 +443,19 @@ def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
436443
arg_type = arg_type.as_anonymous()
437444
arg_type = arg_type.copy_modified(required_keys=set())
438445
if ctx.args and ctx.args[0]:
439-
with ctx.api.msg.filter_errors():
446+
if signature.name in _TP_DICT_MUTATING_METHODS:
447+
# If we want to mutate this object in place, we need to set this flag,
448+
# it will trigger an extra check in TypedDict's checker.
449+
arg_type.to_be_mutated = True
450+
with ctx.api.msg.filter_errors(
451+
filter_errors=lambda name, info: info.code != codes.TYPEDDICT_READONLY_MUTATED,
452+
save_filtered_errors=True,
453+
):
440454
inferred = get_proper_type(
441455
ctx.api.get_expression_type(ctx.args[0][0], type_context=arg_type)
442456
)
457+
if arg_type.to_be_mutated:
458+
arg_type.to_be_mutated = False # Done!
443459
possible_tds = []
444460
if isinstance(inferred, TypedDictType):
445461
possible_tds = [inferred]

mypy/plugins/proper_plugin.py

+1
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def is_special_target(right: ProperType) -> bool:
106106
"mypy.types.ErasedType",
107107
"mypy.types.DeletedType",
108108
"mypy.types.RequiredType",
109+
"mypy.types.ReadOnlyType",
109110
):
110111
# Special case: these are not valid targets for a type alias and thus safe.
111112
# TODO: introduce a SyntheticType base to simplify this?

mypy/semanal.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -7169,7 +7169,7 @@ def type_analyzer(
71697169
allow_tuple_literal: bool = False,
71707170
allow_unbound_tvars: bool = False,
71717171
allow_placeholder: bool = False,
7172-
allow_required: bool = False,
7172+
allow_typed_dict_special_forms: bool = False,
71737173
allow_param_spec_literals: bool = False,
71747174
allow_unpack: bool = False,
71757175
report_invalid_types: bool = True,
@@ -7188,7 +7188,7 @@ def type_analyzer(
71887188
allow_tuple_literal=allow_tuple_literal,
71897189
report_invalid_types=report_invalid_types,
71907190
allow_placeholder=allow_placeholder,
7191-
allow_required=allow_required,
7191+
allow_typed_dict_special_forms=allow_typed_dict_special_forms,
71927192
allow_param_spec_literals=allow_param_spec_literals,
71937193
allow_unpack=allow_unpack,
71947194
prohibit_self_type=prohibit_self_type,
@@ -7211,7 +7211,7 @@ def anal_type(
72117211
allow_tuple_literal: bool = False,
72127212
allow_unbound_tvars: bool = False,
72137213
allow_placeholder: bool = False,
7214-
allow_required: bool = False,
7214+
allow_typed_dict_special_forms: bool = False,
72157215
allow_param_spec_literals: bool = False,
72167216
allow_unpack: bool = False,
72177217
report_invalid_types: bool = True,
@@ -7246,7 +7246,7 @@ def anal_type(
72467246
allow_unbound_tvars=allow_unbound_tvars,
72477247
allow_tuple_literal=allow_tuple_literal,
72487248
allow_placeholder=allow_placeholder,
7249-
allow_required=allow_required,
7249+
allow_typed_dict_special_forms=allow_typed_dict_special_forms,
72507250
allow_param_spec_literals=allow_param_spec_literals,
72517251
allow_unpack=allow_unpack,
72527252
report_invalid_types=report_invalid_types,

mypy/semanal_shared.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def anal_type(
181181
tvar_scope: TypeVarLikeScope | None = None,
182182
allow_tuple_literal: bool = False,
183183
allow_unbound_tvars: bool = False,
184-
allow_required: bool = False,
184+
allow_typed_dict_special_forms: bool = False,
185185
allow_placeholder: bool = False,
186186
report_invalid_types: bool = True,
187187
prohibit_self_type: str | None = None,

0 commit comments

Comments
 (0)