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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy