From dc3095e0ff229c61cf16307c6f49525e65a70d2c Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 20:01:54 -0800 Subject: [PATCH 1/3] gh-112509: Fix keys being present in both required_keys and optional_keys in TypedDict --- Lib/test/test_typing.py | 40 +++++++++++++++++++ Lib/typing.py | 23 ++++++++--- ...-11-28-20-01-33.gh-issue-112509.QtoKed.rst | 3 ++ 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-11-28-20-01-33.gh-issue-112509.QtoKed.rst diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 669803177315e3..3f1f78ca61c9af 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -7769,6 +7769,46 @@ class Cat(Animal): 'voice': str, }) + def test_keys_inheritance_with_same_name(self): + class NotTotal(TypedDict, total=False): + a: int + + class Total(NotTotal): + a: int + + self.assertEqual(NotTotal.__required_keys__, frozenset()) + self.assertEqual(NotTotal.__optional_keys__, frozenset(['a'])) + self.assertEqual(Total.__required_keys__, frozenset(['a'])) + self.assertEqual(Total.__optional_keys__, frozenset()) + + class Base(TypedDict): + a: NotRequired[int] + b: Required[int] + + class Child(Base): + a: Required[int] + b: NotRequired[int] + + self.assertEqual(Base.__required_keys__, frozenset(['b'])) + self.assertEqual(Base.__optional_keys__, frozenset(['a'])) + self.assertEqual(Child.__required_keys__, frozenset(['a'])) + self.assertEqual(Child.__optional_keys__, frozenset(['b'])) + + def test_multiple_inheritance(self): + class Base1(TypedDict): + a: NotRequired[int] + + class Base2(TypedDict): + a: Required[str] + + class Child(Base1, Base2): + pass + + # Last base wins + self.assertEqual(Child.__annotations__, {'a': Required[str]}) + self.assertEqual(Child.__required_keys__, frozenset(['a'])) + self.assertEqual(Child.__optional_keys__, frozenset()) + def test_required_notrequired_keys(self): self.assertEqual(NontotalMovie.__required_keys__, frozenset({"title"})) diff --git a/Lib/typing.py b/Lib/typing.py index 216f0c141b62af..060f9f46d3145e 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -2884,8 +2884,12 @@ def __new__(cls, name, bases, ns, total=True): for base in bases: annotations.update(base.__dict__.get('__annotations__', {})) - required_keys.update(base.__dict__.get('__required_keys__', ())) - optional_keys.update(base.__dict__.get('__optional_keys__', ())) + base_required = base.__dict__.get('__required_keys__', ()) + base_optional = base.__dict__.get('__optional_keys__', ()) + required_keys.update(base_required) + required_keys.difference_update(base_optional) + optional_keys.update(base_optional) + optional_keys.difference_update(base_required) annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): @@ -2897,14 +2901,23 @@ def __new__(cls, name, bases, ns, total=True): annotation_origin = get_origin(annotation_type) if annotation_origin is Required: - required_keys.add(annotation_key) + is_required = True elif annotation_origin is NotRequired: - optional_keys.add(annotation_key) - elif total: + is_required = False + else: + is_required = total + + if is_required: required_keys.add(annotation_key) + optional_keys.discard(annotation_key) else: optional_keys.add(annotation_key) + required_keys.discard(annotation_key) + assert required_keys.isdisjoint(optional_keys), ( + f"Required keys overlap with optional keys in {name}:" + f" {required_keys=}, {optional_keys=}" + ) tp_dict.__annotations__ = annotations tp_dict.__required_keys__ = frozenset(required_keys) tp_dict.__optional_keys__ = frozenset(optional_keys) diff --git a/Misc/NEWS.d/next/Library/2023-11-28-20-01-33.gh-issue-112509.QtoKed.rst b/Misc/NEWS.d/next/Library/2023-11-28-20-01-33.gh-issue-112509.QtoKed.rst new file mode 100644 index 00000000000000..a16d67e7776bcb --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-11-28-20-01-33.gh-issue-112509.QtoKed.rst @@ -0,0 +1,3 @@ +Fix edge cases that could cause a key to be present in both the +``__required_keys__`` and ``__optional_keys__`` attributes of a +:class:`typing.TypedDict`. Patch by Jelle Zijlstra. From af8a732fee9745c624f00e9d5c21b40136d806f1 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 20:04:23 -0800 Subject: [PATCH 2/3] thanks lint --- Lib/test/test_typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 3f1f78ca61c9af..4fbb410f26ab8d 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -7794,7 +7794,7 @@ class Child(Base): self.assertEqual(Child.__required_keys__, frozenset(['a'])) self.assertEqual(Child.__optional_keys__, frozenset(['b'])) - def test_multiple_inheritance(self): + def test_multiple_inheritance_with_same_key(self): class Base1(TypedDict): a: NotRequired[int] From 2c83a1197d7c7cd178c69b94eaccf07906cff84d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 06:36:17 -0800 Subject: [PATCH 3/3] Update Lib/typing.py Co-authored-by: Alex Waygood --- Lib/typing.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Lib/typing.py b/Lib/typing.py index 060f9f46d3145e..b3af701f8d5437 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -2884,12 +2884,14 @@ def __new__(cls, name, bases, ns, total=True): for base in bases: annotations.update(base.__dict__.get('__annotations__', {})) - base_required = base.__dict__.get('__required_keys__', ()) - base_optional = base.__dict__.get('__optional_keys__', ()) - required_keys.update(base_required) - required_keys.difference_update(base_optional) - optional_keys.update(base_optional) - optional_keys.difference_update(base_required) + + base_required = base.__dict__.get('__required_keys__', set()) + required_keys |= base_required + optional_keys -= base_required + + base_optional = base.__dict__.get('__optional_keys__', set()) + required_keys -= base_optional + optional_keys |= base_optional annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items():