From 50819321041c9b23e2b94247eddf8bf8ce9eb039 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 18 Sep 2023 08:31:17 -0700 Subject: [PATCH 1/8] Add support for PEP 705 --- CHANGELOG.md | 6 ++ doc/index.rst | 51 +++++++++++++++- src/test_typing_extensions.py | 96 ++++++++++++++++++++++++++++- src/typing_extensions.py | 112 ++++++++++++++++++++++++++++++---- 4 files changed, 252 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f0487d..5a070036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# Unreleased + +- Add support for PEP 705, adding `typing_extensions.ReadOnly` and the + `readonly=True` and `other_keys=False` arguments to `TypedDict`. Patch + by Jelle Zijlstra. + # Release 4.8.0 (September 17, 2023) No changes since 4.8.0rc1. diff --git a/doc/index.rst b/doc/index.rst index 28b795a3..a25e2ec8 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -318,6 +318,12 @@ Special typing primitives present in a protocol class's :py:term:`method resolution order`. See :issue:`245` for some examples. +.. data:: ReadOnly + + See :pep:`705`. Indicates that a :class:`TypedDict` key may not be modified. + + .. versionadded:: 4.9.0 + .. data:: Required See :py:data:`typing.Required` and :pep:`655`. In ``typing`` since 3.11. @@ -344,7 +350,7 @@ Special typing primitives See :py:data:`typing.TypeGuard` and :pep:`647`. In ``typing`` since 3.10. -.. class:: TypedDict +.. class:: TypedDict(dict, total=True, readonly=False, other_keys=True) See :py:class:`typing.TypedDict` and :pep:`589`. In ``typing`` since 3.8. @@ -366,6 +372,45 @@ Special typing primitives raises a :py:exc:`DeprecationWarning` when this syntax is used in Python 3.12 or lower and fails with a :py:exc:`TypeError` in Python 3.13 and higher. + ``typing_extensions`` supports the experimental additions to ``TypedDict`` + proposed by :pep:`705`. These are implemented in the following attributes:: + + .. attribute:: __readonly_keys__ + + A :py:class:`frozenset` containing the names of all read-only keys. Keys + are read-only if they are declared in a ``TypedDict`` with the + ``readonly=True`` argument, or if they carry the :data:`ReadOnly` qualifier. + + .. versionadded:: 4.9.0 + + .. attribute:: __mutable_keys__ + + A :py:class:`frozenset` containing the names of all mutable keys. Keys + are mutable if they are declared in a ``TypedDict`` with the + ``readonly=False`` argument, or if they do not carry the :data:`ReadOnly` + qualifier. + + .. versionadded:: 4.9.0 + + .. attribute:: __readonly__ + + Boolean indicating whether the current class was declared with + the ``readonly=True`` argument. If this is true, all keys are read-only. + + .. versionadded:: 4.9.0 + + .. attribute:: __other_keys__ + + Boolean indicating the value of the ``other_keys=`` argument for + the current class, which indicates whether the ``TypedDict`` accepts + keys other than those explicitly declared in the class body. + + Note that if a base class has ``other_keys=False``, + but the current class does not, semantically the class will not accept + other keys, but this attribute will still be ``False``. + + .. versionadded:: 4.9.0 + .. versionchanged:: 4.3.0 Added support for generic ``TypedDict``\ s. @@ -394,6 +439,10 @@ Special typing primitives disallowed in Python 3.15. To create a TypedDict class with 0 fields, use ``class TD(TypedDict): pass`` or ``TD = TypedDict("TD", {})``. + .. versionchanged:: 4.9.0 + + Support for the ``readonly=`` and ``other_keys=`` arguments was added. + .. class:: TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False, infer_variance=False, default=...) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 97717bce..212f86d7 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -31,7 +31,7 @@ import typing_extensions from typing_extensions import NoReturn, Any, ClassVar, Final, IntVar, Literal, Type, NewType, TypedDict, Self from typing_extensions import TypeAlias, ParamSpec, Concatenate, ParamSpecArgs, ParamSpecKwargs, TypeGuard -from typing_extensions import Awaitable, AsyncIterator, AsyncContextManager, Required, NotRequired +from typing_extensions import Awaitable, AsyncIterator, AsyncContextManager, Required, NotRequired, ReadOnly from typing_extensions import Protocol, runtime, runtime_checkable, Annotated, final, is_typeddict from typing_extensions import TypeVarTuple, Unpack, dataclass_transform, reveal_type, Never, assert_never, LiteralString from typing_extensions import assert_type, get_type_hints, get_origin, get_args, get_original_bases @@ -3917,6 +3917,100 @@ class T4(TypedDict, Generic[S]): pass self.assertEqual(klass.__optional_keys__, set()) self.assertIsInstance(klass(), dict) + def test_readonly(self): + class TD1(TypedDict): + a: int + b: str + + self.assertEqual(TD1.__readonly_keys__, frozenset()) + self.assertEqual(TD1.__mutable_keys__, frozenset({'a', 'b'})) + + class TD2(TypedDict): + a: ReadOnly[int] + b: str + + self.assertEqual(TD2.__readonly_keys__, frozenset({'a'})) + self.assertEqual(TD2.__mutable_keys__, frozenset({'b'})) + + class TD3(TypedDict, readonly=True): + a: int + b: str + + self.assertEqual(TD3.__readonly_keys__, frozenset({'a', 'b'})) + self.assertEqual(TD3.__mutable_keys__, frozenset()) + + def test_cannot_combine_readonly_qualifier_and_kwarg(self): + with self.assertRaises(TypeError): + class TD(TypedDict, readonly=True): + a: ReadOnly[int] + + def test_readonly_inheritance(self): + class Base1(TypedDict, readonly=True): + a: 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_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'})) + + def test_other_keys(self): + class TD1(TypedDict): + a: int + + self.assertIs(TD1.__other_keys__, True) + + class TD2(TypedDict, other_keys=False): + a: int + + self.assertIs(TD2.__other_keys__, False) + + def test_cannot_add_fields_with_other_keys_false(self): + class TD(TypedDict, other_keys=False): + a: int + + with self.assertRaises(TypeError): + class TD2(TD): + b: int + + def test_can_narrow_other_keys(self): + class Base(TypedDict, other_keys=False): + a: ReadOnly[Optional[int]] + + class Child(Base): + a: int + + self.assertEqual(Child.__annotations__, {"a": int}) + class AnnotatedTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c96bf90f..7cfc3782 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -86,6 +86,7 @@ 'TYPE_CHECKING', 'Never', 'NoReturn', + 'ReadOnly', 'Required', 'NotRequired', @@ -767,7 +768,7 @@ def inner(func): return inner -if sys.version_info >= (3, 13): +if hasattr(typing, "ReadOnly"): # The standard library TypedDict in Python 3.8 does not store runtime information # about which (if any) keys are optional. See https://bugs.python.org/issue38834 # The standard library TypedDict in Python 3.9.0/1 does not honour the "total" @@ -778,6 +779,8 @@ def inner(func): # Aaaand on 3.12 we add __orig_bases__ to TypedDict # to enable better runtime introspection. # On 3.13 we deprecate some odd ways of creating TypedDicts. + # PEP 705 proposes adding read_only= and other_keys= to TypedDict, along with + # the ReadOnly[] qualifier. TypedDict = typing.TypedDict _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict @@ -785,8 +788,30 @@ def inner(func): # 3.10.0 and later _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters + 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): - def __new__(cls, name, bases, ns, total=True): + def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -829,35 +854,57 @@ def __new__(cls, name, bases, ns, total=True): } required_keys = set() optional_keys = set() + readonly_keys = set() + mutable_keys = set() for base in bases: annotations.update(base.__dict__.get('__annotations__', {})) required_keys.update(base.__dict__.get('__required_keys__', ())) optional_keys.update(base.__dict__.get('__optional_keys__', ())) + readonly_keys.update(base.__dict__.get('__readonly_keys__', ())) + mutable_keys.update(base.__dict__.get('__mutable_keys__', ())) + + if not getattr(base, "__other_keys__", True): + if set(own_annotations) - set(base.__dict__.get('__annotations__', {})): + raise TypeError( + "TypedDict cannot inherit from a TypedDict with " + "other_keys=False and new fields" + ) + if readonly and is_typeddict(base) and base is not _TypedDict and not getattr(base, "__readonly__", False): + raise TypeError(f"read-only TypedDict cannot extend non-read-only TypedDict {base}") annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): - 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] - annotation_origin = get_origin(annotation_type) - - if annotation_origin is Required: + qualifiers = list(_get_typeddict_qualifiers(annotation_type)) + + if Required in qualifiers: required_keys.add(annotation_key) - elif annotation_origin is NotRequired: + elif NotRequired in qualifiers: optional_keys.add(annotation_key) elif total: required_keys.add(annotation_key) else: optional_keys.add(annotation_key) + if ReadOnly in qualifiers: + if readonly: + raise TypeError("Using ReadOnly[] on a TypedDict with readonly=True is redundant") + 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) + elif readonly: + readonly_keys.add(annotation_key) + else: + mutable_keys.add(annotation_key) tp_dict.__annotations__ = annotations tp_dict.__required_keys__ = frozenset(required_keys) tp_dict.__optional_keys__ = frozenset(optional_keys) + tp_dict.__readonly_keys__ = frozenset(readonly_keys) + tp_dict.__mutable_keys__ = frozenset(mutable_keys) if not hasattr(tp_dict, '__total__'): tp_dict.__total__ = total + tp_dict.__readonly__ = readonly + tp_dict.__other_keys__ = other_keys return tp_dict __call__ = dict # static method @@ -1924,6 +1971,49 @@ class Movie(TypedDict): """) +if hasattr(typing, 'ReadOnly'): + ReadOnly = typing.ReadOnly +elif sys.version_info[:2] >= (3, 9): # 3.9-3.12 + @_ExtensionsSpecialForm + def ReadOnly(self, parameters): + """A special typing construct to mark a key 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 propery. + """ + item = typing._type_check(parameters, f'{self._name} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + +else: # 3.8 + class _ReadOnlyForm(_ExtensionsSpecialForm, _root=True): + def __getitem__(self, parameters): + item = typing._type_check(parameters, + f'{self._name} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + + ReadOnly = _ReadOnlyForm( + 'ReadOnly', + doc="""A special typing construct to mark a key 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 propery. + """) + + _UNPACK_DOC = """\ Type unpack operator. From 3192691ee7327e646a885cfedb2ec92051a32ef1 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 18 Sep 2023 08:34:41 -0700 Subject: [PATCH 2/8] formatting --- src/typing_extensions.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 7cfc3782..a3ae4179 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -809,7 +809,6 @@ def _get_typeddict_qualifiers(annotation_type): else: break - class _TypedDictMeta(type): def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True): """Create new typed dict class object. @@ -865,13 +864,24 @@ def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True mutable_keys.update(base.__dict__.get('__mutable_keys__', ())) if not getattr(base, "__other_keys__", True): - if set(own_annotations) - set(base.__dict__.get('__annotations__', {})): + if ( + set(own_annotations) - + set(base.__dict__.get('__annotations__', {})) + ): raise TypeError( "TypedDict cannot inherit from a TypedDict with " "other_keys=False and new fields" ) - if readonly and is_typeddict(base) and base is not _TypedDict and not getattr(base, "__readonly__", False): - raise TypeError(f"read-only TypedDict cannot extend non-read-only TypedDict {base}") + if ( + readonly + and is_typeddict(base) + and base is not _TypedDict + and not getattr(base, "__readonly__", False) + ): + raise TypeError( + "read-only TypedDict cannot extend non-read-only " + f"TypedDict {base}" + ) annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): @@ -887,9 +897,14 @@ def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True optional_keys.add(annotation_key) if ReadOnly in qualifiers: if readonly: - raise TypeError("Using ReadOnly[] on a TypedDict with readonly=True is redundant") + raise TypeError( + "Using ReadOnly[] on a TypedDict with readonly=True " + "is redundant") if annotation_key in mutable_keys: - raise TypeError(f"Cannot override mutable key {annotation_key!r} with read-only key") + raise TypeError( + f"Cannot override mutable key {annotation_key!r}" + " with read-only key" + ) readonly_keys.add(annotation_key) elif readonly: readonly_keys.add(annotation_key) @@ -1976,7 +1991,8 @@ class Movie(TypedDict): elif sys.version_info[:2] >= (3, 9): # 3.9-3.12 @_ExtensionsSpecialForm def ReadOnly(self, parameters): - """A special typing construct to mark a key of a TypedDict as read-only. For example: + """A special typing construct to mark a key of a TypedDict as read-only. + For example: class Movie(TypedDict): title: ReadOnly[str] @@ -2000,7 +2016,8 @@ def __getitem__(self, parameters): ReadOnly = _ReadOnlyForm( 'ReadOnly', - doc="""A special typing construct to mark a key of a TypedDict as read-only. For example: + doc="""A special typing construct to mark a key of a TypedDict as read-only. + For example: class Movie(TypedDict): title: ReadOnly[str] From c137ccd825e5e3bdf66c793e8367c616d37bb4ff Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 18:05:22 -0800 Subject: [PATCH 3/8] only ReadOnly --- CHANGELOG.md | 3 +- doc/index.rst | 34 ++++---------------- src/test_typing_extensions.py | 59 ++--------------------------------- src/typing_extensions.py | 23 ++------------ 4 files changed, 11 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 925c4cb5..a5cfb502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ # Release 4.9.0 (???) -- Add support for PEP 705, adding `typing_extensions.ReadOnly` and the - `readonly=True` and `other_keys=False` arguments to `TypedDict`. Patch +- Add support for PEP 705, adding `typing_extensions.ReadOnly`. Patch by Jelle Zijlstra. - All parameters on `NewType.__call__` are now positional-only. This means that the signature of `typing_extensions.NewType.__call__` now exactly matches the diff --git a/doc/index.rst b/doc/index.rst index d7eb00db..fa5b1a02 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -350,7 +350,7 @@ Special typing primitives See :py:data:`typing.TypeGuard` and :pep:`647`. In ``typing`` since 3.10. -.. class:: TypedDict(dict, total=True, readonly=False, other_keys=True) +.. class:: TypedDict(dict, total=True) See :py:class:`typing.TypedDict` and :pep:`589`. In ``typing`` since 3.8. @@ -372,42 +372,20 @@ Special typing primitives raises a :py:exc:`DeprecationWarning` when this syntax is used in Python 3.12 or lower and fails with a :py:exc:`TypeError` in Python 3.13 and higher. - ``typing_extensions`` supports the experimental additions to ``TypedDict`` - proposed by :pep:`705`. These are implemented in the following attributes:: + ``typing_extensions`` supports the experimental :data:`ReadOnly` qualifier + proposed by :pep:`705`. It is reflected in the following attributes:: .. attribute:: __readonly_keys__ A :py:class:`frozenset` containing the names of all read-only keys. Keys - are read-only if they are declared in a ``TypedDict`` with the - ``readonly=True`` argument, or if they carry the :data:`ReadOnly` qualifier. + are read-only if they carry the :data:`ReadOnly` qualifier. .. versionadded:: 4.9.0 .. attribute:: __mutable_keys__ A :py:class:`frozenset` containing the names of all mutable keys. Keys - are mutable if they are declared in a ``TypedDict`` with the - ``readonly=False`` argument, or if they do not carry the :data:`ReadOnly` - qualifier. - - .. versionadded:: 4.9.0 - - .. attribute:: __readonly__ - - Boolean indicating whether the current class was declared with - the ``readonly=True`` argument. If this is true, all keys are read-only. - - .. versionadded:: 4.9.0 - - .. attribute:: __other_keys__ - - Boolean indicating the value of the ``other_keys=`` argument for - the current class, which indicates whether the ``TypedDict`` accepts - keys other than those explicitly declared in the class body. - - Note that if a base class has ``other_keys=False``, - but the current class does not, semantically the class will not accept - other keys, but this attribute will still be ``False``. + are mutable if they do not carry the :data:`ReadOnly` qualifier. .. versionadded:: 4.9.0 @@ -441,7 +419,7 @@ Special typing primitives .. versionchanged:: 4.9.0 - Support for the ``readonly=`` and ``other_keys=`` arguments was added. + Support for the :data:`ReadOnly` qualifier was added. .. class:: TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False, infer_variance=False, default=...) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index df8eca17..e27e0c76 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4047,36 +4047,9 @@ class T4(TypedDict, Generic[S]): pass self.assertEqual(klass.__optional_keys__, set()) self.assertIsInstance(klass(), dict) - def test_readonly(self): - class TD1(TypedDict): - a: int - b: str - - self.assertEqual(TD1.__readonly_keys__, frozenset()) - self.assertEqual(TD1.__mutable_keys__, frozenset({'a', 'b'})) - - class TD2(TypedDict): - a: ReadOnly[int] - b: str - - self.assertEqual(TD2.__readonly_keys__, frozenset({'a'})) - self.assertEqual(TD2.__mutable_keys__, frozenset({'b'})) - - class TD3(TypedDict, readonly=True): - a: int - b: str - - self.assertEqual(TD3.__readonly_keys__, frozenset({'a', 'b'})) - self.assertEqual(TD3.__mutable_keys__, frozenset()) - - def test_cannot_combine_readonly_qualifier_and_kwarg(self): - with self.assertRaises(TypeError): - class TD(TypedDict, readonly=True): - a: ReadOnly[int] - def test_readonly_inheritance(self): - class Base1(TypedDict, readonly=True): - a: int + class Base1(TypedDict): + a: ReadOnly[int] class Child1(Base1): b: str @@ -4113,34 +4086,6 @@ class AllTheThings(TypedDict): self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'})) self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'})) - def test_other_keys(self): - class TD1(TypedDict): - a: int - - self.assertIs(TD1.__other_keys__, True) - - class TD2(TypedDict, other_keys=False): - a: int - - self.assertIs(TD2.__other_keys__, False) - - def test_cannot_add_fields_with_other_keys_false(self): - class TD(TypedDict, other_keys=False): - a: int - - with self.assertRaises(TypeError): - class TD2(TD): - b: int - - def test_can_narrow_other_keys(self): - class Base(TypedDict, other_keys=False): - a: ReadOnly[Optional[int]] - - class Child(Base): - a: int - - self.assertEqual(Child.__annotations__, {"a": int}) - class AnnotatedTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 3e5a4e42..02c6b420 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -780,8 +780,7 @@ def inner(func): # Aaaand on 3.12 we add __orig_bases__ to TypedDict # to enable better runtime introspection. # On 3.13 we deprecate some odd ways of creating TypedDicts. - # PEP 705 proposes adding read_only= and other_keys= to TypedDict, along with - # the ReadOnly[] qualifier. + # PEP 705 proposes adding the ReadOnly[] qualifier. TypedDict = typing.TypedDict _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict @@ -811,7 +810,7 @@ def _get_typeddict_qualifiers(annotation_type): break class _TypedDictMeta(type): - def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True): + def __new__(cls, name, bases, ns, *, total=True): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -873,16 +872,6 @@ def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True "TypedDict cannot inherit from a TypedDict with " "other_keys=False and new fields" ) - if ( - readonly - and is_typeddict(base) - and base is not _TypedDict - and not getattr(base, "__readonly__", False) - ): - raise TypeError( - "read-only TypedDict cannot extend non-read-only " - f"TypedDict {base}" - ) annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): @@ -897,18 +886,12 @@ def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True else: optional_keys.add(annotation_key) if ReadOnly in qualifiers: - if readonly: - raise TypeError( - "Using ReadOnly[] on a TypedDict with readonly=True " - "is redundant") 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) - elif readonly: - readonly_keys.add(annotation_key) else: mutable_keys.add(annotation_key) @@ -919,8 +902,6 @@ def __new__(cls, name, bases, ns, *, total=True, readonly=False, other_keys=True tp_dict.__mutable_keys__ = frozenset(mutable_keys) if not hasattr(tp_dict, '__total__'): tp_dict.__total__ = total - tp_dict.__readonly__ = readonly - tp_dict.__other_keys__ = other_keys return tp_dict __call__ = dict # static method From 7c781d5fdfa017743dba20a43a74bdb2961ceb03 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 18:10:06 -0800 Subject: [PATCH 4/8] Code review fixes --- doc/index.rst | 2 +- src/test_typing_extensions.py | 10 ++++++++++ src/typing_extensions.py | 13 ++----------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index fa5b1a02..76ba1a50 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -320,7 +320,7 @@ Special typing primitives .. data:: ReadOnly - See :pep:`705`. Indicates that a :class:`TypedDict` key may not be modified. + See :pep:`705`. Indicates that a :class:`TypedDict` item may not be modified. .. versionadded:: 4.9.0 diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index e27e0c76..47da16a7 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4074,6 +4074,16 @@ class Base(TypedDict): 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"] diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 02c6b420..8b21e8ec 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -863,19 +863,9 @@ def __new__(cls, name, bases, ns, *, total=True): readonly_keys.update(base.__dict__.get('__readonly_keys__', ())) mutable_keys.update(base.__dict__.get('__mutable_keys__', ())) - if not getattr(base, "__other_keys__", True): - if ( - set(own_annotations) - - set(base.__dict__.get('__annotations__', {})) - ): - raise TypeError( - "TypedDict cannot inherit from a TypedDict with " - "other_keys=False and new fields" - ) - annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): - qualifiers = list(_get_typeddict_qualifiers(annotation_type)) + qualifiers = set(_get_typeddict_qualifiers(annotation_type)) if Required in qualifiers: required_keys.add(annotation_key) @@ -894,6 +884,7 @@ def __new__(cls, name, bases, ns, *, total=True): readonly_keys.add(annotation_key) else: mutable_keys.add(annotation_key) + readonly_keys.discard(annotation_key) tp_dict.__annotations__ = annotations tp_dict.__required_keys__ = frozenset(required_keys) From d223307339f977a52e8144c59e1d84f41922ff00 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Nov 2023 18:32:26 -0800 Subject: [PATCH 5/8] Fix 3.13 --- src/test_typing_extensions.py | 6 ++++-- src/typing_extensions.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 47da16a7..13b8d048 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3520,7 +3520,7 @@ def test_typeddict_create_errors(self): def test_typeddict_errors(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) - if sys.version_info >= (3, 13): + if hasattr(typing, "ReadOnly"): self.assertEqual(TypedDict.__module__, 'typing') else: self.assertEqual(TypedDict.__module__, 'typing_extensions') @@ -5236,7 +5236,9 @@ def test_typing_extensions_defers_when_possible(self): 'SupportsRound', 'Unpack', } if sys.version_info < (3, 13): - exclude |= {'NamedTuple', 'Protocol', 'TypedDict', 'is_typeddict'} + exclude |= {'NamedTuple', 'Protocol'} + if not hasattr(typing, 'ReadOnly'): + exclude |= {'TypedDict', 'is_typeddict'} for item in typing_extensions.__all__: if item not in exclude and hasattr(typing, item): self.assertIs( diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 8b21e8ec..73748b86 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -971,6 +971,8 @@ class Point2D(TypedDict): raise TypeError("TypedDict takes either a dict or keyword arguments," " but not both") if kwargs: + if sys.version_info >= (3, 13): + raise TypeError("TypedDict takes no keyword arguments") warnings.warn( "The kwargs-based syntax for TypedDict definitions is deprecated " "in Python 3.11, will be removed in Python 3.13, and may not be " From e865252176991a0efc59d60bccea19c9113cf7cc Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 06:47:53 -0800 Subject: [PATCH 6/8] Apply suggestions from code review Co-authored-by: Alice --- src/typing_extensions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 73748b86..be09bf30 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1966,7 +1966,7 @@ class Movie(TypedDict): elif sys.version_info[:2] >= (3, 9): # 3.9-3.12 @_ExtensionsSpecialForm def ReadOnly(self, parameters): - """A special typing construct to mark a key of a TypedDict as read-only. + """A special typing construct to mark an item of a TypedDict as read-only. For example: class Movie(TypedDict): @@ -1977,7 +1977,7 @@ def mutate_movie(m: Movie) -> None: m["year"] = 1992 # allowed m["title"] = "The Matrix" # typechecker error - There is no runtime checking for this propery. + There is no runtime checking for this property. """ item = typing._type_check(parameters, f'{self._name} accepts only a single type.') return typing._GenericAlias(self, (item,)) From 597c07acca2891e5e31b7530e97f667256af6031 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 09:39:53 -0800 Subject: [PATCH 7/8] Update src/test_typing_extensions.py Co-authored-by: Alex Waygood --- src/test_typing_extensions.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 13b8d048..4bce90e9 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3520,10 +3520,7 @@ def test_typeddict_create_errors(self): def test_typeddict_errors(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) - if hasattr(typing, "ReadOnly"): - self.assertEqual(TypedDict.__module__, 'typing') - else: - self.assertEqual(TypedDict.__module__, 'typing_extensions') + self.assertEqual(TypedDict.__module__, 'typing_extensions') jim = Emp(name='Jim', id=1) with self.assertRaises(TypeError): isinstance({}, Emp) From deb966de0b179805db52e1c081eebb8ffd187135 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 09:40:34 -0800 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Alex Waygood --- src/typing_extensions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index be09bf30..e9eb73cd 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -857,11 +857,13 @@ def __new__(cls, name, bases, ns, *, total=True): mutable_keys = set() for base in bases: - annotations.update(base.__dict__.get('__annotations__', {})) - required_keys.update(base.__dict__.get('__required_keys__', ())) - optional_keys.update(base.__dict__.get('__optional_keys__', ())) - readonly_keys.update(base.__dict__.get('__readonly_keys__', ())) - mutable_keys.update(base.__dict__.get('__mutable_keys__', ())) + base_dict = base.__dict__ + + annotations.update(base_dict.get('__annotations__', {})) + required_keys.update(base_dict.get('__required_keys__', ())) + optional_keys.update(base_dict.get('__optional_keys__', ())) + readonly_keys.update(base_dict.get('__readonly_keys__', ())) + mutable_keys.update(base_dict.get('__mutable_keys__', ())) annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): @@ -1967,6 +1969,7 @@ class Movie(TypedDict): @_ExtensionsSpecialForm def ReadOnly(self, parameters): """A special typing construct to mark an item of a TypedDict as read-only. + For example: class Movie(TypedDict): @@ -1992,6 +1995,7 @@ def __getitem__(self, parameters): ReadOnly = _ReadOnlyForm( 'ReadOnly', doc="""A special typing construct to mark a key of a TypedDict as read-only. + For example: class Movie(TypedDict):