Skip to content

Commit abb0bbb

Browse files
PEP 655: Support Required[] inside TypedDict (#10370)
Adds support for the Required[] syntax inside TypedDicts as specified by PEP 655 (draft). NotRequired[] is also supported. Co-authored-by: 97littleleaf11 <[email protected]>
1 parent f1eb04a commit abb0bbb

File tree

7 files changed

+210
-9
lines changed

7 files changed

+210
-9
lines changed

mypy/semanal.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -5127,6 +5127,7 @@ def type_analyzer(self, *,
51275127
allow_tuple_literal: bool = False,
51285128
allow_unbound_tvars: bool = False,
51295129
allow_placeholder: bool = False,
5130+
allow_required: bool = False,
51305131
report_invalid_types: bool = True) -> TypeAnalyser:
51315132
if tvar_scope is None:
51325133
tvar_scope = self.tvar_scope
@@ -5138,8 +5139,9 @@ def type_analyzer(self, *,
51385139
allow_unbound_tvars=allow_unbound_tvars,
51395140
allow_tuple_literal=allow_tuple_literal,
51405141
report_invalid_types=report_invalid_types,
5141-
allow_new_syntax=self.is_stub_file,
5142-
allow_placeholder=allow_placeholder)
5142+
allow_placeholder=allow_placeholder,
5143+
allow_required=allow_required,
5144+
allow_new_syntax=self.is_stub_file)
51435145
tpan.in_dynamic_func = bool(self.function_stack and self.function_stack[-1].is_dynamic())
51445146
tpan.global_scope = not self.type and not self.function_stack
51455147
return tpan
@@ -5153,6 +5155,7 @@ def anal_type(self,
51535155
allow_tuple_literal: bool = False,
51545156
allow_unbound_tvars: bool = False,
51555157
allow_placeholder: bool = False,
5158+
allow_required: bool = False,
51565159
report_invalid_types: bool = True,
51575160
third_pass: bool = False) -> Optional[Type]:
51585161
"""Semantically analyze a type.
@@ -5179,6 +5182,7 @@ def anal_type(self,
51795182
allow_unbound_tvars=allow_unbound_tvars,
51805183
allow_tuple_literal=allow_tuple_literal,
51815184
allow_placeholder=allow_placeholder,
5185+
allow_required=allow_required,
51825186
report_invalid_types=report_invalid_types)
51835187
tag = self.track_incomplete_refs()
51845188
typ = typ.accept(a)

mypy/semanal_shared.py

+1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def anal_type(self, t: Type, *,
119119
tvar_scope: Optional[TypeVarLikeScope] = None,
120120
allow_tuple_literal: bool = False,
121121
allow_unbound_tvars: bool = False,
122+
allow_required: bool = False,
122123
report_invalid_types: bool = True) -> Optional[Type]:
123124
raise NotImplementedError
124125

mypy/semanal_typeddict.py

+36-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from typing import Optional, List, Set, Tuple
55
from typing_extensions import Final
66

7-
from mypy.types import Type, AnyType, TypeOfAny, TypedDictType, TPDICT_NAMES
7+
from mypy.types import (
8+
Type, AnyType, TypeOfAny, TypedDictType, TPDICT_NAMES, RequiredType,
9+
)
810
from mypy.nodes import (
911
CallExpr, TypedDictExpr, Expression, NameExpr, Context, StrExpr, BytesExpr, UnicodeExpr,
1012
ClassDef, RefExpr, TypeInfo, AssignmentStmt, PassStmt, ExpressionStmt, EllipsisExpr, TempNode,
@@ -161,7 +163,7 @@ def analyze_typeddict_classdef_fields(
161163
if stmt.type is None:
162164
types.append(AnyType(TypeOfAny.unannotated))
163165
else:
164-
analyzed = self.api.anal_type(stmt.type)
166+
analyzed = self.api.anal_type(stmt.type, allow_required=True)
165167
if analyzed is None:
166168
return None, [], set() # Need to defer
167169
types.append(analyzed)
@@ -177,7 +179,22 @@ def analyze_typeddict_classdef_fields(
177179
if total is None:
178180
self.fail('Value of "total" must be True or False', defn)
179181
total = True
180-
required_keys = set(fields) if total else set()
182+
required_keys = {
183+
field
184+
for (field, t) in zip(fields, types)
185+
if (total or (
186+
isinstance(t, RequiredType) and # type: ignore[misc]
187+
t.required
188+
)) and not (
189+
isinstance(t, RequiredType) and # type: ignore[misc]
190+
not t.required
191+
)
192+
}
193+
types = [ # unwrap Required[T] to just T
194+
t.item if isinstance(t, RequiredType) else t # type: ignore[misc]
195+
for t in types
196+
]
197+
181198
return fields, types, required_keys
182199

183200
def check_typeddict(self,
@@ -221,7 +238,21 @@ def check_typeddict(self,
221238
if name != var_name or is_func_scope:
222239
# Give it a unique name derived from the line number.
223240
name += '@' + str(call.line)
224-
required_keys = set(items) if total else set()
241+
required_keys = {
242+
field
243+
for (field, t) in zip(items, types)
244+
if (total or (
245+
isinstance(t, RequiredType) and # type: ignore[misc]
246+
t.required
247+
)) and not (
248+
isinstance(t, RequiredType) and # type: ignore[misc]
249+
not t.required
250+
)
251+
}
252+
types = [ # unwrap Required[T] to just T
253+
t.item if isinstance(t, RequiredType) else t # type: ignore[misc]
254+
for t in types
255+
]
225256
info = self.build_typeddict_typeinfo(name, items, types, required_keys, call.line)
226257
info.line = node.line
227258
# Store generated TypeInfo under both names, see semanal_namedtuple for more details.
@@ -316,7 +347,7 @@ def parse_typeddict_fields_with_types(
316347
else:
317348
self.fail_typeddict_arg('Invalid field type', field_type_expr)
318349
return [], [], False
319-
analyzed = self.api.anal_type(type)
350+
analyzed = self.api.anal_type(type, allow_required=True)
320351
if analyzed is None:
321352
return None
322353
types.append(analyzed)

mypy/typeanal.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
CallableType, NoneType, ErasedType, DeletedType, TypeList, TypeVarType, SyntheticTypeVisitor,
1717
StarType, PartialType, EllipsisType, UninhabitedType, TypeType, CallableArgument,
1818
TypeQuery, union_items, TypeOfAny, LiteralType, RawExpressionType,
19-
PlaceholderType, Overloaded, get_proper_type, TypeAliasType,
19+
PlaceholderType, Overloaded, get_proper_type, TypeAliasType, RequiredType,
2020
TypeVarLikeType, ParamSpecType, ParamSpecFlavor, callable_with_ellipsis
2121
)
2222

@@ -130,6 +130,7 @@ def __init__(self,
130130
allow_new_syntax: bool = False,
131131
allow_unbound_tvars: bool = False,
132132
allow_placeholder: bool = False,
133+
allow_required: bool = False,
133134
report_invalid_types: bool = True) -> None:
134135
self.api = api
135136
self.lookup_qualified = api.lookup_qualified
@@ -149,6 +150,8 @@ def __init__(self,
149150
self.allow_unbound_tvars = allow_unbound_tvars or defining_alias
150151
# If false, record incomplete ref if we generate PlaceholderType.
151152
self.allow_placeholder = allow_placeholder
153+
# Are we in a context where Required[] is allowed?
154+
self.allow_required = allow_required
152155
# Should we report an error whenever we encounter a RawExpressionType outside
153156
# of a Literal context: e.g. whenever we encounter an invalid type? Normally,
154157
# we want to report an error, but the caller may want to do more specialized
@@ -357,6 +360,22 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Opt
357360
" and at least one annotation", t)
358361
return AnyType(TypeOfAny.from_error)
359362
return self.anal_type(t.args[0])
363+
elif fullname in ('typing_extensions.Required', 'typing.Required'):
364+
if not self.allow_required:
365+
self.fail("Required[] can be only used in a TypedDict definition", t)
366+
return AnyType(TypeOfAny.from_error)
367+
if len(t.args) != 1:
368+
self.fail("Required[] must have exactly one type argument", t)
369+
return AnyType(TypeOfAny.from_error)
370+
return RequiredType(self.anal_type(t.args[0]), required=True)
371+
elif fullname in ('typing_extensions.NotRequired', 'typing.NotRequired'):
372+
if not self.allow_required:
373+
self.fail("NotRequired[] can be only used in a TypedDict definition", t)
374+
return AnyType(TypeOfAny.from_error)
375+
if len(t.args) != 1:
376+
self.fail("NotRequired[] must have exactly one type argument", t)
377+
return AnyType(TypeOfAny.from_error)
378+
return RequiredType(self.anal_type(t.args[0]), required=False)
360379
elif self.anal_type_guard_arg(t, fullname) is not None:
361380
# In most contexts, TypeGuard[...] acts as an alias for bool (ignoring its args)
362381
return self.named_type('builtins.bool')
@@ -995,11 +1014,14 @@ def anal_array(self,
9951014
def anal_type(self, t: Type, nested: bool = True, *, allow_param_spec: bool = False) -> Type:
9961015
if nested:
9971016
self.nesting_level += 1
1017+
old_allow_required = self.allow_required
1018+
self.allow_required = False
9981019
try:
9991020
analyzed = t.accept(self)
10001021
finally:
10011022
if nested:
10021023
self.nesting_level -= 1
1024+
self.allow_required = old_allow_required
10031025
if (not allow_param_spec
10041026
and isinstance(analyzed, ParamSpecType)
10051027
and analyzed.flavor == ParamSpecFlavor.BARE):

mypy/types.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ def copy_modified(self, *,
289289

290290

291291
class TypeGuardedType(Type):
292-
"""Only used by find_instance_check() etc."""
292+
"""Only used by find_isinstance_check() etc."""
293293

294294
__slots__ = ('type_guard',)
295295

@@ -301,6 +301,21 @@ def __repr__(self) -> str:
301301
return "TypeGuard({})".format(self.type_guard)
302302

303303

304+
class RequiredType(Type):
305+
"""Required[T] or NotRequired[T]. Only usable at top-level of a TypedDict definition."""
306+
307+
def __init__(self, item: Type, *, required: bool) -> None:
308+
super().__init__(line=item.line, column=item.column)
309+
self.item = item
310+
self.required = required
311+
312+
def __repr__(self) -> str:
313+
if self.required:
314+
return "Required[{}]".format(self.item)
315+
else:
316+
return "NotRequired[{}]".format(self.item)
317+
318+
304319
class ProperType(Type):
305320
"""Not a type alias.
306321

test-data/unit/check-typeddict.test

+126
Original file line numberDiff line numberDiff line change
@@ -2146,6 +2146,132 @@ Foo = TypedDict('Foo', {'camelCaseKey': str})
21462146
value: Foo = {} # E: Missing key "camelCaseKey" for TypedDict "Foo"
21472147
[builtins fixtures/dict.pyi]
21482148

2149+
-- Required[]
2150+
2151+
[case testDoesRecognizeRequiredInTypedDictWithClass]
2152+
from typing import TypedDict
2153+
from typing import Required
2154+
class Movie(TypedDict, total=False):
2155+
title: Required[str]
2156+
year: int
2157+
m = Movie(title='The Matrix')
2158+
m = Movie() # E: Missing key "title" for TypedDict "Movie"
2159+
[typing fixtures/typing-typeddict.pyi]
2160+
2161+
[case testDoesRecognizeRequiredInTypedDictWithAssignment]
2162+
from typing import TypedDict
2163+
from typing import Required
2164+
Movie = TypedDict('Movie', {
2165+
'title': Required[str],
2166+
'year': int,
2167+
}, total=False)
2168+
m = Movie(title='The Matrix')
2169+
m = Movie() # E: Missing key "title" for TypedDict "Movie"
2170+
[typing fixtures/typing-typeddict.pyi]
2171+
2172+
[case testDoesDisallowRequiredOutsideOfTypedDict]
2173+
from typing import Required
2174+
x: Required[int] = 42 # E: Required[] can be only used in a TypedDict definition
2175+
[typing fixtures/typing-typeddict.pyi]
2176+
2177+
[case testDoesOnlyAllowRequiredInsideTypedDictAtTopLevel]
2178+
from typing import TypedDict
2179+
from typing import Union
2180+
from typing import Required
2181+
Movie = TypedDict('Movie', {
2182+
'title': Union[
2183+
Required[str], # E: Required[] can be only used in a TypedDict definition
2184+
bytes
2185+
],
2186+
'year': int,
2187+
}, total=False)
2188+
[typing fixtures/typing-typeddict.pyi]
2189+
2190+
[case testDoesDisallowRequiredInsideRequired]
2191+
from typing import TypedDict
2192+
from typing import Union
2193+
from typing import Required
2194+
Movie = TypedDict('Movie', {
2195+
'title': Required[Union[
2196+
Required[str], # E: Required[] can be only used in a TypedDict definition
2197+
bytes
2198+
]],
2199+
'year': int,
2200+
}, total=False)
2201+
[typing fixtures/typing-typeddict.pyi]
2202+
2203+
[case testRequiredOnlyAllowsOneItem]
2204+
from typing import TypedDict
2205+
from typing import Required
2206+
class Movie(TypedDict, total=False):
2207+
title: Required[str, bytes] # E: Required[] must have exactly one type argument
2208+
year: int
2209+
[typing fixtures/typing-typeddict.pyi]
2210+
2211+
2212+
-- NotRequired[]
2213+
2214+
[case testDoesRecognizeNotRequiredInTypedDictWithClass]
2215+
from typing import TypedDict
2216+
from typing import NotRequired
2217+
class Movie(TypedDict):
2218+
title: str
2219+
year: NotRequired[int]
2220+
m = Movie(title='The Matrix')
2221+
m = Movie() # E: Missing key "title" for TypedDict "Movie"
2222+
[typing fixtures/typing-typeddict.pyi]
2223+
2224+
[case testDoesRecognizeNotRequiredInTypedDictWithAssignment]
2225+
from typing import TypedDict
2226+
from typing import NotRequired
2227+
Movie = TypedDict('Movie', {
2228+
'title': str,
2229+
'year': NotRequired[int],
2230+
})
2231+
m = Movie(title='The Matrix')
2232+
m = Movie() # E: Missing key "title" for TypedDict "Movie"
2233+
[typing fixtures/typing-typeddict.pyi]
2234+
2235+
[case testDoesDisallowNotRequiredOutsideOfTypedDict]
2236+
from typing import NotRequired
2237+
x: NotRequired[int] = 42 # E: NotRequired[] can be only used in a TypedDict definition
2238+
[typing fixtures/typing-typeddict.pyi]
2239+
2240+
[case testDoesOnlyAllowNotRequiredInsideTypedDictAtTopLevel]
2241+
from typing import TypedDict
2242+
from typing import Union
2243+
from typing import NotRequired
2244+
Movie = TypedDict('Movie', {
2245+
'title': Union[
2246+
NotRequired[str], # E: NotRequired[] can be only used in a TypedDict definition
2247+
bytes
2248+
],
2249+
'year': int,
2250+
})
2251+
[typing fixtures/typing-typeddict.pyi]
2252+
2253+
[case testDoesDisallowNotRequiredInsideNotRequired]
2254+
from typing import TypedDict
2255+
from typing import Union
2256+
from typing import NotRequired
2257+
Movie = TypedDict('Movie', {
2258+
'title': NotRequired[Union[
2259+
NotRequired[str], # E: NotRequired[] can be only used in a TypedDict definition
2260+
bytes
2261+
]],
2262+
'year': int,
2263+
})
2264+
[typing fixtures/typing-typeddict.pyi]
2265+
2266+
[case testNotRequiredOnlyAllowsOneItem]
2267+
from typing import TypedDict
2268+
from typing import NotRequired
2269+
class Movie(TypedDict):
2270+
title: NotRequired[str, bytes] # E: NotRequired[] must have exactly one type argument
2271+
year: int
2272+
[typing fixtures/typing-typeddict.pyi]
2273+
2274+
-- Union dunders
21492275

21502276
[case testTypedDictUnionGetItem]
21512277
from typing import TypedDict, Union

test-data/unit/fixtures/typing-typeddict.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Final = 0
2323
Literal = 0
2424
TypedDict = 0
2525
NoReturn = 0
26+
Required = 0
27+
NotRequired = 0
2628

2729
T = TypeVar('T')
2830
T_co = TypeVar('T_co', covariant=True)

0 commit comments

Comments
 (0)