gh-91243: Add typing.Required and NotRequired (PEP 655) (GH-32419)

I talked to @davidfstr and I offered to implement the runtime part of PEP 655
to make sure we can get it in before the feature freeze. We're going to defer
the documentation to a separate PR, because it can wait until after the feature
freeze.

The runtime implementation conveniently already exists in typing-extensions,
so I largely copied that.

Co-authored-by: David Foster <david@dafoster.net>
This commit is contained in:
Jelle Zijlstra 2022-04-12 12:31:02 -07:00 committed by GitHub
parent 474fdbe9e4
commit ac6c3de03c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 241 additions and 6 deletions

View file

@ -6,13 +6,19 @@ look something like this:
class Bar(_typed_dict_helper.Foo, total=False): class Bar(_typed_dict_helper.Foo, total=False):
b: int b: int
In addition, it uses multiple levels of Annotated to test the interaction
between the __future__ import, Annotated, and Required.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional, TypedDict from typing import Annotated, Optional, Required, TypedDict
OptionalIntType = Optional[int] OptionalIntType = Optional[int]
class Foo(TypedDict): class Foo(TypedDict):
a: OptionalIntType a: OptionalIntType
class VeryAnnotated(TypedDict, total=False):
a: Annotated[Annotated[Annotated[Required[int], "a"], "b"], "c"]

View file

@ -23,7 +23,7 @@ from typing import is_typeddict
from typing import reveal_type from typing import reveal_type
from typing import no_type_check, no_type_check_decorator from typing import no_type_check, no_type_check_decorator
from typing import Type from typing import Type
from typing import NamedTuple, TypedDict from typing import NamedTuple, NotRequired, Required, TypedDict
from typing import IO, TextIO, BinaryIO from typing import IO, TextIO, BinaryIO
from typing import Pattern, Match from typing import Pattern, Match
from typing import Annotated, ForwardRef from typing import Annotated, ForwardRef
@ -3993,6 +3993,26 @@ class Options(TypedDict, total=False):
log_level: int log_level: int
log_path: str log_path: str
class TotalMovie(TypedDict):
title: str
year: NotRequired[int]
class NontotalMovie(TypedDict, total=False):
title: Required[str]
year: int
class AnnotatedMovie(TypedDict):
title: Annotated[Required[str], "foobar"]
year: NotRequired[Annotated[int, 2000]]
class DeeplyAnnotatedMovie(TypedDict):
title: Annotated[Annotated[Required[str], "foobar"], "another level"]
year: NotRequired[Annotated[int, 2000]]
class WeirdlyQuotedMovie(TypedDict):
title: Annotated['Annotated[Required[str], "foobar"]', "another level"]
year: NotRequired['Annotated[int, 2000]']
class HasForeignBaseClass(mod_generics_cache.A): class HasForeignBaseClass(mod_generics_cache.A):
some_xrepr: 'XRepr' some_xrepr: 'XRepr'
other_a: 'mod_generics_cache.A' other_a: 'mod_generics_cache.A'
@ -4280,6 +4300,36 @@ class GetTypeHintTests(BaseTestCase):
): ):
get_type_hints(ann_module6) get_type_hints(ann_module6)
def test_get_type_hints_typeddict(self):
self.assertEqual(get_type_hints(TotalMovie), {'title': str, 'year': int})
self.assertEqual(get_type_hints(TotalMovie, include_extras=True), {
'title': str,
'year': NotRequired[int],
})
self.assertEqual(get_type_hints(AnnotatedMovie), {'title': str, 'year': int})
self.assertEqual(get_type_hints(AnnotatedMovie, include_extras=True), {
'title': Annotated[Required[str], "foobar"],
'year': NotRequired[Annotated[int, 2000]],
})
self.assertEqual(get_type_hints(DeeplyAnnotatedMovie), {'title': str, 'year': int})
self.assertEqual(get_type_hints(DeeplyAnnotatedMovie, include_extras=True), {
'title': Annotated[Required[str], "foobar", "another level"],
'year': NotRequired[Annotated[int, 2000]],
})
self.assertEqual(get_type_hints(WeirdlyQuotedMovie), {'title': str, 'year': int})
self.assertEqual(get_type_hints(WeirdlyQuotedMovie, include_extras=True), {
'title': Annotated[Required[str], "foobar", "another level"],
'year': NotRequired[Annotated[int, 2000]],
})
self.assertEqual(get_type_hints(_typed_dict_helper.VeryAnnotated), {'a': int})
self.assertEqual(get_type_hints(_typed_dict_helper.VeryAnnotated, include_extras=True), {
'a': Annotated[Required[int], "a", "b", "c"]
})
class GetUtilitiesTestCase(TestCase): class GetUtilitiesTestCase(TestCase):
def test_get_origin(self): def test_get_origin(self):
@ -4305,6 +4355,8 @@ class GetUtilitiesTestCase(TestCase):
self.assertIs(get_origin(list | str), types.UnionType) self.assertIs(get_origin(list | str), types.UnionType)
self.assertIs(get_origin(P.args), P) self.assertIs(get_origin(P.args), P)
self.assertIs(get_origin(P.kwargs), P) self.assertIs(get_origin(P.kwargs), P)
self.assertIs(get_origin(Required[int]), Required)
self.assertIs(get_origin(NotRequired[int]), NotRequired)
def test_get_args(self): def test_get_args(self):
T = TypeVar('T') T = TypeVar('T')
@ -4342,6 +4394,8 @@ class GetUtilitiesTestCase(TestCase):
self.assertEqual(get_args(Callable[Concatenate[int, P], int]), self.assertEqual(get_args(Callable[Concatenate[int, P], int]),
(Concatenate[int, P], int)) (Concatenate[int, P], int))
self.assertEqual(get_args(list | str), (list, str)) self.assertEqual(get_args(list | str), (list, str))
self.assertEqual(get_args(Required[int]), (int,))
self.assertEqual(get_args(NotRequired[int]), (int,))
class CollectionsAbcTests(BaseTestCase): class CollectionsAbcTests(BaseTestCase):
@ -5299,6 +5353,32 @@ class TypedDictTests(BaseTestCase):
'voice': str, 'voice': str,
} }
def test_required_notrequired_keys(self):
self.assertEqual(NontotalMovie.__required_keys__,
frozenset({"title"}))
self.assertEqual(NontotalMovie.__optional_keys__,
frozenset({"year"}))
self.assertEqual(TotalMovie.__required_keys__,
frozenset({"title"}))
self.assertEqual(TotalMovie.__optional_keys__,
frozenset({"year"}))
self.assertEqual(_typed_dict_helper.VeryAnnotated.__required_keys__,
frozenset())
self.assertEqual(_typed_dict_helper.VeryAnnotated.__optional_keys__,
frozenset({"a"}))
self.assertEqual(AnnotatedMovie.__required_keys__,
frozenset({"title"}))
self.assertEqual(AnnotatedMovie.__optional_keys__,
frozenset({"year"}))
self.assertEqual(WeirdlyQuotedMovie.__required_keys__,
frozenset({"title"}))
self.assertEqual(WeirdlyQuotedMovie.__optional_keys__,
frozenset({"year"}))
def test_multiple_inheritance(self): def test_multiple_inheritance(self):
class One(TypedDict): class One(TypedDict):
one: int one: int
@ -5399,6 +5479,98 @@ class TypedDictTests(BaseTestCase):
) )
class RequiredTests(BaseTestCase):
def test_basics(self):
with self.assertRaises(TypeError):
Required[NotRequired]
with self.assertRaises(TypeError):
Required[int, str]
with self.assertRaises(TypeError):
Required[int][str]
def test_repr(self):
self.assertEqual(repr(Required), 'typing.Required')
cv = Required[int]
self.assertEqual(repr(cv), 'typing.Required[int]')
cv = Required[Employee]
self.assertEqual(repr(cv), f'typing.Required[{__name__}.Employee]')
def test_cannot_subclass(self):
with self.assertRaises(TypeError):
class C(type(Required)):
pass
with self.assertRaises(TypeError):
class C(type(Required[int])):
pass
with self.assertRaises(TypeError):
class C(Required):
pass
with self.assertRaises(TypeError):
class C(Required[int]):
pass
def test_cannot_init(self):
with self.assertRaises(TypeError):
Required()
with self.assertRaises(TypeError):
type(Required)()
with self.assertRaises(TypeError):
type(Required[Optional[int]])()
def test_no_isinstance(self):
with self.assertRaises(TypeError):
isinstance(1, Required[int])
with self.assertRaises(TypeError):
issubclass(int, Required)
class NotRequiredTests(BaseTestCase):
def test_basics(self):
with self.assertRaises(TypeError):
NotRequired[Required]
with self.assertRaises(TypeError):
NotRequired[int, str]
with self.assertRaises(TypeError):
NotRequired[int][str]
def test_repr(self):
self.assertEqual(repr(NotRequired), 'typing.NotRequired')
cv = NotRequired[int]
self.assertEqual(repr(cv), 'typing.NotRequired[int]')
cv = NotRequired[Employee]
self.assertEqual(repr(cv), f'typing.NotRequired[{__name__}.Employee]')
def test_cannot_subclass(self):
with self.assertRaises(TypeError):
class C(type(NotRequired)):
pass
with self.assertRaises(TypeError):
class C(type(NotRequired[int])):
pass
with self.assertRaises(TypeError):
class C(NotRequired):
pass
with self.assertRaises(TypeError):
class C(NotRequired[int]):
pass
def test_cannot_init(self):
with self.assertRaises(TypeError):
NotRequired()
with self.assertRaises(TypeError):
type(NotRequired)()
with self.assertRaises(TypeError):
type(NotRequired[Optional[int]])()
def test_no_isinstance(self):
with self.assertRaises(TypeError):
isinstance(1, NotRequired[int])
with self.assertRaises(TypeError):
issubclass(int, NotRequired)
class IOTests(BaseTestCase): class IOTests(BaseTestCase):
def test_io(self): def test_io(self):

View file

@ -132,9 +132,11 @@ __all__ = [
'no_type_check', 'no_type_check',
'no_type_check_decorator', 'no_type_check_decorator',
'NoReturn', 'NoReturn',
'NotRequired',
'overload', 'overload',
'ParamSpecArgs', 'ParamSpecArgs',
'ParamSpecKwargs', 'ParamSpecKwargs',
'Required',
'reveal_type', 'reveal_type',
'runtime_checkable', 'runtime_checkable',
'Self', 'Self',
@ -2262,6 +2264,8 @@ def _strip_annotations(t):
""" """
if isinstance(t, _AnnotatedAlias): if isinstance(t, _AnnotatedAlias):
return _strip_annotations(t.__origin__) return _strip_annotations(t.__origin__)
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
return _strip_annotations(t.__args__[0])
if isinstance(t, _GenericAlias): if isinstance(t, _GenericAlias):
stripped_args = tuple(_strip_annotations(a) for a in t.__args__) stripped_args = tuple(_strip_annotations(a) for a in t.__args__)
if stripped_args == t.__args__: if stripped_args == t.__args__:
@ -2786,10 +2790,22 @@ class _TypedDictMeta(type):
optional_keys.update(base.__dict__.get('__optional_keys__', ())) optional_keys.update(base.__dict__.get('__optional_keys__', ()))
annotations.update(own_annotations) annotations.update(own_annotations)
if total: for annotation_key, annotation_type in own_annotations.items():
required_keys.update(own_annotation_keys) annotation_origin = get_origin(annotation_type)
else: if annotation_origin is Annotated:
optional_keys.update(own_annotation_keys) annotation_args = get_args(annotation_type)
if annotation_args:
annotation_type = annotation_args[0]
annotation_origin = get_origin(annotation_type)
if annotation_origin is Required:
required_keys.add(annotation_key)
elif annotation_origin is NotRequired:
optional_keys.add(annotation_key)
elif total:
required_keys.add(annotation_key)
else:
optional_keys.add(annotation_key)
tp_dict.__annotations__ = annotations tp_dict.__annotations__ = annotations
tp_dict.__required_keys__ = frozenset(required_keys) tp_dict.__required_keys__ = frozenset(required_keys)
@ -2874,6 +2890,45 @@ _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})
TypedDict.__mro_entries__ = lambda bases: (_TypedDict,) TypedDict.__mro_entries__ = lambda bases: (_TypedDict,)
@_SpecialForm
def Required(self, parameters):
"""A special typing construct to mark a key of a total=False TypedDict
as required. For example:
class Movie(TypedDict, total=False):
title: Required[str]
year: int
m = Movie(
title='The Matrix', # typechecker error if key is omitted
year=1999,
)
There is no runtime checking that a required key is actually provided
when instantiating a related TypedDict.
"""
item = _type_check(parameters, f'{self._name} accepts only a single type.')
return _GenericAlias(self, (item,))
@_SpecialForm
def NotRequired(self, parameters):
"""A special typing construct to mark a key of a TypedDict as
potentially missing. For example:
class Movie(TypedDict):
title: str
year: NotRequired[int]
m = Movie(
title='The Matrix', # typechecker error if key is omitted
year=1999,
)
"""
item = _type_check(parameters, f'{self._name} accepts only a single type.')
return _GenericAlias(self, (item,))
class NewType: class NewType:
"""NewType creates simple unique types with almost zero """NewType creates simple unique types with almost zero
runtime overhead. NewType(name, tp) is considered a subtype of tp runtime overhead. NewType(name, tp) is considered a subtype of tp

View file

@ -0,0 +1,2 @@
Implement ``typing.Required`` and ``typing.NotRequired`` (:pep:`655`). Patch
by Jelle Zijlstra.