From 665ad3dfa9993b9a4000b097ddead4e292590e8c Mon Sep 17 00:00:00 2001 From: Zac Hatfield-Dodds Date: Sun, 24 Nov 2019 21:48:48 +1100 Subject: [PATCH] Better runtime TypedDict (GH-17214) This patch enables downstream projects inspecting a TypedDict subclass at runtime to tell which keys are optional. This is essential for generating test data with Hypothesis or validating inputs with typeguard or pydantic. --- Lib/test/test_typing.py | 7 +++++++ Lib/typing.py | 18 +++++++++++++++--- .../2019-11-18-17-08-23.bpo-38834.abcdef.rst | 3 +++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2019-11-18-17-08-23.bpo-38834.abcdef.rst diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index ccd617c1fdf..5b4916f9c32 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -3741,6 +3741,13 @@ class TypedDictTests(BaseTestCase): self.assertEqual(Options(log_level=2), {'log_level': 2}) self.assertEqual(Options.__total__, False) + def test_optional_keys(self): + class Point2Dor3D(Point2D, total=False): + z: int + + assert Point2Dor3D.__required_keys__ == frozenset(['x', 'y']) + assert Point2Dor3D.__optional_keys__ == frozenset(['z']) + class IOTests(BaseTestCase): diff --git a/Lib/typing.py b/Lib/typing.py index 5523ee01e1f..7de3e346eaa 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -1715,9 +1715,20 @@ class _TypedDictMeta(type): anns = ns.get('__annotations__', {}) msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" anns = {n: _type_check(tp, msg) for n, tp in anns.items()} + required = set(anns if total else ()) + optional = set(() if total else anns) + for base in bases: - anns.update(base.__dict__.get('__annotations__', {})) + base_anns = base.__dict__.get('__annotations__', {}) + anns.update(base_anns) + if getattr(base, '__total__', True): + required.update(base_anns) + else: + optional.update(base_anns) + tp_dict.__annotations__ = anns + tp_dict.__required_keys__ = frozenset(required) + tp_dict.__optional_keys__ = frozenset(optional) if not hasattr(tp_dict, '__total__'): tp_dict.__total__ = total return tp_dict @@ -1744,8 +1755,9 @@ class TypedDict(dict, metaclass=_TypedDictMeta): assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') - The type info can be accessed via Point2D.__annotations__. TypedDict - supports two additional equivalent forms:: + The type info can be accessed via the Point2D.__annotations__ dict, and + the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets. + TypedDict supports two additional equivalent forms:: Point2D = TypedDict('Point2D', x=int, y=int, label=str) Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str}) diff --git a/Misc/NEWS.d/next/Library/2019-11-18-17-08-23.bpo-38834.abcdef.rst b/Misc/NEWS.d/next/Library/2019-11-18-17-08-23.bpo-38834.abcdef.rst new file mode 100644 index 00000000000..af108b1efbc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2019-11-18-17-08-23.bpo-38834.abcdef.rst @@ -0,0 +1,3 @@ +:class:`typing.TypedDict` subclasses now track which keys are optional using +the ``__required_keys__`` and ``__optional_keys__`` attributes, to enable +runtime validation by downstream projects. Patch by Zac Hatfield-Dodds.