8000 gh-116127: PEP-705: Add `ReadOnly` support for `TypedDict` (#116350) · python/cpython@df4784b · GitHub
[go: up one dir, main page]

Skip to content

Commit df4784b

Browse files
gh-116127: PEP-705: Add ReadOnly support for TypedDict (#116350)
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
1 parent 3265087 commit df4784b

File tree

5 files changed

+182
-11
lines changed

5 files changed

+182
-11
lines changed

Doc/library/typing.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,26 @@ These can be used as types in annotations. They all support subscription using
12741274

12751275
.. versionadded:: 3.11
12761276

1277+
.. data:: ReadOnly
1278+
1279+
A special typing construct to mark an item of a :class:`TypedDict` as read-only.
1280+
1281+
For example::
1282+
1283+
class Movie(TypedDict):
1284+
title: ReadOnly[str]
1285+
year: int
1286+
1287+
def mutate_movie(m: Movie) -> None:
1288+
m["year"] = 1992 # allowed
1289+
m["title"] = "The Matrix" # typechecker error
1290+
1291+
There is no runtime checking for this property.
1292+
1293+
See :class:`TypedDict` and :pep:`705` for more details.
1294+
1295+
.. versionadded:: 3.13
1296+
12771297
.. data:: Annotated
12781298

12791299
Special typing form to add context-specific metadata to an annotation.
@@ -2454,6 +2474,22 @@ types.
24542474
``__required_keys__`` and ``__optional_keys__`` rely on may not work
24552475
properly, and the values of the attributes may be incorrect.
24562476

2477+
Support for :data:`ReadOnly` is reflected in the following attributes::
2478+
2479+
.. attribute:: __readonly_keys__
2480+
2481+
A :class:`frozenset` containing the names of all read-only keys. Keys
2482+
are read-only if they carry the :data:`ReadOnly` qualifier.
2483+
2484+
.. versionadded:: 3.13
2485+
2486+
.. attribute:: __mutable_keys__
2487+
2488+
A :class:`frozenset` containing the names of all mutable keys. Keys
2489+
are mutable if they do not carry the :data:`ReadOnly` qualifier.
2490+
2491+
.. versionadded:: 3.13
2492+
24572493
See :pep:`589` for more examples and detailed rules of using ``TypedDict``.
24582494

24592495
.. versionadded:: 3.8
@@ -2468,6 +2504,9 @@ types.
24682504
.. versionchanged:: 3.13
24692505
Removed support for the keyword-argument method of creating ``TypedDict``\ s.
24702506

2507+
.. versionchanged:: 3.13
2508+
Support for the :data:`ReadOnly` qualifier was added.
2509+
24712510
.. deprecated-removed:: 3.13 3.15
24722511
When using the functional syntax to create a TypedDict class, failing to
24732512
pass a value to the 'fields' parameter (``TD = TypedDict("TD")``) is

Doc/whatsnew/3.13.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,10 @@ typing
602602
check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in
603603
:gh:`104873`.)
604604

605+
* Add :data:`typing.ReadOnly`, a special typing construct to mark
606+
an item of a :class:`typing.TypedDict` as read-only for type checkers.
607+
See :pep:`705` for more details.
608+
605609
unicodedata
606610
-----------
607611

Lib/test/test_typing.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from typing import dataclass_transform
3232
from typing import no_type_check, no_type_check_decorator
3333
from typing import Type
34-
from typing import NamedTuple, NotRequired, Required, TypedDict
34+
from typing import NamedTuple, NotRequired, Required, ReadOnly, TypedDict
3535
from typing import IO, TextIO, BinaryIO
3636
from typing import Pattern, Match
3737
from typing import Annotated, ForwardRef
@@ -8322,6 +8322,69 @@ class T4(TypedDict, Generic[S]): pass
83228322
self.assertEqual(klass.__optional_keys__, set())
83238323
self.assertIsInstance(klass(), dict)
83248324

8325+
def test_readonly_inheritance(self):
8326+
class Base1(TypedDict):
8327+
a: ReadOnly[int]
8328+
8329+
class Child1(Base1):
8330+
b: str
8331+
8332+
self.assertEqual(Child1.__readonly_keys__, frozenset({'a'}))
8333+
self.assertEqual(Child1.__mutable_keys__, frozenset({'b'}))
8334+
8335+
class Base2(TypedDict):
8336+
a: ReadOnly[int]
8337+
8338+
class Child2(Base2):
8339+
b: str
8340+
8341+
self.assertEqual(Child1.__readonly_keys__, frozenset({'a'}))
8342+
self.assertEqual(Child1.__mutable_keys__, frozenset({'b'}))
8343+
8344+
def test_cannot_make_mutable_key_readonly(self):
8345+
class Base(TypedDict):
8346+
a: int
8347+
8348+
with self.assertRaises(TypeError):
8349+
class Child(Base):
8350+
a: ReadOnly[int]
8351+
8352+
def test_can_make_readonly_key_mutable(self):
8353+
class Base(TypedDict):
8354+
a: ReadOnly[int]
8355+
8356+
class Child(Base):
8357+
a: int
8358+
8359+
self.assertEqual(Child.__readonly_keys__, frozenset())
8360+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
8361+
8362+
def test_combine_qualifiers(self):
8363+
class AllTheThings(TypedDict):
8364+
a: Annotated[Required[ReadOnly[int]], "why not"]
8365+
b: Required[Annotated[ReadOnly[int], "why not"]]
8366+
c: ReadOnly[NotRequired[Annotated[int, "why not"]]]
8367+
d: NotRequired[Annotated[int, "why not"]]
8368+
8369+
self.assertEqual(AllTheThings.__required_keys__, frozenset({'a', 'b'}))
8370+
self.assertEqual(AllTheThings.__optional_keys__, frozenset({'c', 'd'}))
8371+
self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'}))
8372+
self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'}))
8373+
8374+
self.assertEqual(
8375+
get_type_hints(AllTheThings, include_extras=False),
8376+
{'a': int, 'b': int, 'c': int, 'd': int},
8377+
)
8378+
self.assertEqual(
8379+
get_type_hints(AllTheThings, include_extras=True),
8380+
{
8381+
'a': Annotated[Required[ReadOnly[int]], 'why not'],
8382+
'b': Required[Annotated[ReadOnly[int], 'why not']],
8383+
'c': ReadOnly[NotRequired[Annotated[int, 'why not']]],
8384+
'd': NotRequired[Annotated[int, 'why not']],
8385+
},
8386+
)
8387+
83258388

83268389
class RequiredTests(BaseTestCase):
83278390

Lib/typing.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
'override',
145145
'ParamSpecArgs',
146146
'ParamSpecKwargs',
147+
'ReadOnly',
147148
'Required',
148149
'reveal_type',
149150
'runtime_checkable',
@@ -2301,7 +2302,7 @@ def _strip_annotations(t):
23012302
"""Strip the annotations from a given type."""
23022303
if isinstance(t, _AnnotatedAlias):
23032304
return _strip_annotations(t.__origin__)
2304-
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
2305+
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired, ReadOnly):
23052306
return _strip_annotations(t.__args__[0])
23062307
if isinstance(t, _GenericAlias):
23072308
stripped_args = tuple(_strip_annotations(a) for a in t.__args__)
@@ -2922,6 +2923,28 @@ def _namedtuple_mro_entries(bases):
29222923
NamedTuple.__mro_entries__ = _namedtuple_mro_entries
29232924

29242925

2926+
def _get_typeddict_qualifiers(annotation_type):
2927+
while True:
2928+
annotation_origin = get_origin(annotation_type)
2929+
if annotation_origin is Annotated:
2930+
annotation_args = get_args(annotation_type)
2931+
if annotation_args:
2932+
annotation_type = annotation_args[0]
2933+
else:
2934+
break
2935+
elif annotation_origin is Required:
2936+
yield Required
2937+
(annotation_type,) = get_args(annotation_type)
2938+
elif annotation_origin is NotRequired:
2939+
yield NotRequired
2940+
(annotation_type,) = get_args(annotation_type)
2941+
elif annotation_origin is ReadOnly:
2942+
yield ReadOnly
2943+
(annotation_type,) = get_args(annotation_type)
2944+
else:
2945+
break
2946+
2947+
29252948
class _TypedDictMeta(type):
29262949
def __new__(cls, name, bases, ns, total=True):
29272950
"""Create a new typed dict class object.
@@ -2955,6 +2978,8 @@ def __new__(cls, name, bases, ns, total=True):
29552978
}
29562979
required_keys = set()
29572980
optional_keys = set()
2981+
readonly_keys = set()
2982+
mutable_keys = set()
29582983

29592984
for base in bases:
29602985
annotations.update(base.__dict__.get('__annotations__', {}))
@@ -2967,18 +2992,15 @@ def __new__(cls, name, bases, ns, total=True):
29672992
required_keys -= base_optional
29682993
optional_keys |= base_optional
29692994

2995+
readonly_keys.update(base.__dict__.get('__readonly_keys__', ()))
2996+
mutable_keys.update(base.__dict__.get('__mutable_keys__', ()))
2997+
29702998
annotations.update(own_annotations)
29712999
for annotation_key, annotation_type in own_annotations.items():
2972-
annotation_origin = get_origin(annotation_type)
2973-
if annotation_origin is Annotated:
2974-
annotation_args = get_args(annotation_type)
2975-
if annotation_args:
2976-
annotation_type = annotation_args[0]
2977-
annotation_origin = get_origin(annotation_type)
2978-
2979-
if annotation_origin is Required:
3000+
qualifiers = set(_get_typeddict_qualifiers(annotation_type))
3001+
if Required in qualifiers:
29803002
is_required = True
2981-
elif annotation_origin is NotRequired:
3003+
elif NotRequired in qualifiers:
29823004
is_required = False
29833005
else:
29843006
is_required = total
@@ -2990,13 +3012,26 @@ def __new__(cls, name, bases, ns, total=True):
29903012
optional_keys.add(annotation_key)
29913013
required_keys.discard(annotation_key)
29923014

3015+
if ReadOnly in qualifiers:
3016+
if annotation_key in mutable_keys:
3017+
raise TypeError(
3018+
f"Cannot override mutable key {annotation_key!r}"
3019+
" with read-only key"
3020+
)
3021+
readonly_keys.add(annotation_key)
3022+
else:
3023+
mutable_keys.add(annotation_key)
3024+
readonly_keys.discard(annotation_key)
3025+
29933026
assert required_keys.isdisjoint(optional_keys), (
29943027
f"Required keys overlap with optional keys in {name}:"
29953028
f" {required_keys=}, {optional_keys=}"
29963029
)
29973030
tp_dict.__annotations__ = annotations
29983031
tp_dict.__required_keys__ = frozenset(required_keys)
29993032
tp_dict.__optional_keys__ = frozenset(optional_keys)
3033+
tp_dict.__readonly_keys__ = frozenset(readonly_keys)
3034+
tp_dict.__mutable_keys__ = frozenset(mutable_keys)
30003035
tp_dict.__total__ = total
30013036
return tp_dict
30023037

@@ -3055,6 +3090,14 @@ class Point2D(TypedDict):
30553090
y: NotRequired[int] # the "y" key can be omitted
30563091
30573092
See PEP 655 for more details on Required and NotRequired.
3093+
3094+
The ReadOnly special form can be used
3095+
to mark individual keys as immutable for type checkers::
3096+
3097+
class DatabaseUser(TypedDict):
3098+
id: ReadOnly[int] # the "id" key must not be modified
3099+
username: str # the "username" key can be changed
3100+
30583101
"""
30593102
if fields is _sentinel or fields is None:
30603103
import warnings
@@ -3131,6 +3174,26 @@ class Movie(TypedDict):
31313174
return _GenericAlias(self, (item,))
31323175

31333176

3177+
@_SpecialForm
3178+
def ReadOnly(self, parameters):
3179+
"""A special typing construct to mark an item of a TypedDict as read-only.
3180+
3181+
For example::
3182+
3183+
class Movie(TypedDict):
3184+
title: ReadOnly[str]
3185+
year: int
3186+
3187+
def mutate_movie(m: Movie) -> None:
3188+
m["year"] = 1992 # allowed
3189+
m["title"] = "The Matrix" # typechecker error
3190+
3191+
There is no runtime checking for this property.
3192+
"""
3193+
item = _type_check(parameters, f'{self._name} accepts only a single type.')
3194+
return _GenericAlias(self, (item,))
3195+
3196+
31343197
class NewType:
31353198
"""NewType creates simple unique types with almost zero runtime overhead.
31363199
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:mod:`typing`: implement :pep:`705` which adds :data:`typing.ReadOnly`
2+
support to :class:`typing.TypedDict`.

0 commit comments

Comments
 (0)
0