diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst index 2a54c1144171..f7c4463db854 100644 --- a/docs/source/command_line.rst +++ b/docs/source/command_line.rst @@ -1085,7 +1085,7 @@ format into the specified directory. Enabling incomplete/experimental features ***************************************** -.. option:: --enable-incomplete-feature {PreciseTupleTypes, InlineTypedDict} +.. option:: --enable-incomplete-feature {PreciseTupleTypes, NewInlineTypedDict, InlineTypedDict} Some features may require several mypy releases to implement, for example due to their complexity, potential for backwards incompatibility, or @@ -1132,6 +1132,14 @@ List of currently incomplete/experimental features: # Without PreciseTupleTypes: tuple[int, ...] # With PreciseTupleTypes: tuple[()] | tuple[int] | tuple[int, int] +* ``NewInlineTypedDict``: this feature enables :pep:`764` syntax for inline + :ref:`TypedDicts `, for example: + + .. code-block:: python + + def test_values() -> TypedDict[{"int": int, "str": str}]: + return {"int": 42, "str": "test"} + * ``InlineTypedDict``: this feature enables non-standard syntax for inline :ref:`TypedDicts `, for example: diff --git a/docs/source/typed_dict.rst b/docs/source/typed_dict.rst index bbb10a12abe8..93959ad9db70 100644 --- a/docs/source/typed_dict.rst +++ b/docs/source/typed_dict.rst @@ -294,8 +294,8 @@ Inline TypedDict types .. note:: - This is an experimental (non-standard) feature. Use - ``--enable-incomplete-feature=InlineTypedDict`` to enable. + This is an experimental feature proposed by :pep:`764`. Use + ``--enable-incomplete-feature=NewInlineTypedDict`` to enable. Sometimes you may want to define a complex nested JSON schema, or annotate a one-off function that returns a TypedDict. In such cases it may be convenient @@ -303,26 +303,36 @@ to use inline TypedDict syntax. For example: .. code-block:: python - def test_values() -> {"int": int, "str": str}: + def test_values() -> TypedDict[{"int": int, "str": str}]: return {"int": 42, "str": "test"} class Response(TypedDict): status: int msg: str # Using inline syntax here avoids defining two additional TypedDicts. - content: {"items": list[{"key": str, "value": str}]} + content: TypedDict[{"items": list[TypedDict[{"key": str, "value": str}]]}] -Inline TypedDicts can also by used as targets of type aliases, but due to -ambiguity with a regular variables it is only allowed for (newer) explicit -type alias forms: +.. note:: -.. code-block:: python + Mypy also supports a legacy syntax for inline TypedDicts that pre-dates :pep:`764`: + + .. code-block:: python + + def test_values() -> {"int": int, "str": str}: + return {"int": 42, "str": "test"} + + This legacy syntax can be enabled using ``--enable-incomplete-feature=InlineTypedDict``. + Due to ambiguity with a regular variables, the legacy syntax may only be used in + type aliases when using (newer) explicit type alias forms: + + .. code-block:: python - from typing import TypeAlias + from typing import TypeAlias - X = {"a": int, "b": int} # creates a variable with type dict[str, type[int]] - Y: TypeAlias = {"a": int, "b": int} # creates a type alias - type Z = {"a": int, "b": int} # same as above (Python 3.12+ only) + X = {"a": int, "b": int} # creates a variable with type dict[str, type[int]] + Y: TypeAlias = {"a": int, "b": int} # creates a type alias + type Z = {"a": int, "b": int} # same as above (Python 3.12+ only) -Also, due to incompatibility with runtime type-checking it is strongly recommended -to *not* use inline syntax in union types. + This restriction does not apply to the :pep:`764` syntax. + Also, due to incompatibility with runtime type-checking, it is strongly recommended + to *not* use legacy inline syntax in union types. diff --git a/mypy/fastparse.py b/mypy/fastparse.py index b9a55613ec16..982eb7058e4e 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2077,8 +2077,6 @@ def visit_Tuple(self, n: ast3.Tuple) -> Type: ) def visit_Dict(self, n: ast3.Dict) -> Type: - if not n.keys: - return self.invalid_type(n) items: dict[str, Type] = {} extra_items_from = [] for item_name, value in zip(n.keys, n.values): diff --git a/mypy/options.py b/mypy/options.py index 17fea6b0bf29..c968b8884ba4 100644 --- a/mypy/options.py +++ b/mypy/options.py @@ -80,7 +80,10 @@ class BuildType: PRECISE_TUPLE_TYPES: Final = "PreciseTupleTypes" NEW_GENERIC_SYNTAX: Final = "NewGenericSyntax" INLINE_TYPEDDICT: Final = "InlineTypedDict" -INCOMPLETE_FEATURES: Final = frozenset((PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT)) +NEW_INLINE_TYPEDDICT: Final = "NewInlineTypedDict" +INCOMPLETE_FEATURES: Final = frozenset( + (PRECISE_TUPLE_TYPES, INLINE_TYPEDDICT, NEW_INLINE_TYPEDDICT) +) COMPLETE_FEATURES: Final = frozenset((TYPE_VAR_TUPLE, UNPACK, NEW_GENERIC_SYNTAX)) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 7bf21709b863..3d745099753a 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -49,7 +49,7 @@ check_arg_names, get_nongen_builtins, ) -from mypy.options import INLINE_TYPEDDICT, Options +from mypy.options import INLINE_TYPEDDICT, NEW_INLINE_TYPEDDICT, Options from mypy.plugin import AnalyzeTypeContext, Plugin, TypeAnalyzerPluginInterface from mypy.semanal_shared import ( SemanticAnalyzerCoreInterface, @@ -66,6 +66,7 @@ FINAL_TYPE_NAMES, LITERAL_TYPE_NAMES, NEVER_NAMES, + TPDICT_NAMES, TYPE_ALIAS_NAMES, UNPACK_TYPE_NAMES, AnyType, @@ -125,6 +126,7 @@ "typing.Union", *LITERAL_TYPE_NAMES, *ANNOTATED_TYPE_NAMES, + *TPDICT_NAMES, } ARG_KINDS_BY_CONSTRUCTOR: Final = { @@ -810,6 +812,22 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ # TODO: verify this is unreachable and replace with an assert? self.fail("Unexpected Self type", t) return AnyType(TypeOfAny.from_error) + elif fullname in TPDICT_NAMES: + if len(t.args) != 1: + self.fail( + "TypedDict[] must have exactly one type argument", t, code=codes.VALID_TYPE + ) + return AnyType(TypeOfAny.from_error) + item = t.args[0] + if not isinstance(item, TypedDictType): # type: ignore[misc] + self.fail( + "Argument to TypedDict[] must be a literal dictionary mapping item names to types", + t, + code=codes.VALID_TYPE, + ) + return AnyType(TypeOfAny.from_error) + item.is_pep764 = True + return self.anal_type(item, allow_typed_dict_special_forms=True) return None def get_omitted_any(self, typ: Type, fullname: str | None = None) -> AnyType: @@ -1336,14 +1354,26 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: 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: + if not t.is_pep764 and INLINE_TYPEDDICT not in self.options.enable_incomplete_feature: self.fail( - "Inline TypedDict is experimental," - " must be enabled with --enable-incomplete-feature=InlineTypedDict", + "Legacy inline TypedDict is experimental," + f" must be enabled with --enable-incomplete-feature={INLINE_TYPEDDICT}", + t, + ) + self.note("Did you mean TypedDict[...]?", t) + if t.is_pep764 and NEW_INLINE_TYPEDDICT not in self.options.enable_incomplete_feature: + self.fail( + "PEP 764 inline TypedDict is experimental," + f" must be enabled with --enable-incomplete-feature={NEW_INLINE_TYPEDDICT}", t, ) required_keys = req_keys fallback = self.named_type("typing._TypedDict") + if t.is_pep764 and t.extra_items_from: + self.fail( + "PEP 764 inline TypedDict does not support merge-in", t, code=codes.VALID_TYPE + ) + return AnyType(TypeOfAny.from_error) for typ in t.extra_items_from: analyzed = self.analyze_type(typ) p_analyzed = get_proper_type(analyzed) @@ -1363,6 +1393,11 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type: else: required_keys = t.required_keys fallback = t.fallback + if not t.is_pep764 and not t.items: + self.fail( + "Legacy inline TypedDict must have at least one item", t, code=codes.VALID_TYPE + ) + return AnyType(TypeOfAny.from_error) return TypedDictType(items, required_keys, readonly_keys, fallback, t.line, t.column) def visit_raw_expression_type(self, t: RawExpressionType) -> Type: diff --git a/mypy/types.py b/mypy/types.py index 41a958ae93cc..f17c4f87de5f 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -2575,6 +2575,7 @@ class TypedDictType(ProperType): "fallback", "extra_items_from", "to_be_mutated", + "is_pep764", ) items: dict[str, Type] # item_name -> item_type @@ -2584,6 +2585,7 @@ class TypedDictType(ProperType): extra_items_from: list[ProperType] # only used during semantic analysis to_be_mutated: bool # only used in a plugin for `.update`, `|=`, etc + is_pep764: bool def __init__( self, @@ -2603,6 +2605,7 @@ def __init__( self.can_be_false = len(self.required_keys) == 0 self.extra_items_from = [] self.to_be_mutated = False + self.is_pep764 = False def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_typeddict_type(self) diff --git a/test-data/unit/check-literal.test b/test-data/unit/check-literal.test index 88c02f70488c..5c15002ec7c7 100644 --- a/test-data/unit/check-literal.test +++ b/test-data/unit/check-literal.test @@ -615,7 +615,8 @@ e: Literal[dummy()] # E: Invalid type: Literal[...] cannot contain a from typing import Literal a: Literal[{"a": 1, "b": 2}] # E: Parameter 1 of Literal[...] is invalid b: Literal[{1, 2, 3}] # E: Invalid type: Literal[...] cannot contain arbitrary expressions -c: {"a": 1, "b": 2} # E: Inline TypedDict is experimental, must be enabled with --enable-incomplete-feature=InlineTypedDict \ +c: {"a": 1, "b": 2} # E: Legacy inline TypedDict is experimental, must be enabled with --enable-incomplete-feature=InlineTypedDict \ + # N: Did you mean TypedDict[...]? \ # E: Invalid type: try using Literal[1] instead? \ # E: Invalid type: try using Literal[2] instead? d: {1, 2, 3} # E: Invalid type comment or annotation diff --git a/test-data/unit/check-python312.test b/test-data/unit/check-python312.test index 2f3d5e08dab3..864001ee2215 100644 --- a/test-data/unit/check-python312.test +++ b/test-data/unit/check-python312.test @@ -1733,6 +1733,17 @@ type Y[T] = {"item": T, **Y[T]} # E: Overwriting TypedDict field "item" while m [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] +[case testTypedDictInlinePEP764YesNewStyleAlias] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +type X[T] = TypedDict[{"item": T, "other": X[T] | None}] +x: X[str] +reveal_type(x) # N: Revealed type is "TypedDict({'item': builtins.str, 'other': Union[..., None]})" +if x["other"] is not None: + reveal_type(x["other"]["item"]) # N: Revealed type is "builtins.str" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + [case testPEP695UsingIncorrectExpressionsInTypeVariableBound] type X[T: (yield 1)] = Any # E: Yield expression cannot be used as a type variable bound type Y[T: (yield from [])] = Any # E: Yield expression cannot be used as a type variable bound diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test index 47c8a71ba0e3..3a43b8a82f78 100644 --- a/test-data/unit/check-typeddict.test +++ b/test-data/unit/check-typeddict.test @@ -3659,7 +3659,7 @@ reveal_type(x) # N: # N: Revealed type is "TypedDict({'int': builtins.int, 'st [case testTypedDictInlineNoEmpty] # flags: --enable-incomplete-feature=InlineTypedDict -x: {} # E: Invalid type comment or annotation +x: {} # E: Legacy inline TypedDict must have at least one item reveal_type(x) # N: Revealed type is "Any" [builtins fixtures/dict.pyi] [typing fixtures/typing-typeddict.pyi] @@ -3707,6 +3707,134 @@ reveal_type(x) # N: Revealed type is "TypedDict({'a': builtins.int, 'b': builti [builtins fixtures/dict.pyi] [typing fixtures/typing-full.pyi] +[case testTypedDictInlinePEP764NotEnabled] +from typing import TypedDict +x: TypedDict[{"a": int}] # E: PEP 764 inline TypedDict is experimental, must be enabled with --enable-incomplete-feature=NewInlineTypedDict +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764OldStyleAlias] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +X = TypedDict[{"int": int, "str": str}] +x: X +reveal_type(x) # N: Revealed type is "TypedDict({'int': builtins.int, 'str': builtins.str})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764MidStyleAlias] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +from typing_extensions import TypeAlias +X: TypeAlias = TypedDict[{"int": int, "str": str}] +x: X +reveal_type(x) # N: Revealed type is "TypedDict({'int': builtins.int, 'str': builtins.str})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764OYesEmpty] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +x: TypedDict[{}] +reveal_type(x) # N: Revealed type is "TypedDict({})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + + +[case testTypedDictInlinePEP764NotRequired] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import NotRequired, TypedDict + +x: TypedDict[{"one": int, "other": NotRequired[int]}] +x = {"one": 1} # OK +y: TypedDict[{"one": int, "other": int}] +y = {"one": 1} # E: Expected TypedDict keys ("one", "other") but found only key "one" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764ReadOnly] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import ReadOnly, TypedDict + +x: TypedDict[{"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 testTypedDictInlinePEP764NestedSchema] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +def nested() -> TypedDict[{"one": str, "other": TypedDict[{"a": int, "b": int}]}]: + if bool(): + return {"one": "yes", "other": {"a": 1, "b": 2}} # OK + else: + return {"one": "no", "other": {"a": 1, "b": "2"}} # E: Incompatible types (expression has type "str", TypedDict item "b" has type "int") +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764MergeAnother] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypeVar, TypedDict +from typing_extensions import TypeAlias + +T = TypeVar("T") +X: TypeAlias = TypedDict[{"item": T}] +x: TypedDict[{"a": int, **X[str], "b": int}] # E: PEP 764 inline TypedDict does not support merge-in +reveal_type(x) # N: Revealed type is "Any" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypedDictInlinePEP764BadArg] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +x: TypedDict[int] # E: Argument to TypedDict[] must be a literal dictionary mapping item names to types +reveal_type(x) # N: Revealed type is "Any" +y: TypedDict[{1: str}] # E: Argument to TypedDict[] must be a literal dictionary mapping item names to types +reveal_type(y) # N: Revealed type is "Any" +z: TypedDict[{"a": 1}] # E: Invalid type: try using Literal[1] instead? +reveal_type(z) # N: Revealed type is "TypedDict({'a': Any})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764TooManyArgs] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +x: TypedDict[{"a": int}, "foo"] # E: TypedDict[] must have exactly one type argument +reveal_type(x) # N: Revealed type is "Any" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764TypeVarValid] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import Generic, TypeVar, TypedDict +T = TypeVar("T") +class X(Generic[T]): + attr: TypedDict[{'name': T}] +def f(arg: T) -> TypedDict[{'name': T}]: ... +Y = TypedDict[{'name': T}] +reveal_type(X[int]().attr['name']) # N: Revealed type is "builtins.int" +reveal_type(f('a')['name']) # N: Revealed type is "builtins.str" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] + +[case testTypedDictInlinePEP764TypeVarInvalid] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypeVar, TypedDict +T = TypeVar('T') +def f(): + X = TypedDict[{'name': T}] # TODO: emit error - this is invalid per PEP-764 +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypedDictInlinePEP764UsesExtensionSyntax] +# flags: --enable-incomplete-feature=NewInlineTypedDict +from typing import TypedDict +x: TypedDict[{'name': str, 'production': {'location': str}}] # E: Legacy inline TypedDict is experimental, must be enabled with --enable-incomplete-feature=InlineTypedDict \ + # N: Did you mean TypedDict[...]? +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str, 'production': TypedDict({'location': builtins.str})})" +[builtins fixtures/dict.pyi] +[typing fixtures/typing-typeddict.pyi] # ReadOnly # See: https://peps.python.org/pep-0705