Skip to content

Commit d710fdd

Browse files
authored
stubgen: Support TypedDict alternative syntax (#14682)
Fixes #14681
1 parent 541639e commit d710fdd

File tree

2 files changed

+201
-0
lines changed

2 files changed

+201
-0
lines changed

mypy/stubgen.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343

4444
import argparse
4545
import glob
46+
import keyword
4647
import os
4748
import os.path
4849
import sys
@@ -80,6 +81,7 @@
8081
ClassDef,
8182
ComparisonExpr,
8283
Decorator,
84+
DictExpr,
8385
EllipsisExpr,
8486
Expression,
8587
FloatExpr,
@@ -126,6 +128,7 @@
126128
from mypy.traverser import all_yield_expressions, has_return_statement, has_yield_expression
127129
from mypy.types import (
128130
OVERLOAD_NAMES,
131+
TPDICT_NAMES,
129132
AnyType,
130133
CallableType,
131134
Instance,
@@ -405,6 +408,14 @@ def visit_tuple_expr(self, node: TupleExpr) -> str:
405408
def visit_list_expr(self, node: ListExpr) -> str:
406409
return f"[{', '.join(n.accept(self) for n in node.items)}]"
407410

411+
def visit_dict_expr(self, o: DictExpr) -> str:
412+
dict_items = []
413+
for key, value in o.items:
414+
# This is currently only used for TypedDict where all keys are strings.
415+
assert isinstance(key, StrExpr)
416+
dict_items.append(f"{key.accept(self)}: {value.accept(self)}")
417+
return f"{{{', '.join(dict_items)}}}"
418+
408419
def visit_ellipsis(self, node: EllipsisExpr) -> str:
409420
return "..."
410421

@@ -641,6 +652,7 @@ def visit_mypy_file(self, o: MypyFile) -> None:
641652
"_typeshed": ["Incomplete"],
642653
"typing": ["Any", "TypeVar"],
643654
"collections.abc": ["Generator"],
655+
"typing_extensions": ["TypedDict"],
644656
}
645657
for pkg, imports in known_imports.items():
646658
for t in imports:
@@ -1014,6 +1026,13 @@ def visit_assignment_stmt(self, o: AssignmentStmt) -> None:
10141026
assert isinstance(o.rvalue, CallExpr)
10151027
self.process_namedtuple(lvalue, o.rvalue)
10161028
continue
1029+
if (
1030+
isinstance(lvalue, NameExpr)
1031+
and isinstance(o.rvalue, CallExpr)
1032+
and self.is_typeddict(o.rvalue)
1033+
):
1034+
self.process_typeddict(lvalue, o.rvalue)
1035+
continue
10171036
if (
10181037
isinstance(lvalue, NameExpr)
10191038
and not self.is_private_name(lvalue.name)
@@ -1082,6 +1101,75 @@ def process_namedtuple(self, lvalue: NameExpr, rvalue: CallExpr) -> None:
10821101
self.add(f"{self._indent} {item}: Incomplete\n")
10831102
self._state = CLASS
10841103

1104+
def is_typeddict(self, expr: CallExpr) -> bool:
1105+
callee = expr.callee
1106+
return (
1107+
isinstance(callee, NameExpr) and self.refers_to_fullname(callee.name, TPDICT_NAMES)
1108+
) or (
1109+
isinstance(callee, MemberExpr)
1110+
and isinstance(callee.expr, NameExpr)
1111+
and f"{callee.expr.name}.{callee.name}" in TPDICT_NAMES
1112+
)
1113+
1114+
def process_typeddict(self, lvalue: NameExpr, rvalue: CallExpr) -> None:
1115+
if self._state != EMPTY:
1116+
self.add("\n")
1117+
1118+
if not isinstance(rvalue.args[0], StrExpr):
1119+
self.add(f"{self._indent}{lvalue.name}: Incomplete")
1120+
self.import_tracker.require_name("Incomplete")
1121+
return
1122+
1123+
items: list[tuple[str, Expression]] = []
1124+
total: Expression | None = None
1125+
if len(rvalue.args) > 1 and rvalue.arg_kinds[1] == ARG_POS:
1126+
if not isinstance(rvalue.args[1], DictExpr):
1127+
self.add(f"{self._indent}{lvalue.name}: Incomplete")
1128+
self.import_tracker.require_name("Incomplete")
1129+
return
1130+
for attr_name, attr_type in rvalue.args[1].items:
1131+
if not isinstance(attr_name, StrExpr):
1132+
self.add(f"{self._indent}{lvalue.name}: Incomplete")
1133+
self.import_tracker.require_name("Incomplete")
1134+
return
1135+
items.append((attr_name.value, attr_type))
1136+
if len(rvalue.args) > 2:
1137+
if rvalue.arg_kinds[2] != ARG_NAMED or rvalue.arg_names[2] != "total":
1138+
self.add(f"{self._indent}{lvalue.name}: Incomplete")
1139+
self.import_tracker.require_name("Incomplete")
1140+
return
1141+
total = rvalue.args[2]
1142+
else:
1143+
for arg_name, arg in zip(rvalue.arg_names[1:], rvalue.args[1:]):
1144+
if not isinstance(arg_name, str):
1145+
self.add(f"{self._indent}{lvalue.name}: Incomplete")
1146+
self.import_tracker.require_name("Incomplete")
1147+
return
1148+
if arg_name == "total":
1149+
total = arg
1150+
else:
1151+
items.append((arg_name, arg))
1152+
self.import_tracker.require_name("TypedDict")
1153+
p = AliasPrinter(self)
1154+
if any(not key.isidentifier() or keyword.iskeyword(key) for key, _ in items):
1155+
# Keep the call syntax if there are non-identifier or keyword keys.
1156+
self.add(f"{self._indent}{lvalue.name} = {rvalue.accept(p)}\n")
1157+
self._state = VAR
1158+
else:
1159+
bases = "TypedDict"
1160+
# TODO: Add support for generic TypedDicts. Requires `Generic` as base class.
1161+
if total is not None:
1162+
bases += f", total={total.accept(p)}"
1163+
self.add(f"{self._indent}class {lvalue.name}({bases}):")
1164+
if len(items) == 0:
1165+
self.add(" ...\n")
1166+
self._state = EMPTY_CLASS
1167+
else:
1168+
self.add("\n")
1169+
for key, key_type in items:
1170+
self.add(f"{self._indent} {key}: {key_type.accept(p)}\n")
1171+
self._state = CLASS
1172+
10851173
def is_alias_expression(self, expr: Expression, top_level: bool = True) -> bool:
10861174
"""Return True for things that look like target for an alias.
10871175

test-data/unit/stubgen.test

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2849,3 +2849,116 @@ def f(x: str | None) -> None: ...
28492849
a: str | int
28502850

28512851
def f(x: str | None) -> None: ...
2852+
2853+
[case testTypeddict]
2854+
import typing, x
2855+
X = typing.TypedDict('X', {'a': int, 'b': str})
2856+
Y = typing.TypedDict('X', {'a': int, 'b': str}, total=False)
2857+
[out]
2858+
from typing_extensions import TypedDict
2859+
2860+
class X(TypedDict):
2861+
a: int
2862+
b: str
2863+
2864+
class Y(TypedDict, total=False):
2865+
a: int
2866+
b: str
2867+
2868+
[case testTypeddictKeywordSyntax]
2869+
from typing import TypedDict
2870+
2871+
X = TypedDict('X', a=int, b=str)
2872+
Y = TypedDict('X', a=int, b=str, total=False)
2873+
[out]
2874+
from typing import TypedDict
2875+
2876+
class X(TypedDict):
2877+
a: int
2878+
b: str
2879+
2880+
class Y(TypedDict, total=False):
2881+
a: int
2882+
b: str
2883+
2884+
[case testTypeddictWithNonIdentifierOrKeywordKeys]
2885+
from typing import TypedDict
2886+
X = TypedDict('X', {'a-b': int, 'c': str})
2887+
Y = TypedDict('X', {'a-b': int, 'c': str}, total=False)
2888+
Z = TypedDict('X', {'a': int, 'in': str})
2889+
[out]
2890+
from typing import TypedDict
2891+
2892+
X = TypedDict('X', {'a-b': int, 'c': str})
2893+
2894+
Y = TypedDict('X', {'a-b': int, 'c': str}, total=False)
2895+
2896+
Z = TypedDict('X', {'a': int, 'in': str})
2897+
2898+
[case testEmptyTypeddict]
2899+
import typing
2900+
X = typing.TypedDict('X', {})
2901+
Y = typing.TypedDict('Y', {}, total=False)
2902+
Z = typing.TypedDict('Z')
2903+
W = typing.TypedDict('W', total=False)
2904+
[out]
2905+
from typing_extensions import TypedDict
2906+
2907+
class X(TypedDict): ...
2908+
2909+
class Y(TypedDict, total=False): ...
2910+
2911+
class Z(TypedDict): ...
2912+
2913+
class W(TypedDict, total=False): ...
2914+
2915+
[case testTypeddictAliased]
2916+
from typing import TypedDict as t_TypedDict
2917+
from typing_extensions import TypedDict as te_TypedDict
2918+
def f(): ...
2919+
X = t_TypedDict('X', {'a': int, 'b': str})
2920+
Y = te_TypedDict('Y', {'a': int, 'b': str})
2921+
def g(): ...
2922+
[out]
2923+
from typing_extensions import TypedDict
2924+
2925+
def f() -> None: ...
2926+
2927+
class X(TypedDict):
2928+
a: int
2929+
b: str
2930+
2931+
class Y(TypedDict):
2932+
a: int
2933+
b: str
2934+
2935+
def g() -> None: ...
2936+
2937+
[case testNotTypeddict]
2938+
from x import TypedDict
2939+
import y
2940+
X = TypedDict('X', {'a': int, 'b': str})
2941+
Y = y.TypedDict('Y', {'a': int, 'b': str})
2942+
[out]
2943+
from _typeshed import Incomplete
2944+
2945+
X: Incomplete
2946+
Y: Incomplete
2947+
2948+
[case testTypeddictWithWrongAttributesType]
2949+
from typing import TypedDict
2950+
R = TypedDict("R", {"a": int, **{"b": str, "c": bytes}})
2951+
S = TypedDict("S", [("b", str), ("c", bytes)])
2952+
T = TypedDict("T", {"a": int}, b=str, total=False)
2953+
U = TypedDict("U", {"a": int}, totale=False)
2954+
V = TypedDict("V", {"a": int}, {"b": str})
2955+
W = TypedDict("W", **{"a": int, "b": str})
2956+
[out]
2957+
from _typeshed import Incomplete
2958+
2959+
R: Incomplete
2960+
S: Incomplete
2961+
T: Incomplete
2962+
U: Incomplete
2963+
V: Incomplete
2964+
W: Incomplete

0 commit comments

Comments
 (0)