Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Lib/test/_typed_dict_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@

class Bar(_typed_dict_helper.Foo, total=False):
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 typing import Optional, TypedDict
from typing import Annotated, Optional, Required, TypedDict

OptionalIntType = Optional[int]

class Foo(TypedDict):
a: OptionalIntType

class VeryAnnotated(TypedDict, total=False):
a: Annotated[Annotated[Annotated[Required[int], "a"], "b"], "c"]
174 changes: 173 additions & 1 deletion Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from typing import reveal_type
from typing import no_type_check, no_type_check_decorator
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 Pattern, Match
from typing import Annotated, ForwardRef
Expand Down Expand Up @@ -3967,6 +3967,26 @@ class Options(TypedDict, total=False):
log_level: int
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):
some_xrepr: 'XRepr'
other_a: 'mod_generics_cache.A'
Expand Down Expand Up @@ -4254,6 +4274,36 @@ def test_top_level_class_var(self):
):
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):
def test_get_origin(self):
Expand All @@ -4279,6 +4329,8 @@ class C(Generic[T]): pass
self.assertIs(get_origin(list | str), types.UnionType)
self.assertIs(get_origin(P.args), 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):
T = TypeVar('T')
Expand Down Expand Up @@ -4316,6 +4368,8 @@ class C(Generic[T]): pass
self.assertEqual(get_args(Callable[Concatenate[int, P], int]),
(Concatenate[int, P], int))
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):
Expand Down Expand Up @@ -5273,6 +5327,32 @@ class Cat(Animal):
'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):
class One(TypedDict):
one: int
Expand Down Expand Up @@ -5373,6 +5453,98 @@ def test_get_type_hints(self):
)


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):

def test_io(self):
Expand Down
63 changes: 59 additions & 4 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@ def _idfunc(_, x):
'no_type_check',
'no_type_check_decorator',
'NoReturn',
'NotRequired',
'overload',
'ParamSpecArgs',
'ParamSpecKwargs',
'Required',
'reveal_type',
'runtime_checkable',
'Self',
Expand Down Expand Up @@ -2261,6 +2263,8 @@ def _strip_annotations(t):
"""
if isinstance(t, _AnnotatedAlias):
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):
stripped_args = tuple(_strip_annotations(a) for a in t.__args__)
if stripped_args == t.__args__:
Expand Down Expand Up @@ -2785,10 +2789,22 @@ def __new__(cls, name, bases, ns, total=True):
optional_keys.update(base.__dict__.get('__optional_keys__', ()))

annotations.update(own_annotations)
if total:
required_keys.update(own_annotation_keys)
else:
optional_keys.update(own_annotation_keys)
for annotation_key, annotation_type in own_annotations.items():
annotation_origin = get_origin(annotation_type)
if annotation_origin is Annotated:
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.__required_keys__ = frozenset(required_keys)
Expand Down Expand Up @@ -2873,6 +2889,45 @@ class body be required.
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:
"""NewType creates simple unique types with almost zero
runtime overhead. NewType(name, tp) is considered a subtype of tp
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Implement ``typing.Required`` and ``typing.NotRequired`` (:pep:`655`). Patch
by Jelle Zijlstra.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
by Jelle Zijlstra.
by David Foster and Jelle Zijlstra.

@davidfstr wrote the original code in typing-extensions so I shouldn't claim sole credit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidfstr wrote the original code in typing-extensions so I shouldn't claim sole credit.

Then also add a Contributed-by header (or whatever it's called) to the commit message?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I put it in the PR header and I'll add it to the commit message when I merge this. (I'll give it some more time though, to give other people a chance to give feedback.)