Skip to content

Commit

Permalink
Address review
Browse files Browse the repository at this point in the history
  • Loading branch information
sobolevn committed Aug 14, 2024
1 parent c8411c6 commit 9ff8ded
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 11 deletions.
7 changes: 4 additions & 3 deletions mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,9 +986,10 @@ def check_typeddict_call_with_kwargs(
always_present_keys: set[str],
) -> Type:
actual_keys = kwargs.keys()
assigned_readonly_keys = actual_keys & callee.readonly_keys
if assigned_readonly_keys:
self.msg.readonly_keys_mutated(assigned_readonly_keys, context=context)
if callee.to_be_mutated:
assigned_readonly_keys = actual_keys & callee.readonly_keys
if assigned_readonly_keys:
self.msg.readonly_keys_mutated(assigned_readonly_keys, context=context)
if not (
callee.required_keys <= always_present_keys and actual_keys <= callee.items.keys()
):
Expand Down
21 changes: 18 additions & 3 deletions mypy/plugins/default.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from functools import partial
from typing import Callable
from typing import Callable, Final

import mypy.errorcodes as codes
from mypy import message_registry
Expand Down Expand Up @@ -419,13 +419,19 @@ def typed_dict_delitem_callback(ctx: MethodContext) -> Type:
return AnyType(TypeOfAny.from_error)

for key in keys:
if key in ctx.type.required_keys:
if key in ctx.type.required_keys or key in ctx.type.readonly_keys:
ctx.api.msg.typeddict_key_cannot_be_deleted(ctx.type, key, ctx.context)
elif key not in ctx.type.items:
ctx.api.msg.typeddict_key_not_found(ctx.type, key, ctx.context)
return ctx.default_return_type


_TP_DICT_MUTATING_METHODS: Final = frozenset({
"update of TypedDict",
"__ior__ of TypedDict",
})


def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
"""Try to infer a better signature type for methods that update `TypedDict`.
Expand All @@ -440,10 +446,19 @@ def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
arg_type = arg_type.as_anonymous()
arg_type = arg_type.copy_modified(required_keys=set())
if ctx.args and ctx.args[0]:
with ctx.api.msg.filter_errors():
if signature.name in _TP_DICT_MUTATING_METHODS:
# If we want to mutate this object in place, we need to set this flag,
# it will trigger an extra check in TypedDict's checker.
arg_type.to_be_mutated = True
with ctx.api.msg.filter_errors(
filter_errors=lambda name, info: info.code != codes.TYPEDDICT_READONLY_MUTATED,
save_filtered_errors=True,
):
inferred = get_proper_type(
ctx.api.get_expression_type(ctx.args[0][0], type_context=arg_type)
)
if arg_type.to_be_mutated:
arg_type.to_be_mutated = False # Done!
possible_tds = []
if isinstance(inferred, TypedDictType):
possible_tds = [inferred]
Expand Down
12 changes: 11 additions & 1 deletion mypy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2568,13 +2568,22 @@ class TypedDictType(ProperType):
TODO: The fallback structure is perhaps overly complicated.
"""

__slots__ = ("items", "required_keys", "readonly_keys", "fallback", "extra_items_from")
__slots__ = (
"items",
"required_keys",
"readonly_keys",
"fallback",
"extra_items_from",
"to_be_mutated",
)

items: dict[str, Type] # item_name -> item_type
required_keys: set[str]
readonly_keys: set[str]
fallback: Instance

extra_items_from: list[ProperType] # only used during semantic analysis
to_be_mutated: bool # only used in a plugin for `.update`, `|=`, etc

def __init__(
self,
Expand All @@ -2593,6 +2602,7 @@ def __init__(
self.can_be_true = len(self.items) > 0
self.can_be_false = len(self.required_keys) == 0
self.extra_items_from = []
self.to_be_mutated = False

def accept(self, visitor: TypeVisitor[T]) -> T:
return visitor.visit_typeddict_type(self)
Expand Down
98 changes: 94 additions & 4 deletions test-data/unit/check-typeddict.test
Original file line number Diff line number Diff line change
Expand Up @@ -3685,14 +3685,35 @@ x["other"] = "a" # E: ReadOnly TypedDict key "other" TypedDict is mutated [typ
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict.pyi]

[case testTypedDictReadOnlyDel]
[case testTypedDictReadOnlyCreation]
from typing import ReadOnly, TypedDict

class TD(TypedDict):
x: ReadOnly[int]
y: int

# Ok:
x = TD({"x": 1, "y": 2})
y = TD(x=1, y=2)
z: TD = {"x": 1, "y": 2}

# Error:
x2 = TD({"x": "a", "y": 2}) # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
y2 = TD(x="a", y=2) # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
z2: TD = {"x": "a", "y": 2} # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict.pyi]

[case testTypedDictReadOnlyDel]
from typing import ReadOnly, TypedDict, NotRequired

class TP(TypedDict):
key: ReadOnly[str]
required_key: ReadOnly[str]
optional_key: ReadOnly[NotRequired[str]]

x: TP
del x["key"] # E: Key "key" of TypedDict "TP" cannot be deleted
del x["required_key"] # E: Key "required_key" of TypedDict "TP" cannot be deleted
del x["optional_key"] # E: Key "optional_key" of TypedDict "TP" cannot be deleted
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict.pyi]

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

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

[case testTypedDictReadOnlyMutateStatements]
[case testTypedDictFromTypingExtensionsReadOnlyMutateMethods]
from typing_extensions import ReadOnly, TypedDict

class TP(TypedDict):
key: ReadOnly[str]

x: TP
x.update({"key": "abc"}) # E: ReadOnly TypedDict key "key" TypedDict is mutated
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict.pyi]

[case testTypedDictFromMypyExtensionsReadOnlyMutateMethods]
from mypy_extensions import TypedDict
from typing_extensions import ReadOnly

class TP(TypedDict):
key: ReadOnly[str]

x: TP
x.update({"key": "abc"}) # E: ReadOnly TypedDict key "key" TypedDict is mutated
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict.pyi]

[case testTypedDictReadOnlyMutate__ior__Statements]
from typing_extensions import ReadOnly, TypedDict

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

[case testTypedDictReadOnlyMutate__or__Statements]
from typing_extensions import ReadOnly, TypedDict

class TP(TypedDict):
key: ReadOnly[str]
other: ReadOnly[int]
mutable: bool

x: TP
# These are new objects, not mutation:
x = x | {"mutable": True}
x = x | {"key": "a"}
x = x | {"key": "a", "other": 1, "mutable": True}
y1 = x | {"mutable": True}
y2 = x | {"key": "a"}
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict-iror.pyi]

[case testTypedDictReadOnlyMutateWithOtherDicts]
from typing import ReadOnly, TypedDict, Dict

class TP(TypedDict):
key: ReadOnly[str]
mutable: bool

class Mutable(TypedDict):
mutable: bool

class Regular(TypedDict):
key: str

m: Mutable
r: Regular
d: Dict[str, object]

# Creating new objects is ok:
tp: TP = {**r, **m}
tp1: TP = {**tp, **m}
tp2: TP = {**r, **m}
tp3: TP = {**tp, **r}
tp4: TP = {**tp, **d} # E: Unsupported type "Dict[str, object]" for ** expansion in TypedDict
[builtins fixtures/dict.pyi]
[typing fixtures/typing-typeddict.pyi]

[case testTypedDictGenericReadOnly]
from typing import ReadOnly, TypedDict, TypeVar, Generic

Expand Down

0 comments on commit 9ff8ded

Please sign in to comment.