mirror of https://github.com/python/cpython
gh-116127: PEP-705: Add `ReadOnly` support for `TypedDict` (#116350)
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
This commit is contained in:
parent
3265087c07
commit
df4784b3b7
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
:mod:`typing`: implement :pep:`705` which adds :data:`typing.ReadOnly`
|
||||||
|
support to :class:`typing.TypedDict`.
|
Loading…
Reference in New Issue