Skip to content

Commit 0b0166d

Browse files
JelleZijlstraalicederynAlexWaygood
authored
Add support for PEP 705 (#284)
Co-authored-by: Alice <[email protected]> Co-authored-by: Alex Waygood <[email protected]>
1 parent db6f9b4 commit 0b0166d

File tree

4 files changed

+183
-21
lines changed

4 files changed

+183
-21
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Release 4.9.0 (???)
22

3+
- Add support for PEP 705, adding `typing_extensions.ReadOnly`. Patch
4+
by Jelle Zijlstra.
35
- All parameters on `NewType.__call__` are now positional-only. This means that
46
the signature of `typing_extensions.NewType.__call__` now exactly matches the
57
signature of `typing.NewType.__call__`. Patch by Alex Waygood.

doc/index.rst

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,12 @@ Special typing primitives
318318
present in a protocol class's :py:term:`method resolution order`. See
319319
:issue:`245` for some examples.
320320

321+
.. data:: ReadOnly
322+
323+
See :pep:`705`. Indicates that a :class:`TypedDict` item may not be modified.
324+
325+
.. versionadded:: 4.9.0
326+
321327
.. data:: Required
322328

323329
See :py:data:`typing.Required` and :pep:`655`. In ``typing`` since 3.11.
@@ -344,7 +350,7 @@ Special typing primitives
344350

345351
See :py:data:`typing.TypeGuard` and :pep:`647`. In ``typing`` since 3.10.
346352

347-
.. class:: TypedDict
353+
.. class:: TypedDict(dict, total=True)
348354

349355
See :py:class:`typing.TypedDict` and :pep:`589`. In ``typing`` since 3.8.
350356

@@ -366,6 +372,23 @@ Special typing primitives
366372
raises a :py:exc:`DeprecationWarning` when this syntax is used in Python 3.12
367373
or lower and fails with a :py:exc:`TypeError` in Python 3.13 and higher.
368374

375+
``typing_extensions`` supports the experimental :data:`ReadOnly` qualifier
376+
proposed by :pep:`705`. It is reflected in the following attributes::
377+
378+
.. attribute:: __readonly_keys__
379+
380+
A :py:class:`frozenset` containing the names of all read-only keys. Keys
381+
are read-only if they carry the :data:`ReadOnly` qualifier.
382+
383+
.. versionadded:: 4.9.0
384+
385+
.. attribute:: __mutable_keys__
386+
387+
A :py:class:`frozenset` containing the names of all mutable keys. Keys
388+
are mutable if they do not carry the :data:`ReadOnly` qualifier.
389+
390+
.. versionadded:: 4.9.0
391+
369392
.. versionchanged:: 4.3.0
370393

371394
Added support for generic ``TypedDict``\ s.
@@ -394,6 +417,10 @@ Special typing primitives
394417
disallowed in Python 3.15. To create a TypedDict class with 0 fields,
395418
use ``class TD(TypedDict): pass`` or ``TD = TypedDict("TD", {})``.
396419

420+
.. versionchanged:: 4.9.0
421+
422+
Support for the :data:`ReadOnly` qualifier was added.
423+
397424
.. class:: TypeVar(name, *constraints, bound=None, covariant=False,
398425
contravariant=False, infer_variance=False, default=...)
399426

src/test_typing_extensions.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import typing_extensions
3232
from typing_extensions import NoReturn, Any, ClassVar, Final, IntVar, Literal, Type, NewType, TypedDict, Self
3333
from typing_extensions import TypeAlias, ParamSpec, Concatenate, ParamSpecArgs, ParamSpecKwargs, TypeGuard
34-
from typing_extensions import Awaitable, AsyncIterator, AsyncContextManager, Required, NotRequired
34+
from typing_extensions import Awaitable, AsyncIterator, AsyncContextManager, Required, NotRequired, ReadOnly
3535
from typing_extensions import Protocol, runtime, runtime_checkable, Annotated, final, is_typeddict
3636
from typing_extensions import TypeVarTuple, Unpack, dataclass_transform, reveal_type, Never, assert_never, LiteralString
3737
from typing_extensions import assert_type, get_type_hints, get_origin, get_args, get_original_bases
@@ -3550,10 +3550,7 @@ def test_typeddict_create_errors(self):
35503550

35513551
def test_typeddict_errors(self):
35523552
Emp = TypedDict('Emp', {'name': str, 'id': int})
3553-
if sys.version_info >= (3, 13):
3554-
self.assertEqual(TypedDict.__module__, 'typing')
3555-
else:
3556-
self.assertEqual(TypedDict.__module__, 'typing_extensions')
3553+
self.assertEqual(TypedDict.__module__, 'typing_extensions')
35573554
jim = Emp(name='Jim', id=1)
35583555
with self.assertRaises(TypeError):
35593556
isinstance({}, Emp)
@@ -4077,6 +4074,55 @@ class T4(TypedDict, Generic[S]): pass
40774074
self.assertEqual(klass.__optional_keys__, set())
40784075
self.assertIsInstance(klass(), dict)
40794076

4077+
def test_readonly_inheritance(self):
4078+
class Base1(TypedDict):
4079+
a: ReadOnly[int]
4080+
4081+
class Child1(Base1):
4082+
b: str
4083+
4084+
self.assertEqual(Child1.__readonly_keys__, frozenset({'a'}))
4085+
self.assertEqual(Child1.__mutable_keys__, frozenset({'b'}))
4086+
4087+
class Base2(TypedDict):
4088+
a: ReadOnly[int]
4089+
4090+
class Child2(Base2):
4091+
b: str
4092+
4093+
self.assertEqual(Child1.__readonly_keys__, frozenset({'a'}))
4094+
self.assertEqual(Child1.__mutable_keys__, frozenset({'b'}))
4095+
4096+
def test_cannot_make_mutable_key_readonly(self):
4097+
class Base(TypedDict):
4098+
a: int
4099+
4100+
with self.assertRaises(TypeError):
4101+
class Child(Base):
4102+
a: ReadOnly[int]
4103+
4104+
def test_can_make_readonly_key_mutable(self):
4105+
class Base(TypedDict):
4106+
a: ReadOnly[int]
4107+
4108+
class Child(Base):
4109+
a: int
4110+
4111+
self.assertEqual(Child.__readonly_keys__, frozenset())
4112+
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))
4113+
4114+
def test_combine_qualifiers(self):
4115+
class AllTheThings(TypedDict):
4116+
a: Annotated[Required[ReadOnly[int]], "why not"]
4117+
b: Required[Annotated[ReadOnly[int], "why not"]]
4118+
c: ReadOnly[NotRequired[Annotated[int, "why not"]]]
4119+
d: NotRequired[Annotated[int, "why not"]]
4120+
4121+
self.assertEqual(AllTheThings.__required_keys__, frozenset({'a', 'b'}))
4122+
self.assertEqual(AllTheThings.__optional_keys__, frozenset({'c', 'd'}))
4123+
self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'}))
4124+
self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'}))
4125+
40804126

40814127
class AnnotatedTests(BaseTestCase):
40824128

@@ -5217,7 +5263,9 @@ def test_typing_extensions_defers_when_possible(self):
52175263
'SupportsRound', 'Unpack',
52185264
}
52195265
if sys.version_info < (3, 13):
5220-
exclude |= {'NamedTuple', 'Protocol', 'TypedDict', 'is_typeddict'}
5266+
exclude |= {'NamedTuple', 'Protocol'}
5267+
if not hasattr(typing, 'ReadOnly'):
5268+
exclude |= {'TypedDict', 'is_typeddict'}
52215269
for item in typing_extensions.__all__:
52225270
if item not in exclude and hasattr(typing, item):
52235271
self.assertIs(

src/typing_extensions.py

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
'TYPE_CHECKING',
8787
'Never',
8888
'NoReturn',
89+
'ReadOnly',
8990
'Required',
9091
'NotRequired',
9192

@@ -773,7 +774,7 @@ def inner(func):
773774
return inner
774775

775776

776-
if sys.version_info >= (3, 13):
777+
if hasattr(typing, "ReadOnly"):
777778
# The standard library TypedDict in Python 3.8 does not store runtime information
778779
# about which (if any) keys are optional. See https://bugs.python.org/issue38834
779780
# The standard library TypedDict in Python 3.9.0/1 does not honour the "total"
@@ -784,15 +785,37 @@ def inner(func):
784785
# Aaaand on 3.12 we add __orig_bases__ to TypedDict
785786
# to enable better runtime introspection.
786787
# On 3.13 we deprecate some odd ways of creating TypedDicts.
788+
# PEP 705 proposes adding the ReadOnly[] qualifier.
787789
TypedDict = typing.TypedDict
788790
_TypedDictMeta = typing._TypedDictMeta
789791
is_typeddict = typing.is_typeddict
790792
else:
791793
# 3.10.0 and later
792794
_TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters
793795

796+
def _get_typeddict_qualifiers(annotation_type):
797+
while True:
798+
annotation_origin = get_origin(annotation_type)
799+
if annotation_origin is Annotated:
800+
annotation_args = get_args(annotation_type)
801+
if annotation_args:
802+
annotation_type = annotation_args[0]
803+
else:
804+
break
805+
elif annotation_origin is Required:
806+
yield Required
807+
annotation_type, = get_args(annotation_type)
808+
elif annotation_origin is NotRequired:
809+
yield NotRequired
810+
annotation_type, = get_args(annotation_type)
811+
elif annotation_origin is ReadOnly:
812+
yield ReadOnly
813+
annotation_type, = get_args(annotation_type)
814+
else:
815+
break
816+
794817
class _TypedDictMeta(type):
795-
def __new__(cls, name, bases, ns, total=True):
818+
def __new__(cls, name, bases, ns, *, total=True):
796819
"""Create new typed dict class object.
797820
798821
This method is called when TypedDict is subclassed,
@@ -835,33 +858,46 @@ def __new__(cls, name, bases, ns, total=True):
835858
}
836859
required_keys = set()
837860
optional_keys = set()
861+
readonly_keys = set()
862+
mutable_keys = set()
838863

839864
for base in bases:
840-
annotations.update(base.__dict__.get('__annotations__', {}))
841-
required_keys.update(base.__dict__.get('__required_keys__', ()))
842-
optional_keys.update(base.__dict__.get('__optional_keys__', ()))
865+
base_dict = base.__dict__
866+
867+
annotations.update(base_dict.get('__annotations__', {}))
868+
required_keys.update(base_dict.get('__required_keys__', ()))
869+
optional_keys.update(base_dict.get('__optional_keys__', ()))
870+
readonly_keys.update(base_dict.get('__readonly_keys__', ()))
871+
mutable_keys.update(base_dict.get('__mutable_keys__', ()))
843872

844873
annotations.update(own_annotations)
845874
for annotation_key, annotation_type in own_annotations.items():
846-
annotation_origin = get_origin(annotation_type)
847-
if annotation_origin is Annotated:
848-
annotation_args = get_args(annotation_type)
849-
if annotation_args:
850-
annotation_type = annotation_args[0]
851-
annotation_origin = get_origin(annotation_type)
852-
853-
if annotation_origin is Required:
875+
qualifiers = set(_get_typeddict_qualifiers(annotation_type))
876+
877+
if Required in qualifiers:
854878
required_keys.add(annotation_key)
855-
elif annotation_origin is NotRequired:
879+
elif NotRequired in qualifiers:
856880
optional_keys.add(annotation_key)
857881
elif total:
858882
required_keys.add(annotation_key)
859883
else:
860884
optional_keys.add(annotation_key)
885+
if ReadOnly in qualifiers:
886+
if annotation_key in mutable_keys:
887+
raise TypeError(
888+
f"Cannot override mutable key {annotation_key!r}"
889+
" with read-only key"
890+
)
891+
readonly_keys.add(annotation_key)
892+
else:
893+
mutable_keys.add(annotation_key)
894+
readonly_keys.discard(annotation_key)
861895

862896
tp_dict.__annotations__ = annotations
863897
tp_dict.__required_keys__ = frozenset(required_keys)
864898
tp_dict.__optional_keys__ = frozenset(optional_keys)
899+
tp_dict.__readonly_keys__ = frozenset(readonly_keys)
900+
tp_dict.__mutable_keys__ = frozenset(mutable_keys)
865901
if not hasattr(tp_dict, '__total__'):
866902
tp_dict.__total__ = total
867903
return tp_dict
@@ -942,6 +978,8 @@ class Point2D(TypedDict):
942978
raise TypeError("TypedDict takes either a dict or keyword arguments,"
943979
" but not both")
944980
if kwargs:
981+
if sys.version_info >= (3, 13):
982+
raise TypeError("TypedDict takes no keyword arguments")
945983
warnings.warn(
946984
"The kwargs-based syntax for TypedDict definitions is deprecated "
947985
"in Python 3.11, will be removed in Python 3.13, and may not be "
@@ -1930,6 +1968,53 @@ class Movie(TypedDict):
19301968
""")
19311969

19321970

1971+
if hasattr(typing, 'ReadOnly'):
1972+
ReadOnly = typing.ReadOnly
1973+
elif sys.version_info[:2] >= (3, 9): # 3.9-3.12
1974+
@_ExtensionsSpecialForm
1975+
def ReadOnly(self, parameters):
1976+
"""A special typing construct to mark an item of a TypedDict as read-only.
1977+
1978+
For example:
1979+
1980+
class Movie(TypedDict):
1981+
title: ReadOnly[str]
1982+
year: int
1983+
1984+
def mutate_movie(m: Movie) -> None:
1985+
m["year"] = 1992 # allowed
1986+
m["title"] = "The Matrix" # typechecker error
1987+
1988+
There is no runtime checking for this property.
1989+
"""
1990+
item = typing._type_check(parameters, f'{self._name} accepts only a single type.')
1991+
return typing._GenericAlias(self, (item,))
1992+
1993+
else: # 3.8
1994+
class _ReadOnlyForm(_ExtensionsSpecialForm, _root=True):
1995+
def __getitem__(self, parameters):
1996+
item = typing._type_check(parameters,
1997+
f'{self._name} accepts only a single type.')
1998+
return typing._GenericAlias(self, (item,))
1999+
2000+
ReadOnly = _ReadOnlyForm(
2001+
'ReadOnly',
2002+
doc="""A special typing construct to mark a key of a TypedDict as read-only.
2003+
2004+
For example:
2005+
2006+
class Movie(TypedDict):
2007+
title: ReadOnly[str]
2008+
year: int
2009+
2010+
def mutate_movie(m: Movie) -> None:
2011+
m["year"] = 1992 # allowed
2012+
m["title"] = "The Matrix" # typechecker error
2013+
2014+
There is no runtime checking for this propery.
2015+
""")
2016+
2017+
19332018
_UNPACK_DOC = """\
19342019
Type unpack operator.
19352020

0 commit comments

Comments
 (0)