8000 Experimental: allow inline/anonymous TypedDicts (#17457) · python/mypy@6d45f3c · GitHub
[go: up one dir, main page]

Skip to content

Commit 6d45f3c

Browse files
Experimental: allow inline/anonymous TypedDicts (#17457)
Fixes #9884 I was always a bit skeptical about this thing, since it feels more like TypeScript than Python, but it is second most upvoted issue. Also (this specific) implementation is like 60 lines of code plus tests, so why not. I know there is no PEP etc., but IMO this syntax is obvious and it just works. cc @JukkaL --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 9c0a6f9 commit 6d45f3c

14 files changed

+247
-40
lines changed

docs/source/command_line.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,27 @@ List of currently incomplete/experimental features:
10551055
# Without PreciseTupleTypes: tuple[int, ...]
10561056
# With PreciseTupleTypes: tuple[()] | tuple[int] | tuple[int, int]
10571057
1058+
* ``NewGenericSyntax``: this feature enables support for syntax defined
1059+
by :pep:`695`. For example:
1060+
1061+
.. code-block:: python
1062+
1063+
class Container[T]: # defines a generic class
1064+
content: T
1065+
1066+
def first[T](items: list[T]) -> T: # defines a generic function
1067+
return items[0]
1068+
1069+
type Items[T] = list[tuple[T, T]] # defines a generic type alias
1070+
1071+
* ``InlineTypedDict``: this feature enables non-standard syntax for inline
1072+
:ref:`TypedDicts <typeddict>`, for example:
1073+
1074+
.. code-block:: python
1075+
1076+
def test_values() -> {"int": int, "str": str}:
1077+
return {"int": 42, "str": "test"}
1078+
10581079
10591080
Miscellaneous
10601081
*************

docs/source/typed_dict.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,41 @@ section of the docs has a full description with an example, but in short, you wi
248248
need to give each TypedDict the same key where each value has a unique
249249
:ref:`Literal type <literal_types>`. Then, check that key to distinguish
250250
between your TypedDicts.
251+
252+
Inline TypedDict types
253+
----------------------
254+
255+
.. note::
256+
257+
This is an experimental (non-standard) feature. Use
258+
``--enable-incomplete-feature=InlineTypedDict`` to enable.
259+
260+
Sometimes you may want to define a complex nested JSON schema, or annotate
261+
a one-off function that returns a TypedDict. In such cases it may be convenient
262+
to use inline TypedDict syntax. For example:
263+
264+
.. code-block:: python
265+
266+
def test_values() -> {"int": int, "str": str}:
267+
return {"int": 42, "str": "test"}
268+
269+
class Response(TypedDict):
270+
status: int
271+
msg: str
272+
# Using inline syntax here avoids defining two additional TypedDicts.
273+
content: {"items": list[{"key": str, "value": str}]}
274+
275+
Inline TypedDicts can also by used as targets of type aliases, but due to
276+
ambiguity with a regular variables it is only allowed for (newer) explicit
277+
type alias forms:
278+
279+
.. code-block:: python
280+
281+
from typing import TypeAlias
282+
283+
X = {"a": int, "b": int} # creates a variable with type dict[str, type[int]]
284+
Y: TypeAlias = {"a": int, "b": int} # creates a type alias
285+
type Z = {"a": int, "b": int} # same as above (Python 3.12+ only)
286+
287+
Also, due to incompatibility with runtime type-checking it is strongly recommended
288+
to *not* use inline syntax in union types.

mypy/checker.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2971,7 +2971,8 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
29712971
self.msg.annotation_in_unchecked_function(context=s)
29722972

29732973
def check_type_alias_rvalue(self, s: AssignmentStmt) -> None:
2974-
alias_type = self.expr_checker.accept(s.rvalue)
2974+
with self.msg.filter_errors():
2975+
alias_type = self.expr_checker.accept(s.rvalue)
29752976
self.store_type(s.lvalues[-1], alias_type)
29762977

29772978
def check_assignment(
@@ -5311,7 +5312,8 @@ def remove_capture_conflicts(self, type_map: TypeMap, inferred_types: dict[Var,
53115312
del type_map[expr]
53125313

53135314
def visit_type_alias_stmt(self, o: TypeAliasStmt) -> None:
5314-
self.expr_checker.accept(o.value)
5315+
with self.msg.filter_errors():
5316+
self.expr_checker.accept(o.value)
53155317

53165318
def make_fake_typeinfo(
53175319
self,

mypy/exprtotype.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
from mypy.fastparse import parse_type_string
66
from mypy.nodes import (
7+
MISSING_FALLBACK,
78
BytesExpr,
89
CallExpr,
910
ComplexExpr,
11+
DictExpr,
1012
EllipsisExpr,
1113
Expression,
1214
FloatExpr,
@@ -29,9 +31,11 @@
2931
AnyType,
3032
CallableArgument,
3133
EllipsisType,
34+
Instance,
3235
ProperType,
3336
RawExpressionType,
3437
Type,
38+
TypedDictType,
3539
TypeList,
3640
TypeOfAny,
3741
UnboundType,
@@ -55,7 +59,7 @@ def _extract_argument_name(expr: Expression) -> str | None:
5559

5660
def expr_to_unanalyzed_type(
5761
expr: Expression,
58-
options: Options | None = None,
62+
options: Options,
5963
allow_new_syntax: bool = False,
6064
_parent: Expression | None = None,
6165
allow_unpack: bool = False,
@@ -67,6 +71,8 @@ def expr_to_unanalyzed_type(
6771
6872
If allow_new_syntax is True, allow all type syntax independent of the target
6973
Python version (used in stubs).
74+
75+
# TODO: a lot of code here is duplicated in fastparse.py, refactor this.
7076
"""
7177
# The `parent` parameter is used in recursive calls to provide context for
7278
# understanding whether an CallableArgument is ok.
@@ -116,7 +122,7 @@ def expr_to_unanalyzed_type(
116122
elif (
117123
isinstance(expr, OpExpr)
118124
and expr.op == "|"
119-
and ((options and options.python_version >= (3, 10)) or allow_new_syntax)
125+
and ((options.python_version >= (3, 10)) or allow_new_syntax)
120126
):
121127
return UnionType(
122128
[
@@ -206,5 +212,26 @@ def expr_to_unanalyzed_type(
206212
return UnpackType(
207213
expr_to_unanalyzed_type(expr.expr, options, allow_new_syntax), from_star_syntax=True
208214
)
215+
elif isinstance(expr, DictExpr):
216+
if not expr.items:
217+
raise TypeTranslationError()
218+
items: dict[str, Type] = {}
219+
extra_items_from = []
220+
for item_name, value in expr.items:
221+
if not isinstance(item_name, StrExpr):
222+
if item_name is None:
223+
extra_items_from.append(
224+
expr_to_unanalyzed_type(value, options, allow_new_syntax, expr)
225+
)
226+
continue
227+
raise TypeTranslationError()
228+
items[item_name.value] = expr_to_unanalyzed_type(
229+
value, options, allow_new_syntax, expr
230+
)
231+
result = TypedDictType(
232+
items, set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column
233+
)
234+
result.extra_items_from = extra_items_from
235+
return result
209236
else:
210237
raise TypeTranslationError()

mypy/fastparse.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ARG_POS,
1818
ARG_STAR,
1919
ARG_STAR2,
20+
MISSING_FALLBACK,
2021
PARAM_SPEC_KIND,
2122
TYPE_VAR_KIND,
2223
TYPE_VAR_TUPLE_KIND,
@@ -42,7 +43,6 @@
4243
EllipsisExpr,
4344
Expression,
4445
ExpressionStmt,
45-
FakeInfo,
4646
FloatExpr,
4747
ForStmt,
4848
FuncDef,
@@ -116,6 +116,7 @@
116116
RawExpressionType,
117117
TupleType,
118118
Type,
119+
TypedDictType,
119120
TypeList,
120121
TypeOfAny,
121122
UnboundType,
@@ -190,7 +191,6 @@ def ast3_parse(
190191

191192
# There is no way to create reasonable fallbacks at this stage,
192193
# they must be patched later.
193-
MISSING_FALLBACK: Final = FakeInfo("fallback can't be filled out until semanal")
194194
_dummy_fallback: Final = Instance(MISSING_FALLBACK, [], -1)
195195

196196
TYPE_IGNORE_PATTERN: Final = re.compile(r"[^#]*#\s*type:\s*ignore\s*(.*)")
@@ -2106,6 +2106,22 @@ def visit_Tuple(self, n: ast3.Tuple) -> Type:
21062106
column=self.convert_column(n.col_offset),
21072107
)
21082108

2109+
def visit_Dict(self, n: ast3.Dict) -> Type:
2110+
if not n.keys:
2111+
return self.invalid_type(n)
2112+
items: dict[str, Type] = {}
2113+
extra_items_from = []
2114+
for item_name, value in zip(n.keys, n.values):
2115+
if not isinstance(item_name, ast3.Constant) or not isinstance(item_name.value, str):
2116+
if item_name is None:
2117+
extra_items_from.append(self.visit(value))
2118+
continue
2119+
return self.invalid_type(n)
2120+
items[item_name.value] = self.visit(value)
2121+
result = TypedDictType(items, set(), _dummy_fallback, n.lineno, n.col_offset)
2122+
result.extra_items_from = extra_items_from
2123+
return result
2124+
21092125
# Attribute(expr value, identifier attr, expr_context ctx)
21102126
def visit_Attribute(self, n: Attribute) -> Type:
21112127
before_dot = self.visit(n.value)

mypy/message_registry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ def with_additional_msg(self, info: str) -> ErrorMessage:
138138
TYPEDDICT_KEY_MUST_BE_STRING_LITERAL: Final = ErrorMessage(
139139
"Expected TypedDict key to be string literal"
140140
)
141+
TYPEDDICT_OVERRIDE_MERGE: Final = 'Overwriting TypedDict field "{}" while merging'
141142
MALFORMED_ASSERT: Final = ErrorMessage("Assertion is always true, perhaps remove parentheses?")
142143
DUPLICATE_TYPE_SIGNATURES: Final = ErrorMessage("Function has duplicate type signatures")
143144
DESCRIPTOR_SET_NOT_CALLABLE: Final = ErrorMessage("{}.__set__ is not callable")

mypy/nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3480,6 +3480,7 @@ def __getattribute__(self, attr: str) -> type:
34803480
VAR_NO_INFO: Final[TypeInfo] = FakeInfo("Var is lacking info")
34813481
CLASSDEF_NO_INFO: Final[TypeInfo] = FakeInfo("ClassDef is lacking info")
34823482
FUNC_NO_INFO: Final[TypeInfo] = FakeInfo("FuncBase for non-methods lack info")
3483+
MISSING_FALLBACK: Final = FakeInfo("fallback can't be filled out until semanal")
34833484

34843485

34853486
class TypeAlias(SymbolNode):

mypy/options.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ class BuildType:
7474
UNPACK: Final = "Unpack"
7575
PRECISE_TUPLE_TYPES: Final = "PreciseTupleTypes"
7676
NEW_GENERIC_SYNTAX: Final = "NewGenericSyntax"
77-
INCOMPLETE_FEATURES: Final = frozenset((PRECISE_TUPLE_TYPES, NEW_GENERIC_SYNTAX))
77+
INLINE_TYPEDDICT: Final = "InlineTypedDict"
78+
INCOMPLETE_FEATURES: Final = frozenset((PRECISE_TUPLE_TYPES, NEW_GENERIC_SYNTAX, INLINE_TYPEDDICT))
7879
COMPLETE_FEATURES: Final = frozenset((TYPE_VAR_TUPLE, UNPACK))
7980

8081

mypy/semanal_typeddict.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from mypy.errorcodes import ErrorCode
99
from mypy.expandtype import expand_type
1010
from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type
11+
from mypy.message_registry import TYPEDDICT_OVERRIDE_MERGE
1112
from mypy.messages import MessageBuilder
1213
from mypy.nodes import (
1314
ARG_NAMED,
@@ -216,7 +217,7 @@ def add_keys_and_types_from_base(
216217
valid_items = self.map_items_to_base(valid_items, tvars, base_args)
217218
for key in base_items:
218219
if key in keys:
219-
self.fail(f'Overwriting TypedDict field "{key}" while merging', ctx)
220+
self.fail(TYPEDDICT_OVERRIDE_MERGE.format(key), ctx)
220221
keys.extend(valid_items.keys())
221222
types.extend(valid_items.values())
222223
required_keys.update(base_typed_dict.required_keys)
@@ -507,17 +508,7 @@ def parse_typeddict_fields_with_types(
507508
field_type_expr, self.options, self.api.is_stub_file
508509
)
509510
except TypeTranslationError:
510-
if (
511-
isinstance(field_type_expr, CallExpr)
512-
and isinstance(field_type_expr.callee, RefExpr)
513-
and field_type_expr.callee.fullname in TPDICT_NAMES
514-
):
515-
self.fail_typeddict_arg(
516-
"Inline TypedDict types not supported; use assignment to define TypedDict",
517-
field_type_expr,
518-
)
519-
else:
520-
self.fail_typeddict_arg("Invalid field type", field_type_expr)
511+
self.fail_typeddict_arg("Use dict literal for nested TypedDict", field_type_expr)
521512
return [], [], False
522513
analyzed = self.api.anal_type(
523514
type,

mypy/typeanal.py

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from mypy import errorcodes as codes, message_registry, nodes
1111
from mypy.errorcodes import ErrorCode
1212
from mypy.expandtype import expand_type
13-
from mypy.message_registry import INVALID_PARAM_SPEC_LOCATION, INVALID_PARAM_SPEC_LOCATION_NOTE
13+
from mypy.message_registry import (
14+
INVALID_PARAM_SPEC_LOCATION,
15+
INVALID_PARAM_SPEC_LOCATION_NOTE,
16+
TYPEDDICT_OVERRIDE_MERGE,
17+
)
1418
from mypy.messages import (
1519
MessageBuilder,
1620
format_type,
@@ -25,6 +29,7 @@
2529
ARG_POS,
2630
ARG_STAR,
2731
ARG_STAR2,
32+
MISSING_FALLBACK,
2833
SYMBOL_FUNCBASE_TYPES,
2934
ArgKind,
3035
Context,
@@ -43,7 +48,7 @@
4348
check_arg_names,
4449
get_nongen_builtins,
4550
)
46-
from mypy.options import Options
51+
from mypy.options import INLINE_TYPEDDICT, Options
4752
from mypy.plugin import AnalyzeTypeContext, Plugin, TypeAnalyzerPluginInterface
4853
from mypy.semanal_shared import (
4954
SemanticAnalyzerCoreInterface,
@@ -1220,10 +1225,45 @@ def visit_tuple_type(self, t: TupleType) -> Type:
12201225
return TupleType(self.anal_array(t.items, allow_unpack=True), fallback, t.line)
12211226

12221227
def visit_typeddict_type(self, t: TypedDictType) -> Type:
1223-
items = {
1224-
item_name: self.anal_type(item_type) for (item_name, item_type) in t.items.items()
1225-
}
1226-
return TypedDictType(items, set(t.required_keys), t.fallback)
1228+
req_keys = set()
1229+
items = {}
1230+
for item_name, item_type in t.items.items():
1231+
analyzed = self.anal_type(item_type, allow_required=True)
1232+
if isinstance(analyzed, RequiredType):
1233+
if analyzed.required:
1234+
req_keys.add(item_name)
1235+
analyzed = analyzed.item
1236+
else:
1237+
# Keys are required by default.
1238+
req_keys.add(item_name)
1239+
items[item_name] = analyzed
1240+
if t.fallback.type is MISSING_FALLBACK: # anonymous/inline TypedDict
1241+
if INLINE_TYPEDDICT not in self.options.enable_incomplete_feature:
1242+
self.fail(
1243+
"Inline TypedDict is experimental,"
1244+
" must be enabled with --enable-incomplete-feature=InlineTypedDict",
1245+
t,
1246+
)
1247+
required_keys = req_keys
1248+
fallback = self.named_type("typing._TypedDict")
1249+
for typ in t.extra_items_from:
1250+
analyzed = self.analyze_type(typ)
1251+
p_analyzed = get_proper_type(analyzed)
1252+
if not isinstance(p_analyzed, TypedDictType):
1253+
if not isinstance(p_analyzed, (AnyType, PlaceholderType)):
1254+
self.fail("Can only merge-in other TypedDict", t, code=codes.VALID_TYPE)
1255+
continue
1256+
for sub_item_name, sub_item_type in p_analyzed.items.items():
1257+
if sub_item_name in items:
1258+
self.fail(TYPEDDICT_OVERRIDE_MERGE.format(sub_item_name), t)
1259+
continue
1260+
items[sub_item_name] = sub_item_type
1261+
if sub_item_name in p_analyzed.required_keys:
1262+
req_keys.add(sub_item_name)
1263+
else:
1264+
required_keys = t.required_keys
1265+
fallback = t.fallback
1266+
return TypedDictType(items, required_keys, fallback, t.line, t.column)
12271267

12281268
def visit_raw_expression_type(self, t: RawExpressionType) -> Type:
12291269
# We should never see a bare Literal. We synthesize these raw literals
@@ -1761,11 +1801,12 @@ def anal_type(
17611801
allow_param_spec: bool = False,
17621802
allow_unpack: bool = False,
17631803
allow_ellipsis: bool = False,
1804+
allow_required: bool = False,
17641805
) -> Type:
17651806
if nested:
17661807
self.nesting_level += 1
17671808
old_allow_required = self.allow_required
1768-
self.allow_required = False
1809+
self.allow_required = allow_required
17691810
old_allow_ellipsis = self.allow_ellipsis
17701811
self.allow_ellipsis = allow_ellipsis
17711812
old_allow_unpack = self.allow_unpack

0 commit comments

Comments
 (0)
0