Skip to content

Commit 9ff8ded

Browse files
committed
Address review
1 parent c8411c6 commit 9ff8ded

File tree

4 files changed

+127
-11
lines changed

4 files changed

+127
-11
lines changed

mypy/checkexpr.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -986,9 +986,10 @@ def check_typeddict_call_with_kwargs(
986986
always_present_keys: set[str],
987987
) -> Type:
988988
actual_keys = kwargs.keys()
989-
assigned_readonly_keys = actual_keys & callee.readonly_keys
990-
if assigned_readonly_keys:
991-
self.msg.readonly_keys_mutated(assigned_readonly_keys, context=context)
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)
992993
if not (
993994
callee.required_keys <= always_present_keys and actual_keys <= callee.items.keys()
994995
):

mypy/plugins/default.py

Lines changed: 18 additions & 3 deletions
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
@@ -419,13 +419,19 @@ def typed_dict_delitem_callback(ctx: MethodContext) -> Type:
419419
return AnyType(TypeOfAny.from_error)
420420

421421
for key in keys:
422-
if key in ctx.type.required_keys:
422+
if key in ctx.type.required_keys or key in ctx.type.readonly_keys:
423423
ctx.api.msg.typeddict_key_cannot_be_deleted(ctx.type, key, ctx.context)
424424
elif key not in ctx.type.items:
425425
ctx.api.msg.typeddict_key_not_found(ctx.type, key, ctx.context)
426426
return ctx.default_return_type
427427

428428

429+
_TP_DICT_MUTATING_METHODS: Final = frozenset({
430+
"update of TypedDict",
431+
"__ior__ of TypedDict",
432+
})
433+
434+
429435
def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
430436
"""Try to infer a better signature type for methods that update `TypedDict`.
431437
@@ -440,10 +446,19 @@ def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
440446
arg_type = arg_type.as_anonymous()
441447
arg_type = arg_type.copy_modified(required_keys=set())
442448
if ctx.args and ctx.args[0]:
443-
with ctx.api.msg.filter_errors():
449+
if signature.name in _TP_DICT_MUTATING_METHODS:
450+
# If we want to mutate this object in place, we need to set this flag,
451+
# it will trigger an extra check in TypedDict's checker.
452+
arg_type.to_be_mutated = True
453+
with ctx.api.msg.filter_errors(
454+
filter_errors=lambda name, info: info.code != codes.TYPEDDICT_READONLY_MUTATED,
455+
save_filtered_errors=True,
456+
):
444457
inferred = get_proper_type(
445458
ctx.api.get_expression_type(ctx.args[0][0], type_context=arg_type)
446459
)
460+
if arg_type.to_be_mutated:
461+
arg_type.to_be_mutated = False # Done!
447462
possible_tds = []
448463
if isinstance(inferred, TypedDictType):
449464
possible_tds = [inferred]

mypy/types.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2568,13 +2568,22 @@ class TypedDictType(ProperType):
25682568
TODO: The fallback structure is perhaps overly complicated.
25692569
"""
25702570

2571-
__slots__ = ("items", "required_keys", "readonly_keys", "fallback", "extra_items_from")
2571+
__slots__ = (
2572+
"items",
2573+
"required_keys",
2574+
"readonly_keys",
2575+
"fallback",
2576+
"extra_items_from",
2577+
"to_be_mutated",
2578+
)
25722579

25732580
items: dict[str, Type] # item_name -> item_type
25742581
required_keys: set[str]
25752582
readonly_keys: set[str]
25762583
fallback: Instance
2584+
25772585
extra_items_from: list[ProperType] # only used during semantic analysis
2586+
to_be_mutated: bool # only used in a plugin for `.update`, `|=`, etc
25782587

25792588
def __init__(
25802589
self,
@@ -2593,6 +2602,7 @@ def __init__(
25932602
self.can_be_true = len(self.items) > 0
25942603
self.can_be_false = len(self.required_keys) == 0
25952604
self.extra_items_from = []
2605+
self.to_be_mutated = False
25962606

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

test-data/unit/check-typeddict.test

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3685,14 +3685,35 @@ x["other"] = "a" # E: ReadOnly TypedDict key "other" TypedDict is mutated [typ
36853685
[builtins fixtures/dict.pyi]
36863686
[typing fixtures/typing-typeddict.pyi]
36873687

3688-
[case testTypedDictReadOnlyDel]
3688+
[case testTypedDictReadOnlyCreation]
36893689
from typing import ReadOnly, TypedDict
36903690

3691+
class TD(TypedDict):
3692+
x: ReadOnly[int]
3693+
y: int
3694+
3695+
# Ok:
3696+
x = TD({"x": 1, "y": 2})
3697+
y = TD(x=1, y=2)
3698+
z: TD = {"x": 1, "y": 2}
3699+
3700+
# Error:
3701+
x2 = TD({"x": "a", "y": 2}) # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
3702+
y2 = TD(x="a", y=2) # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
3703+
z2: TD = {"x": "a", "y": 2} # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
3704+
[builtins fixtures/dict.pyi]
3705+
[typing fixtures/typing-typeddict.pyi]
3706+
3707+
[case testTypedDictReadOnlyDel]
3708+
from typing import ReadOnly, TypedDict, NotRequired
3709+
36913710
class TP(TypedDict):
3692-
key: ReadOnly[str]
3711+
required_key: ReadOnly[str]
3712+
optional_key: ReadOnly[NotRequired[str]]
36933713

36943714
x: TP
3695-
del x["key"] # E: Key "key" of TypedDict "TP" cannot be deleted
3715+
del x["required_key"] # E: Key "required_key" of TypedDict "TP" cannot be deleted
3716+
del x["optional_key"] # E: Key "optional_key" of TypedDict "TP" cannot be deleted
36963717
[builtins fixtures/dict.pyi]
36973718
[typing fixtures/typing-typeddict.pyi]
36983719

@@ -3710,10 +3731,35 @@ reveal_type(x.pop("key")) # E: Key "key" of TypedDict "TP" cannot be deleted \
37103731

37113732
x.update({"key": "abc", "other": 1, "mutable": True}) # E: ReadOnly TypedDict keys ("key", "other") TypedDict are mutated
37123733
x.setdefault("key", "abc") # E: ReadOnly TypedDict key "key" TypedDict is mutated
3734+
x.setdefault("other", 1) # E: ReadOnly TypedDict key "other" TypedDict is mutated
3735+
x.setdefault("mutable", False) # ok
37133736
[builtins fixtures/dict.pyi]
37143737
[typing fixtures/typing-typeddict.pyi]
37153738

3716-
[case testTypedDictReadOnlyMutateStatements]
3739+
[case testTypedDictFromTypingExtensionsReadOnlyMutateMethods]
3740+
from typing_extensions import ReadOnly, TypedDict
3741+
3742+
class TP(TypedDict):
3743+
key: ReadOnly[str]
3744+
3745+
x: TP
3746+
x.update({"key": "abc"}) # E: ReadOnly TypedDict key "key" TypedDict is mutated
3747+
[builtins fixtures/dict.pyi]
3748+
[typing fixtures/typing-typeddict.pyi]
3749+
3750+
[case testTypedDictFromMypyExtensionsReadOnlyMutateMethods]
3751+
from mypy_extensions import TypedDict
3752+
from typing_extensions import ReadOnly
3753+
3754+
class TP(TypedDict):
3755+
key: ReadOnly[str]
3756+
3757+
x: TP
3758+
x.update({"key": "abc"}) # E: ReadOnly TypedDict key "key" TypedDict is mutated
3759+
[builtins fixtures/dict.pyi]
3760+
[typing fixtures/typing-typeddict.pyi]
3761+
3762+
[case testTypedDictReadOnlyMutate__ior__Statements]
37173763
from typing_extensions import ReadOnly, TypedDict
37183764

37193765
class TP(TypedDict):
@@ -3728,6 +3774,50 @@ x |= {"key": "a", "other": 1, "mutable": True} # E: ReadOnly TypedDict keys ("k
37283774
[builtins fixtures/dict.pyi]
37293775
[typing fixtures/typing-typeddict-iror.pyi]
37303776

3777+
[case testTypedDictReadOnlyMutate__or__Statements]
3778+
from typing_extensions import ReadOnly, TypedDict
3779+
3780+
class TP(TypedDict):
3781+
key: ReadOnly[str]
3782+
other: ReadOnly[int]
3783+
mutable: bool
3784+
3785+
x: TP
3786+
# These are new objects, not mutation:
3787+
x = x | {"mutable": True}
3788+
x = x | {"key": "a"}
3789+
x = x | {"key": "a", "other": 1, "mutable": True}
3790+
y1 = x | {"mutable": True}
3791+
y2 = x | {"key": "a"}
3792+
[builtins fixtures/dict.pyi]
3793+
[typing fixtures/typing-typeddict-iror.pyi]
3794+
3795+
[case testTypedDictReadOnlyMutateWithOtherDicts]
3796+
from typing import ReadOnly, TypedDict, Dict
3797+
3798+
class TP(TypedDict):
3799+
key: ReadOnly[str]
3800+
mutable: bool
3801+
3802+
class Mutable(TypedDict):
3803+
mutable: bool
3804+
3805+
class Regular(TypedDict):
3806+
key: str
3807+
3808+
m: Mutable
3809+
r: Regular
3810+
d: Dict[str, object]
3811+
3812+
# Creating new objects is ok:
3813+
tp: TP = {**r, **m}
3814+
tp1: TP = {**tp, **m}
3815+
tp2: TP = {**r, **m}
3816+
tp3: TP = {**tp, **r}
3817+
tp4: TP = {**tp, **d} # E: Unsupported type "Dict[str, object]" for ** expansion in TypedDict
3818+
[builtins fixtures/dict.pyi]
3819+
[typing fixtures/typing-typeddict.pyi]
3820+
37313821
[case testTypedDictGenericReadOnly]
37323822
from typing import ReadOnly, TypedDict, TypeVar, Generic
37333823

0 commit comments

Comments
 (0)