gh-116127: PEP-705: Add `ReadOnly` support for `TypedDict` (#116350)

Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
This commit is contained in:
Nikita Sobolev 2024-03-12 17:49:39 +03:00 committed by GitHub
parent 3265087c07
commit df4784b3b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 182 additions and 11 deletions

View File

@ -1274,6 +1274,26 @@ These can be used as types in annotations. They all support subscription using
.. versionadded:: 3.11 .. versionadded:: 3.11
.. data:: ReadOnly
A special typing construct to mark an item of a :class:`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
.. data:: Annotated .. data:: Annotated
Special typing form to add context-specific metadata to an annotation. Special typing form to add context-specific metadata to an annotation.
@ -2454,6 +2474,22 @@ types.
``__required_keys__`` and ``__optional_keys__`` rely on may not work ``__required_keys__`` and ``__optional_keys__`` rely on may not work
properly, and the values of the attributes may be incorrect. properly, and the values of the attributes may be incorrect.
Support for :data:`ReadOnly` 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``. See :pep:`589` for more examples and detailed rules of using ``TypedDict``.
.. versionadded:: 3.8 .. versionadded:: 3.8
@ -2468,6 +2504,9 @@ types.
.. versionchanged:: 3.13 .. versionchanged:: 3.13
Removed support for the keyword-argument method of creating ``TypedDict``\ s. 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 .. deprecated-removed:: 3.13 3.15
When using the functional syntax to create a TypedDict class, failing to When using the functional syntax to create a TypedDict class, failing to
pass a value to the 'fields' parameter (``TD = TypedDict("TD")``) is pass a value to the 'fields' parameter (``TD = TypedDict("TD")``) is

View File

@ -602,6 +602,10 @@ typing
check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in
:gh:`104873`.) :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 unicodedata
----------- -----------

View File

@ -31,7 +31,7 @@ from typing import reveal_type
from typing import dataclass_transform from typing import dataclass_transform
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, NotRequired, Required, TypedDict from typing import NamedTuple, NotRequired, Required, ReadOnly, 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
@ -8322,6 +8322,69 @@ class TypedDictTests(BaseTestCase):
self.assertEqual(klass.__optional_keys__, set()) self.assertEqual(klass.__optional_keys__, set())
self.assertIsInstance(klass(), dict) 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): class RequiredTests(BaseTestCase):

View File

@ -144,6 +144,7 @@ __all__ = [
'override', 'override',
'ParamSpecArgs', 'ParamSpecArgs',
'ParamSpecKwargs', 'ParamSpecKwargs',
'ReadOnly',
'Required', 'Required',
'reveal_type', 'reveal_type',
'runtime_checkable', 'runtime_checkable',
@ -2301,7 +2302,7 @@ def _strip_annotations(t):
"""Strip the annotations from a given type.""" """Strip the annotations from a given type."""
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): if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired, ReadOnly):
return _strip_annotations(t.__args__[0]) 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__)
@ -2922,6 +2923,28 @@ def _namedtuple_mro_entries(bases):
NamedTuple.__mro_entries__ = _namedtuple_mro_entries 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): class _TypedDictMeta(type):
def __new__(cls, name, bases, ns, total=True): def __new__(cls, name, bases, ns, total=True):
"""Create a new typed dict class object. """Create a new typed dict class object.
@ -2955,6 +2978,8 @@ class _TypedDictMeta(type):
} }
required_keys = set() required_keys = set()
optional_keys = set() optional_keys = set()
readonly_keys = set()
mutable_keys = set()
for base in bases: for base in bases:
annotations.update(base.__dict__.get('__annotations__', {})) annotations.update(base.__dict__.get('__annotations__', {}))
@ -2967,18 +2992,15 @@ class _TypedDictMeta(type):
required_keys -= base_optional required_keys -= base_optional
optional_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) annotations.update(own_annotations)
for annotation_key, annotation_type in own_annotations.items(): for annotation_key, annotation_type in own_annotations.items():
annotation_origin = get_origin(annotation_type) qualifiers = set(_get_typeddict_qualifiers(annotation_type))
if annotation_origin is Annotated: if Required in qualifiers:
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:
is_required = True is_required = True
elif annotation_origin is NotRequired: elif NotRequired in qualifiers:
is_required = False is_required = False
else: else:
is_required = total is_required = total
@ -2990,6 +3012,17 @@ class _TypedDictMeta(type):
optional_keys.add(annotation_key) optional_keys.add(annotation_key)
required_keys.discard(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), ( assert required_keys.isdisjoint(optional_keys), (
f"Required keys overlap with optional keys in {name}:" f"Required keys overlap with optional keys in {name}:"
f" {required_keys=}, {optional_keys=}" f" {required_keys=}, {optional_keys=}"
@ -2997,6 +3030,8 @@ class _TypedDictMeta(type):
tp_dict.__annotations__ = annotations tp_dict.__annotations__ = annotations
tp_dict.__required_keys__ = frozenset(required_keys) tp_dict.__required_keys__ = frozenset(required_keys)
tp_dict.__optional_keys__ = frozenset(optional_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 tp_dict.__total__ = total
return tp_dict return tp_dict
@ -3055,6 +3090,14 @@ def TypedDict(typename, fields=_sentinel, /, *, total=True):
y: NotRequired[int] # the "y" key can be omitted y: NotRequired[int] # the "y" key can be omitted
See PEP 655 for more details on Required and NotRequired. See PEP 655 for more details on Required and NotRequired.
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: if fields is _sentinel or fields is None:
import warnings import warnings
@ -3131,6 +3174,26 @@ def NotRequired(self, parameters):
return _GenericAlias(self, (item,)) 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: class NewType:
"""NewType creates simple unique types with almost zero runtime overhead. """NewType creates simple unique types with almost zero runtime overhead.

View File

@ -0,0 +1,2 @@
:mod:`typing`: implement :pep:`705` which adds :data:`typing.ReadOnly`
support to :class:`typing.TypedDict`.