From bb29d9bd771a541ea296af2c69b6c54410853780 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 5 Mar 2024 14:34:57 +0300 Subject: [PATCH 1/4] gh-116127: PEP-705: Add `ReadOnly` support for `TypedDict` --- Doc/library/typing.rst | 25 ++++++ Lib/test/test_typing.py | 65 ++++++++++++++- Lib/typing.py | 81 ++++++++++++++++--- ...-03-05-14-34-22.gh-issue-116127.5uktu3.rst | 2 + 4 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-03-05-14-34-22.gh-issue-116127.5uktu3.rst diff --git a/Doc/library/typing.rst b/Doc/library/typing.rst index 63bd62d1f6679b..d49ec2db7c217c 100644 --- a/Doc/library/typing.rst +++ b/Doc/library/typing.rst @@ -1275,6 +1275,12 @@ These can be used as types in annotations. They all support subscription using .. versionadded:: 3.11 +.. data:: ReadOnly + + See :pep:`705`. Indicates that a :class:`TypedDict` item may not be modified. + + .. versionadded:: 3.13 + .. data:: Annotated Special typing form to add context-specific metadata to an annotation. @@ -2455,6 +2461,22 @@ types. ``__required_keys__`` and ``__optional_keys__`` rely on may not work properly, and the values of the attributes may be incorrect. + Support of :pep:`705` is reflected in the following attributes:: + + .. attribute:: __readonly_keys__ + + A :class:`frozenset` containing the names of all read-only keys. Keys + are read-only if they carry the :data:`ReadOnly` qualifier. + + .. versionadded:: 3.13 + + .. attribute:: __mutable_keys__ + + A :class:`frozenset` containing the names of all mutable keys. Keys + are mutable if they do not carry the :data:`ReadOnly` qualifier. + + .. versionadded:: 3.13 + See :pep:`589` for more examples and detailed rules of using ``TypedDict``. .. versionadded:: 3.8 @@ -2469,6 +2491,9 @@ types. .. versionchanged:: 3.13 Removed support for the keyword-argument method of creating ``TypedDict``\ s. + .. versionchanged:: 3.13 + Support for the :data:`ReadOnly` qualifier was added. + .. deprecated-removed:: 3.13 3.15 When using the functional syntax to create a TypedDict class, failing to pass a value to the 'fields' parameter (``TD = TypedDict("TD")``) is diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 912384ab6bfe84..bbc13865d57bf6 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -31,7 +31,7 @@ from typing import dataclass_transform from typing import no_type_check, no_type_check_decorator from typing import Type -from typing import NamedTuple, NotRequired, Required, TypedDict +from typing import NamedTuple, NotRequired, Required, ReadOnly, TypedDict from typing import IO, TextIO, BinaryIO from typing import Pattern, Match from typing import Annotated, ForwardRef @@ -8334,6 +8334,69 @@ class T4(TypedDict, Generic[S]): pass self.assertEqual(klass.__optional_keys__, set()) self.assertIsInstance(klass(), dict) + def test_readonly_inheritance(self): + class Base1(TypedDict): + a: ReadOnly[int] + + class Child1(Base1): + b: str + + self.assertEqual(Child1.__readonly_keys__, frozenset({'a'})) + self.assertEqual(Child1.__mutable_keys__, frozenset({'b'})) + + class Base2(TypedDict): + a: ReadOnly[int] + + class Child2(Base2): + b: str + + self.assertEqual(Child1.__readonly_keys__, frozenset({'a'})) + self.assertEqual(Child1.__mutable_keys__, frozenset({'b'})) + + def test_cannot_make_mutable_key_readonly(self): + class Base(TypedDict): + a: int + + with self.assertRaises(TypeError): + class Child(Base): + a: ReadOnly[int] + + def test_can_make_readonly_key_mutable(self): + class Base(TypedDict): + a: ReadOnly[int] + + class Child(Base): + a: int + + self.assertEqual(Child.__readonly_keys__, frozenset()) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + + def test_combine_qualifiers(self): + class AllTheThings(TypedDict): + a: Annotated[Required[ReadOnly[int]], "why not"] + b: Required[Annotated[ReadOnly[int], "why not"]] + c: ReadOnly[NotRequired[Annotated[int, "why not"]]] + d: NotRequired[Annotated[int, "why not"]] + + self.assertEqual(AllTheThings.__required_keys__, frozenset({'a', 'b'})) + self.assertEqual(AllTheThings.__optional_keys__, frozenset({'c', 'd'})) + self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'})) + self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'})) + + self.assertEqual( + get_type_hints(AllTheThings, include_extras=False), + {'a': int, 'b': int, 'c': int, 'd': int}, + ) + self.assertEqual( + get_type_hints(AllTheThings, include_extras=True), + { + 'a': Annotated[Required[ReadOnly[int]], 'why not'], + 'b': Required[Annotated[ReadOnly[int], 'why not']], + 'c': ReadOnly[NotRequired[Annotated[int, 'why not']]], + 'd': NotRequired[Annotated[int, 'why not']], + }, + ) + class RequiredTests(BaseTestCase): diff --git a/Lib/typing.py b/Lib/typing.py index cca9525d632ea5..367cf79c645f19 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -144,6 +144,7 @@ 'override', 'ParamSpecArgs', 'ParamSpecKwargs', + 'ReadOnly', 'Required', 'reveal_type', 'runtime_checkable', @@ -2301,7 +2302,7 @@ def _strip_annotations(t): """Strip the annotations from a given type.""" if isinstance(t, _AnnotatedAlias): return _strip_annotations(t.__origin__) - if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired): + if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired, ReadOnly): return _strip_annotations(t.__args__[0]) if isinstance(t, _GenericAlias): stripped_args = tuple(_strip_annotations(a) for a in t.__args__) @@ -2922,6 +2923,28 @@ def _namedtuple_mro_entries(bases): NamedTuple.__mro_entries__ = _namedtuple_mro_entries +def _get_typeddict_qualifiers(annotation_type): + while True: + 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] + else: + break + elif annotation_origin is Required: + yield Required + (annotation_type,) = get_args(annotation_type) + elif annotation_origin is NotRequired: + yield NotRequired + (annotation_type,) = get_args(annotation_type) + elif annotation_origin is ReadOnly: + yield ReadOnly + (annotation_type,) = get_args(annotation_type) + else: + break + + class _TypedDictMeta(type): def __new__(cls, name, bases, ns, total=True): """Create a new typed dict class object. @@ -2955,6 +2978,8 @@ def __new__(cls, name, bases, ns, total=True): } required_keys = set() optional_keys = set() + readonly_keys = set() + mutable_keys = set() for base in bases: annotations.update(base.__dict__.get('__annotations__', {})) @@ -2967,18 +2992,15 @@ def __new__(cls, name, bases, ns, total=True): required_keys -= base_optional optional_keys |= base_optional + readonly_keys.update(base.__dict__.get('__readonly_keys__', ())) + mutable_keys.update(base.__dict__.get('__mutable_keys__', ())) + annotations.update(own_annotations) 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: + qualifiers = set(_get_typeddict_qualifiers(annotation_type)) + if Required in qualifiers: is_required = True - elif annotation_origin is NotRequired: + elif NotRequired in qualifiers: is_required = False else: is_required = total @@ -2990,6 +3012,17 @@ def __new__(cls, name, bases, ns, total=True): optional_keys.add(annotation_key) required_keys.discard(annotation_key) + if ReadOnly in qualifiers: + if annotation_key in mutable_keys: + raise TypeError( + f"Cannot override mutable key {annotation_key!r}" + " with read-only key" + ) + readonly_keys.add(annotation_key) + else: + mutable_keys.add(annotation_key) + readonly_keys.discard(annotation_key) + assert required_keys.isdisjoint(optional_keys), ( f"Required keys overlap with optional keys in {name}:" f" {required_keys=}, {optional_keys=}" @@ -2997,6 +3030,8 @@ def __new__(cls, name, bases, ns, total=True): tp_dict.__annotations__ = annotations tp_dict.__required_keys__ = frozenset(required_keys) tp_dict.__optional_keys__ = frozenset(optional_keys) + tp_dict.__readonly_keys__ = frozenset(readonly_keys) + tp_dict.__mutable_keys__ = frozenset(mutable_keys) tp_dict.__total__ = total return tp_dict @@ -3055,6 +3090,12 @@ class Point2D(TypedDict): y: NotRequired[int] # the "y" key can be omitted See PEP 655 for more details on Required and NotRequired. + + The ReadOnly special form can be used to make immutable individual keys:: + + class DatabaseUser(TypedDict): + id: ReadOnly[int] # the "id" key must not be modified + username: str # the "username" key can be changed """ if fields is _sentinel or fields is None: import warnings @@ -3131,6 +3172,26 @@ class Movie(TypedDict): return _GenericAlias(self, (item,)) +@_SpecialForm +def ReadOnly(self, parameters): + """A special typing construct to mark an item of a TypedDict as read-only. + + For example:: + + class Movie(TypedDict): + title: ReadOnly[str] + year: int + + def mutate_movie(m: Movie) -> None: + m["year"] = 1992 # allowed + m["title"] = "The Matrix" # typechecker error + + There is no runtime checking for this property. + """ + 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. diff --git a/Misc/NEWS.d/next/Library/2024-03-05-14-34-22.gh-issue-116127.5uktu3.rst b/Misc/NEWS.d/next/Library/2024-03-05-14-34-22.gh-issue-116127.5uktu3.rst new file mode 100644 index 00000000000000..59edde9811e2f5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-03-05-14-34-22.gh-issue-116127.5uktu3.rst @@ -0,0 +1,2 @@ +:mod:`typing`: implement :pep:`705` which adds :data:`typing.ReadOnly` +support to :class:`typing.TypedDict`. From 95dcadf5c37b945d80fe19c1a04a3c8070cf2c24 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 5 Mar 2024 15:04:08 +0300 Subject: [PATCH 2/4] Better wording --- Lib/typing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/typing.py b/Lib/typing.py index 367cf79c645f19..dea7253c9a8c3b 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -3091,11 +3091,13 @@ class Point2D(TypedDict): See PEP 655 for more details on Required and NotRequired. - The ReadOnly special form can be used to make immutable individual keys:: + The ReadOnly special form can be used + to mark individual keys as immutable for type checkers:: class DatabaseUser(TypedDict): id: ReadOnly[int] # the "id" key must not be modified username: str # the "username" key can be changed + """ if fields is _sentinel or fields is None: import warnings From aab046a081829a01d9276eaa1ceeffa2be9497f0 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 5 Mar 2024 15:44:00 +0300 Subject: [PATCH 3/4] Address review --- Doc/library/typing.rst | 18 ++++++++++++++++-- Doc/whatsnew/3.13.rst | 4 ++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Doc/library/typing.rst b/Doc/library/typing.rst index d49ec2db7c217c..5b24110cb74d32 100644 --- a/Doc/library/typing.rst +++ b/Doc/library/typing.rst @@ -1277,7 +1277,21 @@ These can be used as types in annotations. They all support subscription using .. data:: ReadOnly - See :pep:`705`. Indicates that a :class:`TypedDict` item may not be modified. + A special typing construct to mark an item of a TypedDict as read-only. + + For example:: + + class Movie(TypedDict): + title: ReadOnly[str] + year: int + + def mutate_movie(m: Movie) -> None: + m["year"] = 1992 # allowed + m["title"] = "The Matrix" # typechecker error + + There is no runtime checking for this property. + + See :class:`TypedDict` and :pep:`705` for more details. .. versionadded:: 3.13 @@ -2461,7 +2475,7 @@ types. ``__required_keys__`` and ``__optional_keys__`` rely on may not work properly, and the values of the attributes may be incorrect. - Support of :pep:`705` is reflected in the following attributes:: + Support of :data:`ReadOnly` is reflected in the following attributes:: .. attribute:: __readonly_keys__ diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst index 96c8aee5da075a..c5ff68284ad6ae 100644 --- a/Doc/whatsnew/3.13.rst +++ b/Doc/whatsnew/3.13.rst @@ -582,6 +582,10 @@ typing check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in :gh:`104873`.) +* Add :data:`typing.ReadOnly` – a special typing construct to mark + an item of a :class:`typing.TypedDict` as read-only for type checkers. + See :pep:`705` for more details. + unicodedata ----------- From 04332246972b44e4df8a0ad8b0eca91710efc6ac Mon Sep 17 00:00:00 2001 From: Nikita Sobolev Date: Tue, 12 Mar 2024 10:48:47 +0300 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Jelle Zijlstra --- Doc/library/typing.rst | 4 ++-- Doc/whatsnew/3.13.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/typing.rst b/Doc/library/typing.rst index 5b24110cb74d32..99044f46592711 100644 --- a/Doc/library/typing.rst +++ b/Doc/library/typing.rst @@ -1277,7 +1277,7 @@ These can be used as types in annotations. They all support subscription using .. data:: ReadOnly - A special typing construct to mark an item of a TypedDict as read-only. + A special typing construct to mark an item of a :class:`TypedDict` as read-only. For example:: @@ -2475,7 +2475,7 @@ types. ``__required_keys__`` and ``__optional_keys__`` rely on may not work properly, and the values of the attributes may be incorrect. - Support of :data:`ReadOnly` is reflected in the following attributes:: + Support for :data:`ReadOnly` is reflected in the following attributes:: .. attribute:: __readonly_keys__ diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst index c5ff68284ad6ae..305c452eab4be7 100644 --- a/Doc/whatsnew/3.13.rst +++ b/Doc/whatsnew/3.13.rst @@ -582,7 +582,7 @@ typing check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in :gh:`104873`.) -* Add :data:`typing.ReadOnly` – a special typing construct to mark +* Add :data:`typing.ReadOnly`, a special typing construct to mark an item of a :class:`typing.TypedDict` as read-only for type checkers. See :pep:`705` for more details.