diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst index 2739894dd3ec..73171131bc8d 100644 --- a/docs/source/error_code_list.rst +++ b/docs/source/error_code_list.rst @@ -1217,6 +1217,30 @@ If the code being checked is not syntactically valid, mypy issues a syntax error. Most, but not all, syntax errors are *blocking errors*: they can't be ignored with a ``# type: ignore`` comment. +.. _code-typeddict-readonly-mutated: + +ReadOnly key of a TypedDict is mutated [typeddict-readonly-mutated] +------------------------------------------------------------------- + +Consider this example: + +.. code-block:: python + + from datetime import datetime + from typing import TypedDict + from typing_extensions import ReadOnly + + class User(TypedDict): + username: ReadOnly[str] + last_active: datetime + + user: User = {'username': 'foobar', 'last_active': datetime.now()} + user['last_active'] = datetime.now() # ok + user['username'] = 'other' # error: ReadOnly TypedDict key "key" TypedDict is mutated [typeddict-readonly-mutated] + +`PEP 705 `_ specifies +how ``ReadOnly`` special form works for ``TypedDict`` objects. + .. _code-misc: Miscellaneous checks [misc] diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 55c42335744d..98e6eb6a7fc3 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -986,6 +986,10 @@ def check_typeddict_call_with_kwargs( always_present_keys: set[str], ) -> Type: actual_keys = kwargs.keys() + 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() ): @@ -4349,7 +4353,7 @@ def visit_index_with_type( else: return self.nonliteral_tuple_index_helper(left_type, index) elif isinstance(left_type, TypedDictType): - return self.visit_typeddict_index_expr(left_type, e.index) + return self.visit_typeddict_index_expr(left_type, e.index)[0] elif isinstance(left_type, FunctionLike) and left_type.is_type_obj(): if left_type.type_object().is_enum: 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: def visit_typeddict_index_expr( self, td_type: TypedDictType, index: Expression, setitem: bool = False - ) -> Type: + ) -> tuple[Type, set[str]]: if isinstance(index, StrExpr): key_names = [index.value] else: @@ -4553,17 +4557,17 @@ def visit_typeddict_index_expr( key_names.append(key_type.value) else: self.msg.typeddict_key_must_be_string_literal(td_type, index) - return AnyType(TypeOfAny.from_error) + return AnyType(TypeOfAny.from_error), set() value_types = [] for key_name in key_names: value_type = td_type.items.get(key_name) if value_type is None: self.msg.typeddict_key_not_found(td_type, key_name, index, setitem) - return AnyType(TypeOfAny.from_error) + return AnyType(TypeOfAny.from_error), set() else: value_types.append(value_type) - return make_simplified_union(value_types) + return make_simplified_union(value_types), set(key_names) def visit_enum_index_expr( self, enum_type: TypeInfo, index: Expression, context: Context diff --git a/mypy/checkmember.py b/mypy/checkmember.py index 0f117f5475ed..8f99f96e2dd5 100644 --- a/mypy/checkmember.py +++ b/mypy/checkmember.py @@ -1185,9 +1185,12 @@ def analyze_typeddict_access( if isinstance(mx.context, IndexExpr): # Since we can get this during `a['key'] = ...` # it is safe to assume that the context is `IndexExpr`. - item_type = mx.chk.expr_checker.visit_typeddict_index_expr( + item_type, key_names = mx.chk.expr_checker.visit_typeddict_index_expr( typ, mx.context.index, setitem=True ) + assigned_readonly_keys = typ.readonly_keys & key_names + if assigned_readonly_keys: + mx.msg.readonly_keys_mutated(assigned_readonly_keys, context=mx.context) else: # It can also be `a.__setitem__(...)` direct call. # In this case `item_type` can be `Any`, diff --git a/mypy/checkpattern.py b/mypy/checkpattern.py index cb3577ce2f6e..fa23dfb5f453 100644 --- a/mypy/checkpattern.py +++ b/mypy/checkpattern.py @@ -498,7 +498,7 @@ def get_mapping_item_type( with self.msg.filter_errors() as local_errors: result: Type | None = self.chk.expr_checker.visit_typeddict_index_expr( mapping_type, key - ) + )[0] has_local_errors = local_errors.has_new_errors() # If we can't determine the type statically fall back to treating it as a normal # mapping diff --git a/mypy/copytype.py b/mypy/copytype.py index 465f06566f54..ecb1a89759b6 100644 --- a/mypy/copytype.py +++ b/mypy/copytype.py @@ -107,7 +107,9 @@ def visit_tuple_type(self, t: TupleType) -> ProperType: return self.copy_common(t, TupleType(t.items, t.partial_fallback, implicit=t.implicit)) def visit_typeddict_type(self, t: TypedDictType) -> ProperType: - return self.copy_common(t, TypedDictType(t.items, t.required_keys, t.fallback)) + return self.copy_common( + t, TypedDictType(t.items, t.required_keys, t.readonly_keys, t.fallback) + ) def visit_literal_type(self, t: LiteralType) -> ProperType: return self.copy_common(t, LiteralType(value=t.value, fallback=t.fallback)) diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py index ad061b161af1..a170b5d4d65a 100644 --- a/mypy/errorcodes.py +++ b/mypy/errorcodes.py @@ -185,6 +185,9 @@ def __hash__(self) -> int: ANNOTATION_UNCHECKED = ErrorCode( "annotation-unchecked", "Notify about type annotations in unchecked functions", "General" ) +TYPEDDICT_READONLY_MUTATED = ErrorCode( + "typeddict-readonly-mutated", "TypedDict's ReadOnly key is mutated", "General" +) POSSIBLY_UNDEFINED: Final[ErrorCode] = ErrorCode( "possibly-undefined", "Warn about variables that are defined only in some execution paths", diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index c7df851668be..506194a4b285 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -244,7 +244,7 @@ def expr_to_unanalyzed_type( value, options, allow_new_syntax, expr ) result = TypedDictType( - items, set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column + items, set(), set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column ) result.extra_items_from = extra_items_from return result diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 18858b0fa0b8..bbbe2184738c 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2130,7 +2130,7 @@ def visit_Dict(self, n: ast3.Dict) -> Type: continue return self.invalid_type(n) items[item_name.value] = self.visit(value) - result = TypedDictType(items, set(), _dummy_fallback, n.lineno, n.col_offset) + result = TypedDictType(items, set(), set(), _dummy_fallback, n.lineno, n.col_offset) result.extra_items_from = extra_items_from return result diff --git a/mypy/join.py b/mypy/join.py index 5284be7dd2a1..865dd073d081 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -631,10 +631,13 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: ) } fallback = self.s.create_anonymous_fallback() + all_keys = set(items.keys()) # We need to filter by items.keys() since some required keys present in both t and # self.s might be missing from the join if the types are incompatible. - required_keys = set(items.keys()) & t.required_keys & self.s.required_keys - return TypedDictType(items, required_keys, fallback) + required_keys = all_keys & t.required_keys & self.s.required_keys + # If one type has a key as readonly, we mark it as readonly for both: + readonly_keys = (t.readonly_keys | t.readonly_keys) & all_keys + return TypedDictType(items, required_keys, readonly_keys, fallback) elif isinstance(self.s, Instance): return join_types(self.s, t.fallback) else: diff --git a/mypy/meet.py b/mypy/meet.py index 91abf43c0877..9f5c2d72a8cb 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -1017,7 +1017,8 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType: items = dict(item_list) fallback = self.s.create_anonymous_fallback() required_keys = t.required_keys | self.s.required_keys - return TypedDictType(items, required_keys, fallback) + readonly_keys = t.readonly_keys | self.s.readonly_keys + return TypedDictType(items, required_keys, readonly_keys, fallback) elif isinstance(self.s, Instance) and is_subtype(t, self.s): return t else: @@ -1139,6 +1140,9 @@ def typed_dict_mapping_overlap( - TypedDict(x=str, y=str, total=False) doesn't overlap with Dict[str, int] - TypedDict(x=int, y=str, total=False) overlaps with Dict[str, str] + * A TypedDict with at least one ReadOnly[] key does not overlap + with Dict or MutableMapping, because they assume mutable data. + As usual empty, dictionaries lie in a gray area. In general, List[str] and List[str] are considered non-overlapping despite empty list belongs to both. However, List[int] and List[Never] are considered overlapping. @@ -1159,6 +1163,12 @@ def typed_dict_mapping_overlap( assert isinstance(right, TypedDictType) typed, other = right, left + mutable_mapping = next( + (base for base in other.type.mro if base.fullname == "typing.MutableMapping"), None + ) + if mutable_mapping is not None and typed.readonly_keys: + return False + mapping = next(base for base in other.type.mro if base.fullname == "typing.Mapping") other = map_instance_to_supertype(other, mapping) key_type, value_type = get_proper_types(other.args) diff --git a/mypy/messages.py b/mypy/messages.py index 6567d9d96d0b..adf150eab50a 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -926,6 +926,17 @@ def invalid_index_type( code=code, ) + def readonly_keys_mutated(self, keys: set[str], context: Context) -> None: + if len(keys) == 1: + suffix = "is" + else: + suffix = "are" + self.fail( + "ReadOnly {} TypedDict {} mutated".format(format_key_list(sorted(keys)), suffix), + code=codes.TYPEDDICT_READONLY_MUTATED, + context=context, + ) + def too_few_arguments( self, callee: CallableType, context: Context, argument_names: Sequence[str | None] | None ) -> None: @@ -2613,10 +2624,13 @@ def format_literal_value(typ: LiteralType) -> str: return format(typ.fallback) items = [] for item_name, item_type in typ.items.items(): - modifier = "" if item_name in typ.required_keys else "?" + modifier = "" + if item_name not in typ.required_keys: + modifier += "?" + if item_name in typ.readonly_keys: + modifier += "=" items.append(f"{item_name!r}{modifier}: {format(item_type)}") - s = f"TypedDict({{{', '.join(items)}}})" - return s + return f"TypedDict({{{', '.join(items)}}})" elif isinstance(typ, LiteralType): return f"Literal[{format_literal_value(typ)}]" elif isinstance(typ, UnionType): diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py index 5139b9b82289..73c5742614ee 100644 --- a/mypy/plugins/default.py +++ b/mypy/plugins/default.py @@ -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 @@ -372,6 +372,10 @@ def typed_dict_setdefault_callback(ctx: MethodContext) -> Type: ) return AnyType(TypeOfAny.from_error) + assigned_readonly_keys = ctx.type.readonly_keys & set(keys) + if assigned_readonly_keys: + ctx.api.msg.readonly_keys_mutated(assigned_readonly_keys, context=ctx.context) + default_type = ctx.arg_types[1][0] value_types = [] @@ -415,13 +419,16 @@ 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`. @@ -436,10 +443,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] diff --git a/mypy/plugins/proper_plugin.py b/mypy/plugins/proper_plugin.py index a1fd05272b65..f51685c80afa 100644 --- a/mypy/plugins/proper_plugin.py +++ b/mypy/plugins/proper_plugin.py @@ -106,6 +106,7 @@ def is_special_target(right: ProperType) -> bool: "mypy.types.ErasedType", "mypy.types.DeletedType", "mypy.types.RequiredType", + "mypy.types.ReadOnlyType", ): # Special case: these are not valid targets for a type alias and thus safe. # TODO: introduce a SyntheticType base to simplify this? diff --git a/mypy/semanal.py b/mypy/semanal.py index 0b654d6b145f..27abf2c1dc4c 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -7169,7 +7169,7 @@ def type_analyzer( allow_tuple_literal: bool = False, allow_unbound_tvars: bool = False, allow_placeholder: bool = False, - allow_required: bool = False, + allow_typed_dict_special_forms: bool = False, allow_param_spec_literals: bool = False, allow_unpack: bool = False, report_invalid_types: bool = True, @@ -7188,7 +7188,7 @@ def type_analyzer( allow_tuple_literal=allow_tuple_literal, report_invalid_types=report_invalid_types, allow_placeholder=allow_placeholder, - allow_required=allow_required, + allow_typed_dict_special_forms=allow_typed_dict_special_forms, allow_param_spec_literals=allow_param_spec_literals, allow_unpack=allow_unpack, prohibit_self_type=prohibit_self_type, @@ -7211,7 +7211,7 @@ def anal_type( allow_tuple_literal: bool = False, allow_unbound_tvars: bool = False, allow_placeholder: bool = False, - allow_required: bool = False, + allow_typed_dict_special_forms: bool = False, allow_param_spec_literals: bool = False, allow_unpack: bool = False, report_invalid_types: bool = True, @@ -7246,7 +7246,7 @@ def anal_type( allow_unbound_tvars=allow_unbound_tvars, allow_tuple_literal=allow_tuple_literal, allow_placeholder=allow_placeholder, - allow_required=allow_required, + allow_typed_dict_special_forms=allow_typed_dict_special_forms, allow_param_spec_literals=allow_param_spec_literals, allow_unpack=allow_unpack, report_invalid_types=report_invalid_types, diff --git a/mypy/semanal_shared.py b/mypy/semanal_shared.py index db19f074911f..cb0bdebab724 100644 --- a/mypy/semanal_shared.py +++ b/mypy/semanal_shared.py @@ -181,7 +181,7 @@ def anal_type( tvar_scope: TypeVarLikeScope | None = None, allow_tuple_literal: bool = False, allow_unbound_tvars: bool = False, - allow_required: bool = False, + allow_typed_dict_special_forms: bool = False, allow_placeholder: bool = False, report_invalid_types: bool = True, prohibit_self_type: str | None = None, diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 832530df55e5..d081898bf010 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -43,6 +43,7 @@ from mypy.types import ( TPDICT_NAMES, AnyType, + ReadOnlyType, RequiredType, Type, TypedDictType, @@ -102,13 +103,15 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N and defn.base_type_exprs[0].fullname in TPDICT_NAMES ): # Building a new TypedDict - fields, types, statements, required_keys = self.analyze_typeddict_classdef_fields(defn) + fields, types, statements, required_keys, readonly_keys = ( + self.analyze_typeddict_classdef_fields(defn) + ) if fields is None: return True, None # Defer if self.api.is_func_scope() and "@" not in defn.name: defn.name += "@" + str(defn.line) info = self.build_typeddict_typeinfo( - defn.name, fields, types, required_keys, defn.line, existing_info + defn.name, fields, types, required_keys, readonly_keys, defn.line, existing_info ) defn.analyzed = TypedDictExpr(info) defn.analyzed.line = defn.line @@ -154,10 +157,13 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N keys: list[str] = [] types = [] required_keys = set() + readonly_keys = set() # Iterate over bases in reverse order so that leftmost base class' keys take precedence for base in reversed(typeddict_bases): - self.add_keys_and_types_from_base(base, keys, types, required_keys, defn) - (new_keys, new_types, new_statements, new_required_keys) = ( + self.add_keys_and_types_from_base( + base, keys, types, required_keys, readonly_keys, defn + ) + (new_keys, new_types, new_statements, new_required_keys, new_readonly_keys) = ( self.analyze_typeddict_classdef_fields(defn, keys) ) if new_keys is None: @@ -165,8 +171,9 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N keys.extend(new_keys) types.extend(new_types) required_keys.update(new_required_keys) + readonly_keys.update(new_readonly_keys) info = self.build_typeddict_typeinfo( - defn.name, keys, types, required_keys, defn.line, existing_info + defn.name, keys, types, required_keys, readonly_keys, defn.line, existing_info ) defn.analyzed = TypedDictExpr(info) defn.analyzed.line = defn.line @@ -180,6 +187,7 @@ def add_keys_and_types_from_base( keys: list[str], types: list[Type], required_keys: set[str], + readonly_keys: set[str], ctx: Context, ) -> None: base_args: list[Type] = [] @@ -221,6 +229,7 @@ def add_keys_and_types_from_base( keys.extend(valid_items.keys()) types.extend(valid_items.values()) required_keys.update(base_typed_dict.required_keys) + readonly_keys.update(base_typed_dict.readonly_keys) def analyze_base_args(self, base: IndexExpr, ctx: Context) -> list[Type] | None: """Analyze arguments of base type expressions as types. @@ -241,7 +250,9 @@ def analyze_base_args(self, base: IndexExpr, ctx: Context) -> list[Type] | None: self.fail("Invalid TypedDict type argument", ctx) return None analyzed = self.api.anal_type( - type, allow_required=True, allow_placeholder=not self.api.is_func_scope() + type, + allow_typed_dict_special_forms=True, + allow_placeholder=not self.api.is_func_scope(), ) if analyzed is None: return None @@ -270,7 +281,7 @@ def map_items_to_base( def analyze_typeddict_classdef_fields( self, defn: ClassDef, oldfields: list[str] | None = None - ) -> tuple[list[str] | None, list[Type], list[Statement], set[str]]: + ) -> tuple[list[str] | None, list[Type], list[Statement], set[str], set[str]]: """Analyze fields defined in a TypedDict class definition. This doesn't consider inherited fields (if any). Also consider totality, @@ -316,17 +327,15 @@ def analyze_typeddict_classdef_fields( else: analyzed = self.api.anal_type( stmt.unanalyzed_type, - allow_required=True, + allow_typed_dict_special_forms=True, allow_placeholder=not self.api.is_func_scope(), prohibit_self_type="TypedDict item type", ) if analyzed is None: - return None, [], [], set() # Need to defer + return None, [], [], set(), set() # Need to defer types.append(analyzed) if not has_placeholder(analyzed): - stmt.type = ( - analyzed.item if isinstance(analyzed, RequiredType) else analyzed - ) + stmt.type = self.extract_meta_info(analyzed, stmt)[0] # ...despite possible minor failures that allow further analysis. if stmt.type is None or hasattr(stmt, "new_syntax") and not stmt.new_syntax: self.fail(TPDICT_CLASS_ERROR, stmt) @@ -342,17 +351,49 @@ def analyze_typeddict_classdef_fields( if key == "total": continue self.msg.unexpected_keyword_argument_for_function(for_function, key, defn) - required_keys = { - field - for (field, t) in zip(fields, types) - if (total or (isinstance(t, RequiredType) and t.required)) - and not (isinstance(t, RequiredType) and not t.required) - } - types = [ # unwrap Required[T] to just T - t.item if isinstance(t, RequiredType) else t for t in types - ] - - return fields, types, statements, required_keys + + res_types = [] + readonly_keys = set() + required_keys = set() + for field, t in zip(fields, types): + typ, required, readonly = self.extract_meta_info(t) + res_types.append(typ) + if (total or required is True) and required is not False: + required_keys.add(field) + if readonly: + readonly_keys.add(field) + + return fields, res_types, statements, required_keys, readonly_keys + + def extract_meta_info( + self, typ: Type, context: Context | None = None + ) -> tuple[Type, bool | None, bool]: + """Unwrap all metadata types.""" + is_required = None # default, no modification + readonly = False # by default all is mutable + + seen_required = False + seen_readonly = False + while isinstance(typ, (RequiredType, ReadOnlyType)): + if isinstance(typ, RequiredType): + if context is not None and seen_required: + self.fail( + '"{}" type cannot be nested'.format( + "Required[]" if typ.required else "NotRequired[]" + ), + context, + code=codes.VALID_TYPE, + ) + is_required = typ.required + seen_required = True + typ = typ.item + if isinstance(typ, ReadOnlyType): + if context is not None and seen_readonly: + self.fail('"ReadOnly[]" type cannot be nested', context, code=codes.VALID_TYPE) + readonly = True + seen_readonly = True + typ = typ.item + return typ, is_required, readonly def check_typeddict( self, node: Expression, var_name: str | None, is_func_scope: bool @@ -391,7 +432,7 @@ def check_typeddict( name += "@" + str(call.line) else: name = var_name = "TypedDict@" + str(call.line) - info = self.build_typeddict_typeinfo(name, [], [], set(), call.line, None) + info = self.build_typeddict_typeinfo(name, [], [], set(), set(), call.line, None) else: if var_name is not None and name != var_name: self.fail( @@ -410,8 +451,11 @@ def check_typeddict( if (total or (isinstance(t, RequiredType) and t.required)) and not (isinstance(t, RequiredType) and not t.required) } - types = [ # unwrap Required[T] to just T - t.item if isinstance(t, RequiredType) else t for t in types + readonly_keys = { + field for (field, t) in zip(items, types) if isinstance(t, ReadOnlyType) + } + types = [ # unwrap Required[T] or ReadOnly[T] to just T + t.item if isinstance(t, (RequiredType, ReadOnlyType)) else t for t in types ] # Perform various validations after unwrapping. @@ -428,7 +472,7 @@ def check_typeddict( if isinstance(node.analyzed, TypedDictExpr): existing_info = node.analyzed.info info = self.build_typeddict_typeinfo( - name, items, types, required_keys, call.line, existing_info + name, items, types, required_keys, readonly_keys, call.line, existing_info ) info.line = node.line # Store generated TypeInfo under both names, see semanal_namedtuple for more details. @@ -514,7 +558,7 @@ def parse_typeddict_fields_with_types( return [], [], False analyzed = self.api.anal_type( type, - allow_required=True, + allow_typed_dict_special_forms=True, allow_placeholder=not self.api.is_func_scope(), prohibit_self_type="TypedDict item type", ) @@ -535,6 +579,7 @@ def build_typeddict_typeinfo( items: list[str], types: list[Type], required_keys: set[str], + readonly_keys: set[str], line: int, existing_info: TypeInfo | None, ) -> TypeInfo: @@ -546,7 +591,9 @@ def build_typeddict_typeinfo( ) assert fallback is not None info = existing_info or self.api.basic_new_typeinfo(name, fallback, line) - typeddict_type = TypedDictType(dict(zip(items, types)), required_keys, fallback) + typeddict_type = TypedDictType( + dict(zip(items, types)), required_keys, readonly_keys, fallback + ) if info.special_alias and has_placeholder(info.special_alias.target): self.api.process_placeholder( None, "TypedDict item", info, force_progress=typeddict_type != info.typeddict_type diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py index f8a874005adb..fc868d288b4d 100644 --- a/mypy/server/astdiff.py +++ b/mypy/server/astdiff.py @@ -475,7 +475,8 @@ def visit_tuple_type(self, typ: TupleType) -> SnapshotItem: def visit_typeddict_type(self, typ: TypedDictType) -> SnapshotItem: items = tuple((key, snapshot_type(item_type)) for key, item_type in typ.items.items()) required = tuple(sorted(typ.required_keys)) - return ("TypedDictType", items, required) + readonly = tuple(sorted(typ.readonly_keys)) + return ("TypedDictType", items, required, readonly) def visit_literal_type(self, typ: LiteralType) -> SnapshotItem: return ("LiteralType", snapshot_type(typ.fallback), typ.value) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 608d098791a9..3b775a06bd6e 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -914,6 +914,17 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool: # lands so here we are anticipating that change. if (name in left.required_keys) != (name in right.required_keys): return False + # Readonly fields check: + # + # A = TypedDict('A', {'x': ReadOnly[int]}) + # B = TypedDict('A', {'x': int}) + # def reset_x(b: B) -> None: + # b['x'] = 0 + # + # So, `A` cannot be a subtype of `B`, while `B` can be a subtype of `A`, + # because you can use `B` everywhere you use `A`, but not the other way around. + if name in left.readonly_keys and name not in right.readonly_keys: + return False # (NOTE: Fallbacks don't matter.) return True else: diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index 38e4c5ba0d01..8aac7e5c2bbd 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -276,6 +276,7 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: result = TypedDictType( items, t.required_keys, + t.readonly_keys, # TODO: This appears to be unsafe. cast(Any, t.fallback.accept(self)), t.line, diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 6c94390c23dc..0a6b7689136e 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -83,6 +83,7 @@ PlaceholderType, ProperType, RawExpressionType, + ReadOnlyType, RequiredType, SyntheticTypeVisitor, TrivialSyntheticTypeTranslator, @@ -219,7 +220,7 @@ def __init__( allow_tuple_literal: bool = False, allow_unbound_tvars: bool = False, allow_placeholder: bool = False, - allow_required: bool = False, + allow_typed_dict_special_forms: bool = False, allow_param_spec_literals: bool = False, allow_unpack: bool = False, report_invalid_types: bool = True, @@ -253,7 +254,7 @@ def __init__( # If false, record incomplete ref if we generate PlaceholderType. self.allow_placeholder = allow_placeholder # Are we in a context where Required[] is allowed? - self.allow_required = allow_required + self.allow_typed_dict_special_forms = allow_typed_dict_special_forms # Are we in a context where ParamSpec literals are allowed? self.allow_param_spec_literals = allow_param_spec_literals # Are we in context where literal "..." specifically is allowed? @@ -684,7 +685,7 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ return AnyType(TypeOfAny.from_error) return self.anal_type(t.args[0]) elif fullname in ("typing_extensions.Required", "typing.Required"): - if not self.allow_required: + if not self.allow_typed_dict_special_forms: self.fail( "Required[] can be only used in a TypedDict definition", t, @@ -696,9 +697,11 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ "Required[] must have exactly one type argument", t, code=codes.VALID_TYPE ) return AnyType(TypeOfAny.from_error) - return RequiredType(self.anal_type(t.args[0]), required=True) + return RequiredType( + self.anal_type(t.args[0], allow_typed_dict_special_forms=True), required=True + ) elif fullname in ("typing_extensions.NotRequired", "typing.NotRequired"): - if not self.allow_required: + if not self.allow_typed_dict_special_forms: self.fail( "NotRequired[] can be only used in a TypedDict definition", t, @@ -710,7 +713,23 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ "NotRequired[] must have exactly one type argument", t, code=codes.VALID_TYPE ) return AnyType(TypeOfAny.from_error) - return RequiredType(self.anal_type(t.args[0]), required=False) + return RequiredType( + self.anal_type(t.args[0], allow_typed_dict_special_forms=True), required=False + ) + elif fullname in ("typing_extensions.ReadOnly", "typing.ReadOnly"): + if not self.allow_typed_dict_special_forms: + self.fail( + "ReadOnly[] can be only used in a TypedDict definition", + t, + code=codes.VALID_TYPE, + ) + return AnyType(TypeOfAny.from_error) + if len(t.args) != 1: + self.fail( + '"ReadOnly[]" must have exactly one type argument', t, code=codes.VALID_TYPE + ) + return AnyType(TypeOfAny.from_error) + return ReadOnlyType(self.anal_type(t.args[0], allow_typed_dict_special_forms=True)) elif ( self.anal_type_guard_arg(t, fullname) is not None or self.anal_type_is_arg(t, fullname) is not None @@ -1223,9 +1242,11 @@ def visit_tuple_type(self, t: TupleType) -> Type: def visit_typeddict_type(self, t: TypedDictType) -> Type: req_keys = set() + readonly_keys = set() items = {} for item_name, item_type in t.items.items(): - analyzed = self.anal_type(item_type, allow_required=True) + # TODO: rework + analyzed = self.anal_type(item_type, allow_typed_dict_special_forms=True) if isinstance(analyzed, RequiredType): if analyzed.required: req_keys.add(item_name) @@ -1233,6 +1254,9 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: else: # Keys are required by default. req_keys.add(item_name) + if isinstance(analyzed, ReadOnlyType): + readonly_keys.add(item_name) + analyzed = analyzed.item items[item_name] = analyzed if t.fallback.type is MISSING_FALLBACK: # anonymous/inline TypedDict if INLINE_TYPEDDICT not in self.options.enable_incomplete_feature: @@ -1257,10 +1281,12 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: items[sub_item_name] = sub_item_type if sub_item_name in p_analyzed.required_keys: req_keys.add(sub_item_name) + if sub_item_name in p_analyzed.readonly_keys: + readonly_keys.add(sub_item_name) else: required_keys = t.required_keys fallback = t.fallback - return TypedDictType(items, required_keys, fallback, t.line, t.column) + return TypedDictType(items, required_keys, readonly_keys, fallback, t.line, t.column) def visit_raw_expression_type(self, t: RawExpressionType) -> Type: # We should never see a bare Literal. We synthesize these raw literals @@ -1811,12 +1837,12 @@ def anal_type( allow_param_spec: bool = False, allow_unpack: bool = False, allow_ellipsis: bool = False, - allow_required: bool = False, + allow_typed_dict_special_forms: bool = False, ) -> Type: if nested: self.nesting_level += 1 - old_allow_required = self.allow_required - self.allow_required = allow_required + old_allow_typed_dict_special_forms = self.allow_typed_dict_special_forms + self.allow_typed_dict_special_forms = allow_typed_dict_special_forms old_allow_ellipsis = self.allow_ellipsis self.allow_ellipsis = allow_ellipsis old_allow_unpack = self.allow_unpack @@ -1826,7 +1852,7 @@ def anal_type( finally: if nested: self.nesting_level -= 1 - self.allow_required = old_allow_required + self.allow_typed_dict_special_forms = old_allow_typed_dict_special_forms self.allow_ellipsis = old_allow_ellipsis self.allow_unpack = old_allow_unpack if ( diff --git a/mypy/types.py b/mypy/types.py index b1e57b2f6a86..dff7e2c0c829 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -476,6 +476,20 @@ def accept(self, visitor: TypeVisitor[T]) -> T: return self.item.accept(visitor) +class ReadOnlyType(Type): + """ReadOnly[T] Only usable at top-level of a TypedDict definition.""" + + def __init__(self, item: Type) -> None: + super().__init__(line=item.line, column=item.column) + self.item = item + + def __repr__(self) -> str: + return f"ReadOnly[{self.item}]" + + def accept(self, visitor: TypeVisitor[T]) -> T: + return self.item.accept(visitor) + + class ProperType(Type): """Not a type alias. @@ -2554,17 +2568,28 @@ class TypedDictType(ProperType): TODO: The fallback structure is perhaps overly complicated. """ - __slots__ = ("items", "required_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, items: dict[str, Type], required_keys: set[str], + readonly_keys: set[str], fallback: Instance, line: int = -1, column: int = -1, @@ -2572,16 +2597,25 @@ def __init__( super().__init__(line, column) self.items = items self.required_keys = required_keys + self.readonly_keys = readonly_keys self.fallback = fallback 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) def __hash__(self) -> int: - return hash((frozenset(self.items.items()), self.fallback, frozenset(self.required_keys))) + return hash( + ( + frozenset(self.items.items()), + self.fallback, + frozenset(self.required_keys), + frozenset(self.readonly_keys), + ) + ) def __eq__(self, other: object) -> bool: if not isinstance(other, TypedDictType): @@ -2596,6 +2630,7 @@ def __eq__(self, other: object) -> bool: ) and self.fallback == other.fallback and self.required_keys == other.required_keys + and self.readonly_keys == other.readonly_keys ) def serialize(self) -> JsonDict: @@ -2603,6 +2638,7 @@ def serialize(self) -> JsonDict: ".class": "TypedDictType", "items": [[n, t.serialize()] for (n, t) in self.items.items()], "required_keys": sorted(self.required_keys), + "readonly_keys": sorted(self.readonly_keys), "fallback": self.fallback.serialize(), } @@ -2612,6 +2648,7 @@ def deserialize(cls, data: JsonDict) -> TypedDictType: return TypedDictType( {n: deserialize_type(t) for (n, t) in data["items"]}, set(data["required_keys"]), + set(data["readonly_keys"]), Instance.deserialize(data["fallback"]), ) @@ -2635,6 +2672,7 @@ def copy_modified( item_types: list[Type] | None = None, item_names: list[str] | None = None, required_keys: set[str] | None = None, + readonly_keys: set[str] | None = None, ) -> TypedDictType: if fallback is None: fallback = self.fallback @@ -2644,10 +2682,12 @@ def copy_modified( items = dict(zip(self.items, item_types)) if required_keys is None: required_keys = self.required_keys + if readonly_keys is None: + readonly_keys = self.readonly_keys if item_names is not None: items = {k: v for (k, v) in items.items() if k in item_names} required_keys &= set(item_names) - return TypedDictType(items, required_keys, fallback, self.line, self.column) + return TypedDictType(items, required_keys, readonly_keys, fallback, self.line, self.column) def create_anonymous_fallback(self) -> Instance: anonymous = self.as_anonymous() @@ -3421,10 +3461,12 @@ def visit_tuple_type(self, t: TupleType) -> str: def visit_typeddict_type(self, t: TypedDictType) -> str: def item_str(name: str, typ: str) -> str: - if name in t.required_keys: - return f"{name!r}: {typ}" - else: - return f"{name!r}?: {typ}" + modifier = "" + if name not in t.required_keys: + modifier += "?" + if name in t.readonly_keys: + modifier += "=" + return f"{name!r}{modifier}: {typ}" s = ( "{" diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index dc1929751977..e1797421636e 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -2384,10 +2384,12 @@ class ForceDeferredEval: pass [case testTypedDictRequiredUnimportedAny] # flags: --disallow-any-unimported -from typing import NotRequired, TypedDict +from typing import NotRequired, TypedDict, ReadOnly from nonexistent import Foo # type: ignore[import-not-found] class Bar(TypedDict): foo: NotRequired[Foo] # E: Type of variable becomes "Any" due to an unfollowed import + bar: ReadOnly[Foo] # E: Type of variable becomes "Any" due to an unfollowed import + baz: NotRequired[ReadOnly[Foo]] # E: Type of variable becomes "Any" due to an unfollowed import [typing fixtures/typing-typeddict.pyi] -- Required[] @@ -3631,6 +3633,16 @@ y = {"one": 1} # E: Expected TypedDict keys ("one", "other") but found only key [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] +[case testTypedDictInlineReadOnly] +# flags: --enable-incomplete-feature=InlineTypedDict +from typing import ReadOnly + +x: {"one": int, "other": ReadOnly[int]} +x["one"] = 1 # ok +x["other"] = 1 # E: ReadOnly TypedDict key "other" TypedDict is mutated +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + [case testTypedDictInlineNestedSchema] # flags: --enable-incomplete-feature=InlineTypedDict def nested() -> {"one": str, "other": {"a": int, "b": int}}: @@ -3652,3 +3664,327 @@ x: {"a": int, **X[str], "b": int} reveal_type(x) # N: Revealed type is "TypedDict({'a': builtins.int, 'b': builtins.int, 'item': builtins.str})" [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] + + +# ReadOnly +# See: https://peps.python.org/pep-0705 + +[case testTypedDictReadOnly] +# flags: --show-error-codes +from typing import ReadOnly, TypedDict + +class TP(TypedDict): + one: int + other: ReadOnly[str] + +x: TP +reveal_type(x["one"]) # N: Revealed type is "builtins.int" +reveal_type(x["other"]) # N: Revealed type is "builtins.str" +x["one"] = 1 # ok +x["other"] = "a" # E: ReadOnly TypedDict key "other" TypedDict is mutated [typeddict-readonly-mutated] +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[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): + required_key: ReadOnly[str] + optional_key: ReadOnly[NotRequired[str]] + +x: TP +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] + +[case testTypedDictReadOnlyMutateMethods] +from typing import ReadOnly, TypedDict + +class TP(TypedDict): + key: ReadOnly[str] + other: ReadOnly[int] + mutable: bool + +x: TP +reveal_type(x.pop("key")) # E: Key "key" of TypedDict "TP" cannot be deleted \ + # N: Revealed type is "builtins.str" + +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 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): + key: ReadOnly[str] + other: ReadOnly[int] + mutable: bool + +x: TP +x |= {"mutable": True} # ok +x |= {"key": "a"} # E: ReadOnly TypedDict key "key" TypedDict is mutated +x |= {"key": "a", "other": 1, "mutable": True} # E: ReadOnly TypedDict keys ("key", "other") TypedDict are mutated +[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 + +T = TypeVar('T') + +class TP(TypedDict, Generic[T]): + key: ReadOnly[T] + +x: TP[int] +reveal_type(x["key"]) # N: Revealed type is "builtins.int" +x["key"] = 1 # E: ReadOnly TypedDict key "key" TypedDict is mutated +x["key"] = "a" # E: ReadOnly TypedDict key "key" TypedDict is mutated \ + # E: Value of "key" has incompatible type "str"; expected "int" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyOtherTypedDict] +from typing import ReadOnly, TypedDict + +class First(TypedDict): + field: int + +class TP(TypedDict): + key: ReadOnly[First] + +x: TP +reveal_type(x["key"]["field"]) # N: Revealed type is "builtins.int" +x["key"]["field"] = 1 # ok +x["key"] = {"field": 2} # E: ReadOnly TypedDict key "key" TypedDict is mutated +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyInheritance] +from typing import ReadOnly, TypedDict + +class Base(TypedDict): + a: ReadOnly[str] + +class Child(Base): + b: ReadOnly[int] + +base: Base +reveal_type(base["a"]) # N: Revealed type is "builtins.str" +base["a"] = "x" # E: ReadOnly TypedDict key "a" TypedDict is mutated +base["b"] # E: TypedDict "Base" has no key "b" + +child: Child +reveal_type(child["a"]) # N: Revealed type is "builtins.str" +reveal_type(child["b"]) # N: Revealed type is "builtins.int" +child["a"] = "x" # E: ReadOnly TypedDict key "a" TypedDict is mutated +child["b"] = 1 # E: ReadOnly TypedDict key "b" TypedDict is mutated +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlySubtyping] +from typing import ReadOnly, TypedDict + +class A(TypedDict): + key: ReadOnly[str] + +class B(TypedDict): + key: str + +a: A +b: B + +def accepts_A(d: A): ... +def accepts_B(d: B): ... + +accepts_A(a) +accepts_A(b) +accepts_B(a) # E: Argument 1 to "accepts_B" has incompatible type "A"; expected "B" +accepts_B(b) +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyCall] +from typing import ReadOnly, TypedDict + +TP = TypedDict("TP", {"one": int, "other": ReadOnly[str]}) + +x: TP +reveal_type(x["one"]) # N: Revealed type is "builtins.int" +reveal_type(x["other"]) # N: Revealed type is "builtins.str" +x["one"] = 1 # ok +x["other"] = "a" # E: ReadOnly TypedDict key "other" TypedDict is mutated +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyABCSubtypes] +from typing import ReadOnly, TypedDict, Mapping, Dict, MutableMapping + +class TP(TypedDict): + one: int + other: ReadOnly[int] + +def accepts_mapping(m: Mapping[str, object]): ... +def accepts_mutable_mapping(mm: MutableMapping[str, object]): ... +def accepts_dict(d: Dict[str, object]): ... + +x: TP +accepts_mapping(x) +accepts_mutable_mapping(x) # E: Argument 1 to "accepts_mutable_mapping" has incompatible type "TP"; expected "MutableMapping[str, object]" +accepts_dict(x) # E: Argument 1 to "accepts_dict" has incompatible type "TP"; expected "Dict[str, object]" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyAndNotRequired] +from typing import ReadOnly, TypedDict, NotRequired + +class TP(TypedDict): + one: ReadOnly[NotRequired[int]] + two: NotRequired[ReadOnly[str]] + +x: TP +reveal_type(x) # N: Revealed type is "TypedDict('__main__.TP', {'one'?=: builtins.int, 'two'?=: builtins.str})" +reveal_type(x.get("one")) # N: Revealed type is "Union[builtins.int, None]" +reveal_type(x.get("two")) # N: Revealed type is "Union[builtins.str, None]" +x["one"] = 1 # E: ReadOnly TypedDict key "one" TypedDict is mutated +x["two"] = "a" # E: ReadOnly TypedDict key "two" TypedDict is mutated +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testMeetOfTypedDictsWithReadOnly] +from typing import TypeVar, Callable, TypedDict, ReadOnly +XY = TypedDict('XY', {'x': ReadOnly[int], 'y': int}) +YZ = TypedDict('YZ', {'y': int, 'z': ReadOnly[int]}) +T = TypeVar('T') +def f(x: Callable[[T, T], None]) -> T: pass +def g(x: XY, y: YZ) -> None: pass +reveal_type(f(g)) # N: Revealed type is "TypedDict({'x'=: builtins.int, 'y': builtins.int, 'z'=: builtins.int})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictReadOnlyUnpack] +from typing_extensions import TypedDict, Unpack, ReadOnly + +class TD(TypedDict): + x: ReadOnly[int] + y: str + +def func(**kwargs: Unpack[TD]): + kwargs["x"] = 1 # E: ReadOnly TypedDict key "x" TypedDict is mutated + kwargs["y" ] = "a" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testIncorrectTypedDictSpecialFormsUsage] +from typing import ReadOnly, TypedDict, NotRequired, Required + +x: ReadOnly[int] # E: ReadOnly[] can be only used in a TypedDict definition +y: Required[int] # E: Required[] can be only used in a TypedDict definition +z: NotRequired[int] # E: NotRequired[] can be only used in a TypedDict definition + +class TP(TypedDict): + a: ReadOnly[ReadOnly[int]] # E: "ReadOnly[]" type cannot be nested + b: ReadOnly[NotRequired[ReadOnly[str]]] # E: "ReadOnly[]" type cannot be nested + c: NotRequired[Required[int]] # E: "Required[]" type cannot be nested + d: Required[NotRequired[int]] # E: "NotRequired[]" type cannot be nested + e: Required[ReadOnly[NotRequired[int]]] # E: "NotRequired[]" type cannot be nested + f: ReadOnly[ReadOnly[ReadOnly[int]]] # E: "ReadOnly[]" type cannot be nested + g: Required[Required[int]] # E: "Required[]" type cannot be nested + h: NotRequired[NotRequired[int]] # E: "NotRequired[]" type cannot be nested + + j: NotRequired[ReadOnly[Required[ReadOnly[int]]]] # E: "Required[]" type cannot be nested \ + # E: "ReadOnly[]" type cannot be nested + + k: ReadOnly # E: "ReadOnly[]" must have exactly one type argument +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test index 2ad31311a402..faedd890922d 100644 --- a/test-data/unit/fine-grained.test +++ b/test-data/unit/fine-grained.test @@ -3705,6 +3705,34 @@ def foo() -> None: == b.py:4: error: Incompatible types in assignment (expression has type "str", variable has type "int") +[case testTypedDictUpdateReadOnly] +import b +[file a.py] +from typing_extensions import TypedDict, ReadOnly +Point = TypedDict('Point', {'x': int, 'y': int}) +p = Point(x=1, y=2) +[file a.py.2] +from typing_extensions import TypedDict, ReadOnly +class Point(TypedDict): + x: int + y: ReadOnly[int] +p = Point(x=1, y=2) +[file a.py.3] +from typing_extensions import TypedDict, ReadOnly +Point = TypedDict('Point', {'x': ReadOnly[int], 'y': int}) +p = Point(x=1, y=2) +[file b.py] +from a import Point +def foo(x: Point) -> None: + x['x'] = 1 + x['y'] = 2 +[builtins fixtures/dict.pyi] +[out] +== +b.py:4: error: ReadOnly TypedDict key "y" TypedDict is mutated +== +b.py:3: error: ReadOnly TypedDict key "x" TypedDict is mutated + [case testBasicAliasUpdate] import b [file a.py] diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi index d136ac4ab8be..7e9c642cf261 100644 --- a/test-data/unit/fixtures/typing-typeddict.pyi +++ b/test-data/unit/fixtures/typing-typeddict.pyi @@ -26,6 +26,7 @@ TypedDict = 0 NoReturn = 0 Required = 0 NotRequired = 0 +ReadOnly = 0 Self = 0 T = TypeVar('T') @@ -59,6 +60,10 @@ class Mapping(Iterable[T], Generic[T, T_co], metaclass=ABCMeta): def __len__(self) -> int: ... def __contains__(self, arg: object) -> int: pass +class MutableMapping(Mapping[T, T_co], Generic[T, T_co], metaclass=ABCMeta): + # Other methods are not used in tests. + def clear(self) -> None: ... + # Fallback type for all typed dicts (does not exist at runtime). class _TypedDict(Mapping[str, object]): # Needed to make this class non-abstract. It is explicitly declared abstract in diff --git a/test-data/unit/lib-stub/typing_extensions.pyi b/test-data/unit/lib-stub/typing_extensions.pyi index b5bfc1ab3f20..d9d7067efe0f 100644 --- a/test-data/unit/lib-stub/typing_extensions.pyi +++ b/test-data/unit/lib-stub/typing_extensions.pyi @@ -41,6 +41,7 @@ TypeVarTuple: _SpecialForm Unpack: _SpecialForm Required: _SpecialForm NotRequired: _SpecialForm +ReadOnly: _SpecialForm @final class TypeAliasType: