Skip to content

Commit 88ceec0

Browse files
ilevkivskyiJukkaL
authored andcommitted
Implement class syntax for TypedDict (#2808)
* Class syntax for TypedDict * Add tests; fix minor points * Fix tests; formatting * Prohibit overwriting fields on merging/extending * Response to review comments
1 parent 33f3ba2 commit 88ceec0

File tree

2 files changed

+242
-0
lines changed

2 files changed

+242
-0
lines changed

mypy/semanal.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,8 @@ def check_function_signature(self, fdef: FuncItem) -> None:
564564

565565
def visit_class_def(self, defn: ClassDef) -> None:
566566
self.clean_up_bases_and_infer_type_variables(defn)
567+
if self.analyze_typeddict_classdef(defn):
568+
return
567569
if self.analyze_namedtuple_classdef(defn):
568570
return
569571
self.setup_class_def_analysis(defn)
@@ -1009,6 +1011,101 @@ def bind_class_type_variables_in_symbol_table(
10091011
nodes.append(node)
10101012
return nodes
10111013

1014+
def is_typeddict(self, expr: Expression) -> bool:
1015+
return (isinstance(expr, RefExpr) and isinstance(expr.node, TypeInfo) and
1016+
expr.node.typeddict_type is not None)
1017+
1018+
def analyze_typeddict_classdef(self, defn: ClassDef) -> bool:
1019+
# special case for TypedDict
1020+
possible = False
1021+
for base_expr in defn.base_type_exprs:
1022+
if isinstance(base_expr, RefExpr):
1023+
base_expr.accept(self)
1024+
if (base_expr.fullname == 'mypy_extensions.TypedDict' or
1025+
self.is_typeddict(base_expr)):
1026+
possible = True
1027+
if possible:
1028+
node = self.lookup(defn.name, defn)
1029+
if node is not None:
1030+
node.kind = GDEF # TODO in process_namedtuple_definition also applies here
1031+
if (len(defn.base_type_exprs) == 1 and
1032+
isinstance(defn.base_type_exprs[0], RefExpr) and
1033+
defn.base_type_exprs[0].fullname == 'mypy_extensions.TypedDict'):
1034+
# Building a new TypedDict
1035+
fields, types = self.check_typeddict_classdef(defn)
1036+
node.node = self.build_typeddict_typeinfo(defn.name, fields, types)
1037+
return True
1038+
# Extending/merging existing TypedDicts
1039+
if any(not isinstance(expr, RefExpr) or
1040+
expr.fullname != 'mypy_extensions.TypedDict' and
1041+
not self.is_typeddict(expr) for expr in defn.base_type_exprs):
1042+
self.fail("All bases of a new TypedDict must be TypedDict types", defn)
1043+
typeddict_bases = list(filter(self.is_typeddict, defn.base_type_exprs))
1044+
newfields = [] # type: List[str]
1045+
newtypes = [] # type: List[Type]
1046+
tpdict = None # type: OrderedDict[str, Type]
1047+
for base in typeddict_bases:
1048+
assert isinstance(base, RefExpr)
1049+
assert isinstance(base.node, TypeInfo)
1050+
assert isinstance(base.node.typeddict_type, TypedDictType)
1051+
tpdict = base.node.typeddict_type.items
1052+
newdict = tpdict.copy()
1053+
for key in tpdict:
1054+
if key in newfields:
1055+
self.fail('Cannot overwrite TypedDict field "{}" while merging'
1056+
.format(key), defn)
1057+
newdict.pop(key)
1058+
newfields.extend(newdict.keys())
1059+
newtypes.extend(newdict.values())
1060+
fields, types = self.check_typeddict_classdef(defn, newfields)
1061+
newfields.extend(fields)
1062+
newtypes.extend(types)
1063+
node.node = self.build_typeddict_typeinfo(defn.name, newfields, newtypes)
1064+
return True
1065+
return False
1066+
1067+
def check_typeddict_classdef(self, defn: ClassDef,
1068+
oldfields: List[str] = None) -> Tuple[List[str], List[Type]]:
1069+
TPDICT_CLASS_ERROR = ('Invalid statement in TypedDict definition; '
1070+
'expected "field_name: field_type"')
1071+
if self.options.python_version < (3, 6):
1072+
self.fail('TypedDict class syntax is only supported in Python 3.6', defn)
1073+
return [], []
1074+
fields = [] # type: List[str]
1075+
types = [] # type: List[Type]
1076+
for stmt in defn.defs.body:
1077+
if not isinstance(stmt, AssignmentStmt):
1078+
# Still allow pass or ... (for empty TypedDict's).
1079+
if (not isinstance(stmt, PassStmt) and
1080+
not (isinstance(stmt, ExpressionStmt) and
1081+
isinstance(stmt.expr, EllipsisExpr))):
1082+
self.fail(TPDICT_CLASS_ERROR, stmt)
1083+
elif len(stmt.lvalues) > 1 or not isinstance(stmt.lvalues[0], NameExpr):
1084+
# An assignment, but an invalid one.
1085+
self.fail(TPDICT_CLASS_ERROR, stmt)
1086+
else:
1087+
name = stmt.lvalues[0].name
1088+
if name in (oldfields or []):
1089+
self.fail('Cannot overwrite TypedDict field "{}" while extending'
1090+
.format(name), stmt)
1091+
continue
1092+
if name in fields:
1093+
self.fail('Duplicate TypedDict field "{}"'.format(name), stmt)
1094+
continue
1095+
# Append name and type in this case...
1096+
fields.append(name)
1097+
types.append(AnyType() if stmt.type is None else self.anal_type(stmt.type))
1098+
# ...despite possible minor failures that allow further analyzis.
1099+
if name.startswith('_'):
1100+
self.fail('TypedDict field name cannot start with an underscore: {}'
1101+
.format(name), stmt)
1102+
if stmt.type is None or hasattr(stmt, 'new_syntax') and not stmt.new_syntax:
1103+
self.fail(TPDICT_CLASS_ERROR, stmt)
1104+
elif not isinstance(stmt.rvalue, TempNode):
1105+
# x: int assigns rvalue to TempNode(AnyType())
1106+
self.fail('Right hand side values are not supported in TypedDict', stmt)
1107+
return fields, types
1108+
10121109
def visit_import(self, i: Import) -> None:
10131110
for id, as_id in i.ids:
10141111
if as_id is not None:

test-data/unit/check-typeddict.test

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,151 @@ p = Point(x='meaning_of_life', y=1337) # E: Incompatible types (expression has
6363
[builtins fixtures/dict.pyi]
6464

6565

66+
-- Define TypedDict (Class syntax)
67+
68+
[case testCanCreateTypedDictWithClass]
69+
# flags: --python-version 3.6
70+
from mypy_extensions import TypedDict
71+
72+
class Point(TypedDict):
73+
x: int
74+
y: int
75+
76+
p = Point(x=42, y=1337)
77+
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=typing.Mapping[builtins.str, builtins.int])'
78+
[builtins fixtures/dict.pyi]
79+
80+
[case testCanCreateTypedDictWithSubclass]
81+
# flags: --python-version 3.6
82+
from mypy_extensions import TypedDict
83+
84+
class Point1D(TypedDict):
85+
x: int
86+
class Point2D(Point1D):
87+
y: int
88+
r: Point1D
89+
p: Point2D
90+
reveal_type(r) # E: Revealed type is 'TypedDict(x=builtins.int, _fallback=__main__.Point1D)'
91+
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=__main__.Point2D)'
92+
[builtins fixtures/dict.pyi]
93+
94+
[case testCanCreateTypedDictWithSubclass2]
95+
# flags: --python-version 3.6
96+
from mypy_extensions import TypedDict
97+
98+
class Point1D(TypedDict):
99+
x: int
100+
class Point2D(TypedDict, Point1D): # We also allow to include TypedDict in bases, it is simply ignored at runtime
101+
y: int
102+
103+
p: Point2D
104+
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=__main__.Point2D)'
105+
[builtins fixtures/dict.pyi]
106+
107+
[case testCanCreateTypedDictClassEmpty]
108+
# flags: --python-version 3.6
109+
from mypy_extensions import TypedDict
110+
111+
class EmptyDict(TypedDict):
112+
pass
113+
114+
p = EmptyDict()
115+
reveal_type(p) # E: Revealed type is 'TypedDict(_fallback=typing.Mapping[builtins.str, builtins.None])'
116+
[builtins fixtures/dict.pyi]
117+
118+
119+
-- Define TypedDict (Class syntax errors)
120+
121+
[case testCanCreateTypedDictWithClassOldVersion]
122+
# flags: --python-version 3.5
123+
from mypy_extensions import TypedDict
124+
125+
class Point(TypedDict): # E: TypedDict class syntax is only supported in Python 3.6
126+
pass
127+
[builtins fixtures/dict.pyi]
128+
129+
[case testCannotCreateTypedDictWithClassOtherBases]
130+
# flags: --python-version 3.6
131+
from mypy_extensions import TypedDict
132+
133+
class A: pass
134+
135+
class Point1D(TypedDict, A): # E: All bases of a new TypedDict must be TypedDict types
136+
x: int
137+
class Point2D(Point1D, A): # E: All bases of a new TypedDict must be TypedDict types
138+
y: int
139+
140+
p: Point2D
141+
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, _fallback=__main__.Point2D)'
142+
[builtins fixtures/dict.pyi]
143+
144+
[case testCannotCreateTypedDictWithClassWithOtherStuff]
145+
# flags: --python-version 3.6
146+
from mypy_extensions import TypedDict
147+
148+
class Point(TypedDict):
149+
x: int
150+
y: int = 1 # E: Right hand side values are not supported in TypedDict
151+
def f(): pass # E: Invalid statement in TypedDict definition; expected "field_name: field_type"
152+
z = int # E: Invalid statement in TypedDict definition; expected "field_name: field_type"
153+
154+
p = Point(x=42, y=1337, z='whatever')
155+
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, y=builtins.int, z=builtins.str, _fallback=typing.Mapping[builtins.str, builtins.object])'
156+
[builtins fixtures/dict.pyi]
157+
158+
[case testCannotCreateTypedDictWithClassUnderscores]
159+
# flags: --python-version 3.6
160+
from mypy_extensions import TypedDict
161+
162+
class Point(TypedDict):
163+
x: int
164+
_y: int # E: TypedDict field name cannot start with an underscore: _y
165+
166+
p: Point
167+
reveal_type(p) # E: Revealed type is 'TypedDict(x=builtins.int, _y=builtins.int, _fallback=__main__.Point)'
168+
[builtins fixtures/dict.pyi]
169+
170+
[case testCannotCreateTypedDictWithClassOverwriting]
171+
# flags: --python-version 3.6
172+
from mypy_extensions import TypedDict
173+
174+
class Bad(TypedDict):
175+
x: int
176+
x: str # E: Duplicate TypedDict field "x"
177+
178+
b: Bad
179+
reveal_type(b) # E: Revealed type is 'TypedDict(x=builtins.int, _fallback=__main__.Bad)'
180+
[builtins fixtures/dict.pyi]
181+
182+
[case testCannotCreateTypedDictWithClassOverwriting2]
183+
# flags: --python-version 3.6
184+
from mypy_extensions import TypedDict
185+
186+
class Point1(TypedDict):
187+
x: int
188+
class Point2(TypedDict):
189+
x: float
190+
class Bad(Point1, Point2): # E: Cannot overwrite TypedDict field "x" while merging
191+
pass
192+
193+
b: Bad
194+
reveal_type(b) # E: Revealed type is 'TypedDict(x=builtins.int, _fallback=__main__.Bad)'
195+
[builtins fixtures/dict.pyi]
196+
197+
[case testCannotCreateTypedDictWithClassOverwriting2]
198+
# flags: --python-version 3.6
199+
from mypy_extensions import TypedDict
200+
201+
class Point1(TypedDict):
202+
x: int
203+
class Point2(Point1):
204+
x: float # E: Cannot overwrite TypedDict field "x" while extending
205+
206+
p2: Point2
207+
reveal_type(p2) # E: Revealed type is 'TypedDict(x=builtins.int, _fallback=__main__.Point2)'
208+
[builtins fixtures/dict.pyi]
209+
210+
66211
-- Subtyping
67212

68213
[case testCanConvertTypedDictToItself]

0 commit comments

Comments
 (0)