From 5d20e9eed31de88667542ba5a6f66e6dc439b681 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 28 Sep 2023 14:35:35 +0100 Subject: [PATCH 01/28] Make `NewType.__call__` params positional-only (#288) This is really minor, but it means that the signature of `typing_extensions.NewType.__call__` exactly matches that of `typing.NewType.__call__` on all Python versions we support: ```pycon Python 3.8.16 (default, Mar 2 2023, 03:18:16) [MSC v.1916 64 bit (AMD64)] :: Anaconda, Inc. on win32 Type "help", "copyright", "credits" or "license" for more information. >>> from typing import NewType >>> x = NewType("x", int) >>> x(obj=42) Traceback (most recent call last): File "", line 1, in TypeError: new_type() got an unexpected keyword argument 'obj' ``` ```pycon Python 3.10.8 | packaged by conda-forge | (main, Nov 24 2022, 14:07:00) [MSC v.1916 64 bit (AMD64)] on win32 Type "help", "copyright", "credits" or "license" for more information. >>> x = NewType("x", int) Traceback (most recent call last): File "", line 1, in NameError: name 'NewType' is not defined >>> from typing import NewType >>> x = NewType("x", int) >>> x(obj=42) Traceback (most recent call last): File "", line 1, in TypeError: NewType.__call__() got an unexpected keyword argument 'obj' ``` --- CHANGELOG.md | 6 ++++++ src/typing_extensions.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f0487d..3ecde9cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# Release 4.9.0 (???) + +- All parameters on `NewType.__call__` are now positional-only. This means that + the signature of `typing_extensions.NewType.__call__` now exactly matches the + signature of `typing.NewType.__call__`. Patch by Alex Waygood. + # Release 4.8.0 (September 17, 2023) No changes since 4.8.0rc1. diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c96bf90f..58706dc9 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2600,7 +2600,7 @@ def name_by_id(user_id: UserId) -> str: num = UserId(5) + 1 # type: int """ - def __call__(self, obj): + def __call__(self, obj, /): return obj def __init__(self, name, tp): From b6318ab4b4777a38b468eef7aae97061c283eb8d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 3 Oct 2023 19:35:05 -0700 Subject: [PATCH 02/28] Add .readthedocs.yaml (#289) --- .readthedocs.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..83cd53cb --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + From 04f98954ba63a5e8a09c12171be24785298276b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Wed, 18 Oct 2023 20:26:19 +0200 Subject: [PATCH 03/28] Fix tests on Python 3.13 (#291) This is a followup for d95cc228ea96feec105592a9902e5b2d6cc048a9 The removed block raises TypeError on Python 3.13+. The TypeError is already asserted in test_keywords_syntax_raises_on_3_13. For older Pythons, the DeprecationWarning and __name__ + __annotations__ value are already asserted in test_typeddict_special_keyword_names. --- src/test_typing_extensions.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 97717bce..7d8e2553 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3383,11 +3383,6 @@ def test_typeddict_create_errors(self): with self.assertRaises(TypeError): TypedDict('Emp', [('name', str)], None) - with self.assertWarns(DeprecationWarning): - Emp = TypedDict('Emp', name=str, id=int) - self.assertEqual(Emp.__name__, 'Emp') - self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) - def test_typeddict_errors(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) if sys.version_info >= (3, 13): From fda0c15e3b5ed05703420cfb4d0974edb5e39c46 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 19 Oct 2023 21:39:22 +0300 Subject: [PATCH 04/28] Run tests on Python 3.13 (#292) * Run pydantic's tests on py312; bump PyPy to PyPy3.10 for typeguard's tests Pydantic started testing with py312 in https://github.com/pydantic/pydantic/commit/ea7bf54c2e46d03aedf76b1f661d88971688c7cf Typeguard bumped the version of PyPy they test with in CI in https://github.com/agronholm/typeguard/commit/9aa873a95d703130894fa28cb511ce04f1b6fd9f Co-authored-by: Alex Waygood --- .github/workflows/ci.yml | 5 +++-- .github/workflows/package.yml | 8 ++++---- .github/workflows/third_party.yml | 33 ++++++++++++++++--------------- tox.ini | 2 +- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e221022c..1c3d990e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: - "3.11" - "3.11.0" - "3.12" + - "3.13" - "pypy3.8" - "pypy3.9" - "pypy3.10" @@ -58,7 +59,7 @@ jobs: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 @@ -82,7 +83,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index ad2deee1..622861bc 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: 3 @@ -50,10 +50,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: 3 diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index b318e333..fcde71ca 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -41,12 +41,12 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "pypy3.9"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.9", "pypy3.10"] runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Checkout pydantic - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: pydantic/pydantic - name: Edit pydantic pyproject.toml @@ -54,13 +54,14 @@ jobs: # as a requirement unless we do this run: sed -i 's/^requires-python = .*/requires-python = ">=3.8"/' pyproject.toml - name: Checkout typing_extensions - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: typing-extensions-latest - name: Setup pdm for pydantic tests uses: pdm-project/setup-pdm@v3 with: python-version: ${{ matrix.python-version }} + allow-python-prereleases: true - name: Add local version of typing_extensions as a dependency run: pdm add ./typing-extensions-latest - name: Install pydantic test dependencies @@ -90,12 +91,12 @@ jobs: timeout-minutes: 60 steps: - name: Checkout typing_inspect - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: ilevkivskyi/typing_inspect path: typing_inspect - name: Checkout typing_extensions - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: typing-extensions-latest - name: Setup Python @@ -133,12 +134,12 @@ jobs: timeout-minutes: 60 steps: - name: Check out pyanalyze - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: quora/pyanalyze path: pyanalyze - name: Checkout typing_extensions - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: typing-extensions-latest - name: Setup Python @@ -172,17 +173,17 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.9"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.10"] runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Check out typeguard - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: agronholm/typeguard path: typeguard - name: Checkout typing_extensions - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: typing-extensions-latest - name: Setup Python @@ -221,12 +222,12 @@ jobs: timeout-minutes: 60 steps: - name: Check out typed-argument-parser - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: swansonk14/typed-argument-parser path: typed-argument-parser - name: Checkout typing_extensions - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: typing-extensions-latest - name: Setup Python @@ -272,12 +273,12 @@ jobs: timeout-minutes: 60 steps: - name: Checkout mypy for stubtest and mypyc tests - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: python/mypy path: mypy - name: Checkout typing_extensions - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: typing-extensions-latest - name: Setup Python @@ -319,11 +320,11 @@ jobs: timeout-minutes: 60 steps: - name: Checkout cattrs - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: python-attrs/cattrs - name: Checkout typing_extensions - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: typing-extensions-latest - name: Setup Python diff --git a/tox.ini b/tox.ini index 5bed0225..5be7adb8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = True -envlist = py38, py39, py310, py311, py312 +envlist = py38, py39, py310, py311, py312, py313 [testenv] changedir = src From 9de9fd613913faee5db317f827f5eec3755d8a92 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 29 Oct 2023 14:40:41 +0000 Subject: [PATCH 05/28] Raise if a non-`str` is passed as the first parameter to `@deprecated` (#296) --- CHANGELOG.md | 2 ++ src/test_typing_extensions.py | 15 +++++++++++++++ src/typing_extensions.py | 5 +++++ 3 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ecde9cf..43838db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ - All parameters on `NewType.__call__` are now positional-only. This means that the signature of `typing_extensions.NewType.__call__` now exactly matches the signature of `typing.NewType.__call__`. Patch by Alex Waygood. +- `typing.deprecated` now gives a better error message if you pass a non-`str` + argument to the `msg` parameter. Patch by Alex Waygood. # Release 4.8.0 (September 17, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 7d8e2553..151d44c6 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -480,6 +480,21 @@ def d(): warnings.simplefilter("error") d() + def test_only_strings_allowed(self): + with self.assertRaisesRegex( + TypeError, + "Expected an object of type str for 'msg', not 'type'" + ): + @deprecated + class Foo: ... + + with self.assertRaisesRegex( + TypeError, + "Expected an object of type str for 'msg', not 'function'" + ): + @deprecated + def foo(): ... + class AnyTests(BaseTestCase): def test_can_subclass(self): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 58706dc9..34affa97 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2331,6 +2331,11 @@ def g(x: str) -> int: ... See PEP 702 for details. """ + if not isinstance(msg, str): + raise TypeError( + f"Expected an object of type str for 'msg', not {type(msg).__name__!r}" + ) + def decorator(arg: _T, /) -> _T: if category is None: arg.__deprecated__ = msg From fc9acbdbfaf039fa98e59a6c5c1caa59efc2bb31 Mon Sep 17 00:00:00 2001 From: Nikita Sobolev Date: Tue, 31 Oct 2023 18:05:50 +0300 Subject: [PATCH 06/28] gh-110686: Fix pattern matching with `runtime_checkable` protocols (#290) --- CHANGELOG.md | 2 ++ src/test_typing_extensions.py | 37 +++++++++++++++++++++++++++++++++-- src/typing_extensions.py | 7 ++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43838db0..16775856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ signature of `typing.NewType.__call__`. Patch by Alex Waygood. - `typing.deprecated` now gives a better error message if you pass a non-`str` argument to the `msg` parameter. Patch by Alex Waygood. +- Exclude `__match_args__` from `Protocol` members, + this is a backport of https://github.com/python/cpython/pull/110683 # Release 4.8.0 (September 17, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 151d44c6..ffc84266 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2521,6 +2521,39 @@ class Bad: pass self.assertNotIsInstance(Other(), Concrete) self.assertIsInstance(NT(1, 2), Position) + def test_runtime_checkable_with_match_args(self): + @runtime_checkable + class P_regular(Protocol): + x: int + y: int + + @runtime_checkable + class P_match(Protocol): + __match_args__ = ("x", "y") + x: int + y: int + + class Regular: + def __init__(self, x: int, y: int): + self.x = x + self.y = y + + class WithMatch: + __match_args__ = ("x", "y", "z") + def __init__(self, x: int, y: int, z: int): + self.x = x + self.y = y + self.z = z + + class Nope: ... + + self.assertIsInstance(Regular(1, 2), P_regular) + self.assertIsInstance(Regular(1, 2), P_match) + self.assertIsInstance(WithMatch(1, 2, 3), P_regular) + self.assertIsInstance(WithMatch(1, 2, 3), P_match) + self.assertNotIsInstance(Nope(), P_regular) + self.assertNotIsInstance(Nope(), P_match) + def test_protocols_isinstance_init(self): T = TypeVar('T') @runtime_checkable @@ -5062,12 +5095,12 @@ def test_typing_extensions_defers_when_possible(self): exclude |= {'final', 'Any', 'NewType'} if sys.version_info < (3, 12): exclude |= { - 'Protocol', 'SupportsAbs', 'SupportsBytes', + 'SupportsAbs', 'SupportsBytes', 'SupportsComplex', 'SupportsFloat', 'SupportsIndex', 'SupportsInt', 'SupportsRound', 'Unpack', } if sys.version_info < (3, 13): - exclude |= {'NamedTuple', 'TypedDict', 'is_typeddict'} + exclude |= {'NamedTuple', 'Protocol', '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 34affa97..78ae1635 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -473,6 +473,7 @@ def clear_overloads(): "__orig_bases__", "__module__", "_MutableMapping__marker", "__doc__", "__subclasshook__", "__orig_class__", "__init__", "__new__", "__protocol_attrs__", "__callable_proto_members_only__", + "__match_args__", } if sys.version_info >= (3, 9): @@ -503,9 +504,9 @@ def _caller(depth=2): return None -# The performance of runtime-checkable protocols is significantly improved on Python 3.12, -# so we backport the 3.12 version of Protocol to Python <=3.11 -if sys.version_info >= (3, 12): +# `__match_args__` attribute was removed from protocol members in 3.13, +# we want to backport this change to older Python versions. +if sys.version_info >= (3, 13): Protocol = typing.Protocol else: def _allow_reckless_class_checks(depth=3): From f9f257c6baa557658004bbe019bd38fcff3fb2b5 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 4 Nov 2023 14:04:47 -0700 Subject: [PATCH 07/28] Fix deprecating a mixin; warn when inheriting from a deprecated class (#294) Co-authored-by: Alex Waygood --- CHANGELOG.md | 4 +- doc/index.rst | 5 ++ src/test_typing_extensions.py | 87 +++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 30 ++++++++++-- 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16775856..2893d27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,9 @@ - All parameters on `NewType.__call__` are now positional-only. This means that the signature of `typing_extensions.NewType.__call__` now exactly matches the signature of `typing.NewType.__call__`. Patch by Alex Waygood. -- `typing.deprecated` now gives a better error message if you pass a non-`str` +- Fix bug with using `@deprecated` on a mixin class. Inheriting from a + deprecated class now raises a `DeprecationWarning`. Patch by Jelle Zijlstra. +- `@deprecated` now gives a better error message if you pass a non-`str` argument to the `msg` parameter. Patch by Alex Waygood. - Exclude `__match_args__` from `Protocol` members, this is a backport of https://github.com/python/cpython/pull/110683 diff --git a/doc/index.rst b/doc/index.rst index 28b795a3..39885861 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -553,6 +553,11 @@ Decorators .. versionadded:: 4.5.0 + .. versionchanged:: 4.9.0 + + Inheriting from a deprecated class now also raises a runtime + :py:exc:`DeprecationWarning`. + .. decorator:: final See :py:func:`typing.final` and :pep:`591`. In ``typing`` since 3.8. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index ffc84266..702ba541 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -418,6 +418,93 @@ def __new__(cls, x): self.assertEqual(instance.x, 42) self.assertTrue(new_called) + def test_mixin_class(self): + @deprecated("Mixin will go away soon") + class Mixin: + pass + + class Base: + def __init__(self, a) -> None: + self.a = a + + with self.assertWarnsRegex(DeprecationWarning, "Mixin will go away soon"): + class Child(Base, Mixin): + pass + + instance = Child(42) + self.assertEqual(instance.a, 42) + + def test_existing_init_subclass(self): + @deprecated("C will go away soon") + class C: + def __init_subclass__(cls) -> None: + cls.inited = True + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + C() + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + class D(C): + pass + + self.assertTrue(D.inited) + self.assertIsInstance(D(), D) # no deprecation + + def test_existing_init_subclass_in_base(self): + class Base: + def __init_subclass__(cls, x) -> None: + cls.inited = x + + @deprecated("C will go away soon") + class C(Base, x=42): + pass + + self.assertEqual(C.inited, 42) + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + C() + + with self.assertWarnsRegex(DeprecationWarning, "C will go away soon"): + class D(C, x=3): + pass + + self.assertEqual(D.inited, 3) + + def test_init_subclass_has_correct_cls(self): + init_subclass_saw = None + + @deprecated("Base will go away soon") + class Base: + def __init_subclass__(cls) -> None: + nonlocal init_subclass_saw + init_subclass_saw = cls + + self.assertIsNone(init_subclass_saw) + + with self.assertWarnsRegex(DeprecationWarning, "Base will go away soon"): + class C(Base): + pass + + self.assertIs(init_subclass_saw, C) + + def test_init_subclass_with_explicit_classmethod(self): + init_subclass_saw = None + + @deprecated("Base will go away soon") + class Base: + @classmethod + def __init_subclass__(cls) -> None: + nonlocal init_subclass_saw + init_subclass_saw = cls + + self.assertIsNone(init_subclass_saw) + + with self.assertWarnsRegex(DeprecationWarning, "Base will go away soon"): + class C(Base): + pass + + self.assertIs(init_subclass_saw, C) + def test_function(self): @deprecated("b will go away soon") def b(): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 78ae1635..c8c6853b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2343,21 +2343,45 @@ def decorator(arg: _T, /) -> _T: return arg elif isinstance(arg, type): original_new = arg.__new__ - has_init = arg.__init__ is not object.__init__ @functools.wraps(original_new) def __new__(cls, *args, **kwargs): - warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + if cls is arg: + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) if original_new is not object.__new__: return original_new(cls, *args, **kwargs) # Mirrors a similar check in object.__new__. - elif not has_init and (args or kwargs): + elif cls.__init__ is object.__init__ and (args or kwargs): raise TypeError(f"{cls.__name__}() takes no arguments") else: return original_new(cls) arg.__new__ = staticmethod(__new__) + + original_init_subclass = arg.__init_subclass__ + # We need slightly different behavior if __init_subclass__ + # is a bound method (likely if it was implemented in Python) + if isinstance(original_init_subclass, _types.MethodType): + original_init_subclass = original_init_subclass.__func__ + + @functools.wraps(original_init_subclass) + def __init_subclass__(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return original_init_subclass(*args, **kwargs) + + arg.__init_subclass__ = classmethod(__init_subclass__) + # Or otherwise, which likely means it's a builtin such as + # object's implementation of __init_subclass__. + else: + @functools.wraps(original_init_subclass) + def __init_subclass__(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return original_init_subclass(*args, **kwargs) + + arg.__init_subclass__ = __init_subclass__ + arg.__deprecated__ = __new__.__deprecated__ = msg + __init_subclass__.__deprecated__ = msg return arg elif callable(arg): @functools.wraps(arg) From 7af82f97686df8da7fbf0d9871f3f942d9254449 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 7 Nov 2023 23:19:02 -0800 Subject: [PATCH 08/28] @deprecated: will be in warnings, not typing (#298) --- doc/index.rst | 2 +- src/typing_extensions.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index 39885861..3bbe2fc8 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -549,7 +549,7 @@ Decorators .. decorator:: deprecated(msg, *, category=DeprecationWarning, stacklevel=1) - See :pep:`702`. Experimental; not yet part of the standard library. + See :pep:`702`. In the :mod:`warnings` module since Python 3.13. .. versionadded:: 4.5.0 diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c8c6853b..fc656de8 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2282,8 +2282,8 @@ def method(self) -> None: return arg -if hasattr(typing, "deprecated"): - deprecated = typing.deprecated +if hasattr(warnings, "deprecated"): + deprecated = warnings.deprecated else: _T = typing.TypeVar("_T") From 4f91502281d748671c7c1dfa26726111853f1342 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 29 Nov 2023 14:31:20 +0000 Subject: [PATCH 09/28] Backport recent change to `NamedTuple` classes regarding `__set_name__` (#303) --- CHANGELOG.md | 4 + src/test_typing_extensions.py | 135 ++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 30 +++++++- 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2893d27a..1ff6a1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ argument to the `msg` parameter. Patch by Alex Waygood. - Exclude `__match_args__` from `Protocol` members, this is a backport of https://github.com/python/cpython/pull/110683 +- When creating a `typing_extensions.NamedTuple` class, ensure `__set_name__` + is called on all objects that define `__set_name__` and exist in the values + of the `NamedTuple` class's class dictionary. Patch by Alex Waygood, + backporting https://github.com/python/cpython/pull/111876. # Release 4.8.0 (September 17, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 702ba541..b54c5926 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -56,6 +56,11 @@ # versions, but not all HAS_FORWARD_MODULE = "module" in inspect.signature(typing._type_check).parameters +skip_if_early_py313_alpha = skipIf( + sys.version_info[:4] == (3, 13, 0, 'alpha') and sys.version_info.serial < 3, + "Bugfixes will be released in 3.13.0a3" +) + ANN_MODULE_SOURCE = '''\ from typing import Optional from functools import wraps @@ -5548,6 +5553,136 @@ class GenericNamedTuple(NamedTuple, Generic[T]): self.assertEqual(CallNamedTuple.__orig_bases__, (NamedTuple,)) + @skip_if_early_py313_alpha + def test_setname_called_on_values_in_class_dictionary(self): + class Vanilla: + def __set_name__(self, owner, name): + self.name = name + + class Foo(NamedTuple): + attr = Vanilla() + + foo = Foo() + self.assertEqual(len(foo), 0) + self.assertNotIn('attr', Foo._fields) + self.assertIsInstance(foo.attr, Vanilla) + self.assertEqual(foo.attr.name, "attr") + + class Bar(NamedTuple): + attr: Vanilla = Vanilla() + + bar = Bar() + self.assertEqual(len(bar), 1) + self.assertIn('attr', Bar._fields) + self.assertIsInstance(bar.attr, Vanilla) + self.assertEqual(bar.attr.name, "attr") + + @skipIf( + TYPING_3_12_0, + "__set_name__ behaviour changed on py312+ to use BaseException.add_note()" + ) + def test_setname_raises_the_same_as_on_other_classes_py311_minus(self): + class CustomException(BaseException): pass + + class Annoying: + def __set_name__(self, owner, name): + raise CustomException + + annoying = Annoying() + + with self.assertRaises(RuntimeError) as cm: + class NormalClass: + attr = annoying + normal_exception = cm.exception + + with self.assertRaises(RuntimeError) as cm: + class NamedTupleClass(NamedTuple): + attr = annoying + namedtuple_exception = cm.exception + + expected_note = ( + "Error calling __set_name__ on 'Annoying' instance " + "'attr' in 'NamedTupleClass'" + ) + + self.assertIs(type(namedtuple_exception), RuntimeError) + self.assertIs(type(namedtuple_exception), type(normal_exception)) + self.assertEqual(len(namedtuple_exception.args), len(normal_exception.args)) + self.assertEqual( + namedtuple_exception.args[0], + normal_exception.args[0].replace("NormalClass", "NamedTupleClass") + ) + + self.assertIs(type(namedtuple_exception.__cause__), CustomException) + self.assertIs( + type(namedtuple_exception.__cause__), type(normal_exception.__cause__) + ) + self.assertEqual( + namedtuple_exception.__cause__.args, normal_exception.__cause__.args + ) + + @skipUnless( + TYPING_3_12_0, + "__set_name__ behaviour changed on py312+ to use BaseException.add_note()" + ) + @skip_if_early_py313_alpha + def test_setname_raises_the_same_as_on_other_classes_py312_plus(self): + class CustomException(BaseException): pass + + class Annoying: + def __set_name__(self, owner, name): + raise CustomException + + annoying = Annoying() + + with self.assertRaises(CustomException) as cm: + class NormalClass: + attr = annoying + normal_exception = cm.exception + + with self.assertRaises(CustomException) as cm: + class NamedTupleClass(NamedTuple): + attr = annoying + namedtuple_exception = cm.exception + + expected_note = ( + "Error calling __set_name__ on 'Annoying' instance " + "'attr' in 'NamedTupleClass'" + ) + + self.assertIs(type(namedtuple_exception), CustomException) + self.assertIs(type(namedtuple_exception), type(normal_exception)) + self.assertEqual(namedtuple_exception.args, normal_exception.args) + + self.assertEqual(len(namedtuple_exception.__notes__), 1) + self.assertEqual( + len(namedtuple_exception.__notes__), len(normal_exception.__notes__) + ) + + self.assertEqual(namedtuple_exception.__notes__[0], expected_note) + self.assertEqual( + namedtuple_exception.__notes__[0], + normal_exception.__notes__[0].replace("NormalClass", "NamedTupleClass") + ) + + @skip_if_early_py313_alpha + def test_strange_errors_when_accessing_set_name_itself(self): + class CustomException(Exception): pass + + class Meta(type): + def __getattribute__(self, attr): + if attr == "__set_name__": + raise CustomException + return object.__getattribute__(self, attr) + + class VeryAnnoying(metaclass=Meta): pass + + very_annoying = VeryAnnoying() + + with self.assertRaises(CustomException): + class Foo(NamedTuple): + attr = very_annoying + class TypeVarTests(BaseTestCase): def test_basic_plain(self): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index fc656de8..8730173b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2467,11 +2467,35 @@ def __new__(cls, typename, bases, ns): class_getitem = typing.Generic.__class_getitem__.__func__ nm_tpl.__class_getitem__ = classmethod(class_getitem) # update from user namespace without overriding special namedtuple attributes - for key in ns: + for key, val in ns.items(): if key in _prohibited_namedtuple_fields: raise AttributeError("Cannot overwrite NamedTuple attribute " + key) - elif key not in _special_namedtuple_fields and key not in nm_tpl._fields: - setattr(nm_tpl, key, ns[key]) + elif key not in _special_namedtuple_fields: + if key not in nm_tpl._fields: + setattr(nm_tpl, key, ns[key]) + try: + set_name = type(val).__set_name__ + except AttributeError: + pass + else: + try: + set_name(val, nm_tpl, key) + except BaseException as e: + msg = ( + f"Error calling __set_name__ on {type(val).__name__!r} " + f"instance {key!r} in {typename!r}" + ) + # BaseException.add_note() existed on py311, + # but the __set_name__ machinery didn't start + # using add_note() until py312. + # Making sure exceptions are raised in the same way + # as in "normal" classes seems most important here. + if sys.version_info >= (3, 12): + e.add_note(msg) + raise + else: + raise RuntimeError(msg) from e + if typing.Generic in bases: nm_tpl.__init_subclass__() return nm_tpl From e4d9d8bcb674a78ce3beb288c465d8e3648b534f Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 29 Nov 2023 17:28:51 +0000 Subject: [PATCH 10/28] fix typo in `override()` docstring (#305) backport of https://github.com/python/cpython/commit/12c7e9d573de57343cf018fb4e67521aba46c90f --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 8730173b..f85b0aa1 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2252,7 +2252,7 @@ def override(arg: _F, /) -> _F: Usage: class Base: - def method(self) -> None: ... + def method(self) -> None: pass class Child(Base): From 18ae2b323d5199071cc51eef342bc0ac98e1edf1 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 29 Nov 2023 17:39:02 +0000 Subject: [PATCH 11/28] Backport recent improvements to the error message when trying to call `issubclass()` against a protocol with non-method members (#304) --- CHANGELOG.md | 3 +++ src/test_typing_extensions.py | 17 +++++++++++++++++ src/typing_extensions.py | 7 ++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff6a1ef..43569fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ is called on all objects that define `__set_name__` and exist in the values of the `NamedTuple` class's class dictionary. Patch by Alex Waygood, backporting https://github.com/python/cpython/pull/111876. +- Improve the error message when trying to call `issubclass()` against a + `Protocol` that has non-method members. Patch by Alex Waygood (backporting + https://github.com/python/cpython/pull/112344, by Randolph Scholz). # Release 4.8.0 (September 17, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index b54c5926..f4c211fc 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3446,6 +3446,23 @@ def method(self) -> None: ... self.assertIsInstance(Foo(), ProtocolWithMixedMembers) self.assertNotIsInstance(42, ProtocolWithMixedMembers) + @skip_if_early_py313_alpha + def test_protocol_issubclass_error_message(self): + class Vec2D(Protocol): + x: float + y: float + + def square_norm(self) -> float: + return self.x ** 2 + self.y ** 2 + + self.assertEqual(Vec2D.__protocol_attrs__, {'x', 'y', 'square_norm'}) + expected_error_message = ( + "Protocols with non-method members don't support issubclass()." + " Non-method members: 'x', 'y'." + ) + with self.assertRaisesRegex(TypeError, re.escape(expected_error_message)): + issubclass(int, Vec2D) + class Point2DGeneric(Generic[T], TypedDict): a: T diff --git a/src/typing_extensions.py b/src/typing_extensions.py index f85b0aa1..5e4932bc 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -570,8 +570,13 @@ def __subclasscheck__(cls, other): not cls.__callable_proto_members_only__ and cls.__dict__.get("__subclasshook__") is _proto_hook ): + non_method_attrs = sorted( + attr for attr in cls.__protocol_attrs__ + if not callable(getattr(cls, attr, None)) + ) raise TypeError( - "Protocols with non-method members don't support issubclass()" + "Protocols with non-method members don't support issubclass()." + f" Non-method members: {str(non_method_attrs)[1:-1]}." ) if not getattr(cls, '_is_runtime_protocol', False): raise TypeError( From db6f9b4a0e1c18c6269691691e72e6b80a247ebd Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 09:39:28 -0800 Subject: [PATCH 12/28] Update @deprecated implementation (#302) Co-authored-by: Alex Waygood --- CHANGELOG.md | 2 + src/test_typing_extensions.py | 12 +++++- src/typing_extensions.py | 69 ++++++++++++++++++++++------------- 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43569fae..d85aae3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ deprecated class now raises a `DeprecationWarning`. Patch by Jelle Zijlstra. - `@deprecated` now gives a better error message if you pass a non-`str` argument to the `msg` parameter. Patch by Alex Waygood. +- `@deprecated` is now implemented as a class for better introspectability. + Patch by Jelle Zijlstra. - Exclude `__match_args__` from `Protocol` members, this is a backport of https://github.com/python/cpython/pull/110683 - When creating a `typing_extensions.NamedTuple` class, ensure `__set_name__` diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index f4c211fc..562704e9 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -575,18 +575,26 @@ def d(): def test_only_strings_allowed(self): with self.assertRaisesRegex( TypeError, - "Expected an object of type str for 'msg', not 'type'" + "Expected an object of type str for 'message', not 'type'" ): @deprecated class Foo: ... with self.assertRaisesRegex( TypeError, - "Expected an object of type str for 'msg', not 'function'" + "Expected an object of type str for 'message', not 'function'" ): @deprecated def foo(): ... + def test_no_retained_references_to_wrapper_instance(self): + @deprecated('depr') + def d(): pass + + self.assertFalse(any( + isinstance(cell.cell_contents, deprecated) for cell in d.__closure__ + )) + class AnyTests(BaseTestCase): def test_can_subclass(self): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 5e4932bc..a875ec97 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2292,15 +2292,12 @@ def method(self) -> None: else: _T = typing.TypeVar("_T") - def deprecated( - msg: str, - /, - *, - category: typing.Optional[typing.Type[Warning]] = DeprecationWarning, - stacklevel: int = 1, - ) -> typing.Callable[[_T], _T]: + class deprecated: """Indicate that a class, function or overload is deprecated. + When this decorator is applied to an object, the type checker + will generate a diagnostic on usage of the deprecated object. + Usage: @deprecated("Use B instead") @@ -2317,36 +2314,56 @@ def g(x: int) -> int: ... @overload def g(x: str) -> int: ... - When this decorator is applied to an object, the type checker - will generate a diagnostic on usage of the deprecated object. - - The warning specified by ``category`` will be emitted on use - of deprecated objects. For functions, that happens on calls; - for classes, on instantiation. If the ``category`` is ``None``, - no warning is emitted. The ``stacklevel`` determines where the + The warning specified by *category* will be emitted at runtime + on use of deprecated objects. For functions, that happens on calls; + for classes, on instantiation and on creation of subclasses. + If the *category* is ``None``, no warning is emitted at runtime. + The *stacklevel* determines where the warning is emitted. If it is ``1`` (the default), the warning is emitted at the direct caller of the deprecated object; if it is higher, it is emitted further up the stack. + Static type checker behavior is not affected by the *category* + and *stacklevel* arguments. - The decorator sets the ``__deprecated__`` - attribute on the decorated object to the deprecation message - passed to the decorator. If applied to an overload, the decorator + The deprecation message passed to the decorator is saved in the + ``__deprecated__`` attribute on the decorated object. + If applied to an overload, the decorator must be after the ``@overload`` decorator for the attribute to exist on the overload as returned by ``get_overloads()``. See PEP 702 for details. """ - if not isinstance(msg, str): - raise TypeError( - f"Expected an object of type str for 'msg', not {type(msg).__name__!r}" - ) - - def decorator(arg: _T, /) -> _T: + def __init__( + self, + message: str, + /, + *, + category: typing.Optional[typing.Type[Warning]] = DeprecationWarning, + stacklevel: int = 1, + ) -> None: + if not isinstance(message, str): + raise TypeError( + "Expected an object of type str for 'message', not " + f"{type(message).__name__!r}" + ) + self.message = message + self.category = category + self.stacklevel = stacklevel + + def __call__(self, arg: _T, /) -> _T: + # Make sure the inner functions created below don't + # retain a reference to self. + msg = self.message + category = self.category + stacklevel = self.stacklevel if category is None: arg.__deprecated__ = msg return arg elif isinstance(arg, type): + import functools + from types import MethodType + original_new = arg.__new__ @functools.wraps(original_new) @@ -2366,7 +2383,7 @@ def __new__(cls, *args, **kwargs): original_init_subclass = arg.__init_subclass__ # We need slightly different behavior if __init_subclass__ # is a bound method (likely if it was implemented in Python) - if isinstance(original_init_subclass, _types.MethodType): + if isinstance(original_init_subclass, MethodType): original_init_subclass = original_init_subclass.__func__ @functools.wraps(original_init_subclass) @@ -2389,6 +2406,8 @@ def __init_subclass__(*args, **kwargs): __init_subclass__.__deprecated__ = msg return arg elif callable(arg): + import functools + @functools.wraps(arg) def wrapper(*args, **kwargs): warnings.warn(msg, category=category, stacklevel=stacklevel + 1) @@ -2402,8 +2421,6 @@ def wrapper(*args, **kwargs): f"a class or callable, not {arg!r}" ) - return decorator - # We have to do some monkey patching to deal with the dual nature of # Unpack/TypeVarTuple: From 0b0166d649cebcb48e7e208ae5da36cfab5965fe Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 09:45:37 -0800 Subject: [PATCH 13/28] Add support for PEP 705 (#284) Co-authored-by: Alice Co-authored-by: Alex Waygood --- CHANGELOG.md | 2 + doc/index.rst | 29 ++++++++- src/test_typing_extensions.py | 60 ++++++++++++++++-- src/typing_extensions.py | 113 +++++++++++++++++++++++++++++----- 4 files changed, 183 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d85aae3c..36c735d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Release 4.9.0 (???) +- 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 signature of `typing.NewType.__call__`. Patch by Alex Waygood. diff --git a/doc/index.rst b/doc/index.rst index 3bbe2fc8..76ba1a50 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` item 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) See :py:class:`typing.TypedDict` and :pep:`589`. In ``typing`` since 3.8. @@ -366,6 +372,23 @@ 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 :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 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 do not carry the :data:`ReadOnly` qualifier. + + .. versionadded:: 4.9.0 + .. versionchanged:: 4.3.0 Added support for generic ``TypedDict``\ s. @@ -394,6 +417,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 :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 562704e9..77876b7f 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 @@ -3550,10 +3550,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): - 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) @@ -4077,6 +4074,55 @@ class T4(TypedDict, Generic[S]): pass self.assertEqual(klass.__optional_keys__, set()) 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'})) + class AnnotatedTests(BaseTestCase): @@ -5217,7 +5263,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 a875ec97..1666e96b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -86,6 +86,7 @@ 'TYPE_CHECKING', 'Never', 'NoReturn', + 'ReadOnly', 'Required', 'NotRequired', @@ -773,7 +774,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" @@ -784,6 +785,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 the ReadOnly[] qualifier. TypedDict = typing.TypedDict _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict @@ -791,8 +793,29 @@ 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): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -835,33 +858,46 @@ 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__', ())) + 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(): - 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 = set(_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 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) 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 return tp_dict @@ -942,6 +978,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 " @@ -1930,6 +1968,53 @@ 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 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 = 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 daa793141c3d504ce0a1d19ef032ea83466ba5c2 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 29 Nov 2023 18:07:36 +0000 Subject: [PATCH 14/28] Run typed-argument-parser tests on 3.12 in the daily workflow (#307) They declared support for Python 3.12 in https://github.com/swansonk14/typed-argument-parser/commit/0789b251e58892ca0fb6c18ade046c8a960c3268 --- .github/workflows/third_party.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index fcde71ca..3d7de82c 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -217,7 +217,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] runs-on: ubuntu-latest timeout-minutes: 60 steps: From f82d6367f3ff8f16b6291de06394ec6b9318bfc3 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Nov 2023 10:13:59 -0800 Subject: [PATCH 15/28] Prepare release 4.9.0rc1 (#306) --- CHANGELOG.md | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c735d4..c5a8aebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Release 4.9.0 (???) +# Release 4.9.0rc1 (November 29, 2023) - Add support for PEP 705, adding `typing_extensions.ReadOnly`. Patch by Jelle Zijlstra. @@ -11,8 +11,8 @@ argument to the `msg` parameter. Patch by Alex Waygood. - `@deprecated` is now implemented as a class for better introspectability. Patch by Jelle Zijlstra. -- Exclude `__match_args__` from `Protocol` members, - this is a backport of https://github.com/python/cpython/pull/110683 +- Exclude `__match_args__` from `Protocol` members. + Backport of https://github.com/python/cpython/pull/110683 by Nikita Sobolev. - When creating a `typing_extensions.NamedTuple` class, ensure `__set_name__` is called on all objects that define `__set_name__` and exist in the values of the `NamedTuple` class's class dictionary. Patch by Alex Waygood, diff --git a/pyproject.toml b/pyproject.toml index b71e6d01..7570b039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.8.0" +version = "4.9.0rc1" description = "Backported and Experimental Type Hints for Python 3.8+" readme = "README.md" requires-python = ">=3.8" From fc461d6faf4585849b561f2e4cbb06e9db095307 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 9 Dec 2023 17:11:53 -0800 Subject: [PATCH 16/28] Release 4.9.0 (#313) --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a8aebc..fedc2a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# Release 4.9.0 (December 9, 2023) + +This feature release adds `typing_extensions.ReadOnly`, as specified +by PEP 705, and makes various other improvements, especially to +`@typing_extensions.deprecated()`. + +There are no changes since 4.9.0rc1. + # Release 4.9.0rc1 (November 29, 2023) - Add support for PEP 705, adding `typing_extensions.ReadOnly`. Patch diff --git a/pyproject.toml b/pyproject.toml index 7570b039..5bea3e9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.9.0rc1" +version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" readme = "README.md" requires-python = ">=3.8" From d6dc4f157e731b2475141aae0c2586ed0243b686 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 9 Dec 2023 17:20:30 -0800 Subject: [PATCH 17/28] Fix readthedocs config (#314) The folder is called docs, not doc. Also use 3.12 while I'm here. --- .readthedocs.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 83cd53cb..60419be8 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,8 +6,8 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.11" + python: "3.12" sphinx: - configuration: docs/conf.py + configuration: doc/conf.py From f84880d60b1d5f7b4ceaab563e7eeb6021f4ca13 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 13 Jan 2024 08:35:48 -0800 Subject: [PATCH 18/28] third-party tests: skip cattrs on pypy (#321) It's broken for reasons unrelated to typing-extensions. See #320. --- .github/workflows/third_party.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index 3d7de82c..b086299d 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -315,7 +315,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "pypy3.9"] + python-version: ["3.8", "3.9", "3.10", "3.11"] runs-on: ubuntu-latest timeout-minutes: 60 steps: From 004b893ddce2a5743d9a4de3a97ef5c48882d384 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 20 Jan 2024 17:56:16 +0000 Subject: [PATCH 19/28] Backport recent improvements to the implementation of `Protocol` (#324) --- CHANGELOG.md | 11 ++++ src/test_typing_extensions.py | 61 ++++++++++++++++++-- src/typing_extensions.py | 101 +++++++++++++++++++++++++++------- 3 files changed, 146 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fedc2a3f..be1c16d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# Unreleased + +- Speedup `issubclass()` checks against simple runtime-checkable protocols by + around 6% (backporting https://github.com/python/cpython/pull/112717, by Alex + Waygood). +- Fix a regression in the implementation of protocols where `typing.Protocol` + classes that were not marked as `@runtime_checkable` would be unnecessarily + introspected, potentially causing exceptions to be raised if the protocol had + problematic members. Patch by Alex Waygood, backporting + https://github.com/python/cpython/pull/113401. + # Release 4.9.0 (December 9, 2023) This feature release adds `typing_extensions.ReadOnly`, as specified diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 77876b7f..58dc1851 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2817,8 +2817,8 @@ def meth(self): pass # noqa: B027 self.assertNotIn("__protocol_attrs__", vars(NonP)) self.assertNotIn("__protocol_attrs__", vars(NonPR)) - self.assertNotIn("__callable_proto_members_only__", vars(NonP)) - self.assertNotIn("__callable_proto_members_only__", vars(NonPR)) + self.assertNotIn("__non_callable_proto_members__", vars(NonP)) + self.assertNotIn("__non_callable_proto_members__", vars(NonPR)) acceptable_extra_attrs = { '_is_protocol', '_is_runtime_protocol', '__parameters__', @@ -2891,11 +2891,26 @@ def __subclasshook__(cls, other): @skip_if_py312b1 def test_issubclass_fails_correctly(self): @runtime_checkable - class P(Protocol): + class NonCallableMembers(Protocol): x = 1 + + class NotRuntimeCheckable(Protocol): + def callable_member(self) -> int: ... + + @runtime_checkable + class RuntimeCheckable(Protocol): + def callable_member(self) -> int: ... + class C: pass - with self.assertRaisesRegex(TypeError, r"issubclass\(\) arg 1 must be a class"): - issubclass(C(), P) + + # These three all exercise different code paths, + # but should result in the same error message: + for protocol in NonCallableMembers, NotRuntimeCheckable, RuntimeCheckable: + with self.subTest(proto_name=protocol.__name__): + with self.assertRaisesRegex( + TypeError, r"issubclass\(\) arg 1 must be a class" + ): + issubclass(C(), protocol) def test_defining_generic_protocols(self): T = TypeVar('T') @@ -3456,6 +3471,7 @@ def method(self) -> None: ... @skip_if_early_py313_alpha def test_protocol_issubclass_error_message(self): + @runtime_checkable class Vec2D(Protocol): x: float y: float @@ -3471,6 +3487,39 @@ def square_norm(self) -> float: with self.assertRaisesRegex(TypeError, re.escape(expected_error_message)): issubclass(int, Vec2D) + def test_nonruntime_protocol_interaction_with_evil_classproperty(self): + class classproperty: + def __get__(self, instance, type): + raise RuntimeError("NO") + + class Commentable(Protocol): + evil = classproperty() + + # recognised as a protocol attr, + # but not actually accessed by the protocol metaclass + # (which would raise RuntimeError) for non-runtime protocols. + # See gh-113320 + self.assertEqual(get_protocol_members(Commentable), {"evil"}) + + def test_runtime_protocol_interaction_with_evil_classproperty(self): + class CustomError(Exception): pass + + class classproperty: + def __get__(self, instance, type): + raise CustomError + + with self.assertRaises(TypeError) as cm: + @runtime_checkable + class Commentable(Protocol): + evil = classproperty() + + exc = cm.exception + self.assertEqual( + exc.args[0], + "Failed to determine whether protocol member 'evil' is a method member" + ) + self.assertIs(type(exc.__cause__), CustomError) + class Point2DGeneric(Generic[T], TypedDict): a: T @@ -5263,7 +5312,7 @@ def test_typing_extensions_defers_when_possible(self): 'SupportsRound', 'Unpack', } if sys.version_info < (3, 13): - exclude |= {'NamedTuple', 'Protocol'} + exclude |= {'NamedTuple', 'Protocol', 'runtime_checkable'} if not hasattr(typing, 'ReadOnly'): exclude |= {'TypedDict', 'is_typeddict'} for item in typing_extensions.__all__: diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 1666e96b..4007594c 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -473,7 +473,7 @@ def clear_overloads(): "_is_runtime_protocol", "__dict__", "__slots__", "__parameters__", "__orig_bases__", "__module__", "_MutableMapping__marker", "__doc__", "__subclasshook__", "__orig_class__", "__init__", "__new__", - "__protocol_attrs__", "__callable_proto_members_only__", + "__protocol_attrs__", "__non_callable_proto_members__", "__match_args__", } @@ -521,6 +521,22 @@ def _no_init(self, *args, **kwargs): if type(self)._is_protocol: raise TypeError('Protocols cannot be instantiated') + def _type_check_issubclass_arg_1(arg): + """Raise TypeError if `arg` is not an instance of `type` + in `issubclass(arg, )`. + + In most cases, this is verified by type.__subclasscheck__. + Checking it again unnecessarily would slow down issubclass() checks, + so, we don't perform this check unless we absolutely have to. + + For various error paths, however, + we want to ensure that *this* error message is shown to the user + where relevant, rather than a typing.py-specific error message. + """ + if not isinstance(arg, type): + # Same error message as for issubclass(1, int). + raise TypeError('issubclass() arg 1 must be a class') + # Inheriting from typing._ProtocolMeta isn't actually desirable, # but is necessary to allow typing.Protocol and typing_extensions.Protocol # to mix without getting TypeErrors about "metaclass conflict" @@ -551,11 +567,6 @@ def __init__(cls, *args, **kwargs): abc.ABCMeta.__init__(cls, *args, **kwargs) if getattr(cls, "_is_protocol", False): cls.__protocol_attrs__ = _get_protocol_attrs(cls) - # PEP 544 prohibits using issubclass() - # with protocols that have non-method members. - cls.__callable_proto_members_only__ = all( - callable(getattr(cls, attr, None)) for attr in cls.__protocol_attrs__ - ) def __subclasscheck__(cls, other): if cls is Protocol: @@ -564,26 +575,23 @@ def __subclasscheck__(cls, other): getattr(cls, '_is_protocol', False) and not _allow_reckless_class_checks() ): - if not isinstance(other, type): - # Same error message as for issubclass(1, int). - raise TypeError('issubclass() arg 1 must be a class') + if not getattr(cls, '_is_runtime_protocol', False): + _type_check_issubclass_arg_1(other) + raise TypeError( + "Instance and class checks can only be used with " + "@runtime_checkable protocols" + ) if ( - not cls.__callable_proto_members_only__ + # this attribute is set by @runtime_checkable: + cls.__non_callable_proto_members__ and cls.__dict__.get("__subclasshook__") is _proto_hook ): - non_method_attrs = sorted( - attr for attr in cls.__protocol_attrs__ - if not callable(getattr(cls, attr, None)) - ) + _type_check_issubclass_arg_1(other) + non_method_attrs = sorted(cls.__non_callable_proto_members__) raise TypeError( "Protocols with non-method members don't support issubclass()." f" Non-method members: {str(non_method_attrs)[1:-1]}." ) - if not getattr(cls, '_is_runtime_protocol', False): - raise TypeError( - "Instance and class checks can only be used with " - "@runtime_checkable protocols" - ) return abc.ABCMeta.__subclasscheck__(cls, other) def __instancecheck__(cls, instance): @@ -610,7 +618,8 @@ def __instancecheck__(cls, instance): val = inspect.getattr_static(instance, attr) except AttributeError: break - if val is None and callable(getattr(cls, attr, None)): + # this attribute is set by @runtime_checkable: + if val is None and attr not in cls.__non_callable_proto_members__: break else: return True @@ -678,8 +687,58 @@ def __init_subclass__(cls, *args, **kwargs): cls.__init__ = _no_init +if sys.version_info >= (3, 13): + runtime_checkable = typing.runtime_checkable +else: + def runtime_checkable(cls): + """Mark a protocol class as a runtime protocol. + + Such protocol can be used with isinstance() and issubclass(). + Raise TypeError if applied to a non-protocol class. + This allows a simple-minded structural check very similar to + one trick ponies in collections.abc such as Iterable. + + For example:: + + @runtime_checkable + class Closable(Protocol): + def close(self): ... + + assert isinstance(open('/some/file'), Closable) + + Warning: this will check only the presence of the required methods, + not their type signatures! + """ + if not issubclass(cls, typing.Generic) or not getattr(cls, '_is_protocol', False): + raise TypeError('@runtime_checkable can be only applied to protocol classes,' + ' got %r' % cls) + cls._is_runtime_protocol = True + + # Only execute the following block if it's a typing_extensions.Protocol class. + # typing.Protocol classes don't need it. + if isinstance(cls, _ProtocolMeta): + # PEP 544 prohibits using issubclass() + # with protocols that have non-method members. + # See gh-113320 for why we compute this attribute here, + # rather than in `_ProtocolMeta.__init__` + cls.__non_callable_proto_members__ = set() + for attr in cls.__protocol_attrs__: + try: + is_callable = callable(getattr(cls, attr, None)) + except Exception as e: + raise TypeError( + f"Failed to determine whether protocol member {attr!r} " + "is a method member" + ) from e + else: + if not is_callable: + cls.__non_callable_proto_members__.add(attr) + + return cls + + # The "runtime" alias exists for backwards compatibility. -runtime = runtime_checkable = typing.runtime_checkable +runtime = runtime_checkable # Our version of runtime-checkable protocols is faster on Python 3.8-3.11 From 69b48c377a2a1286c57059e66c47d386374c46c2 Mon Sep 17 00:00:00 2001 From: James Morris <6653392+J-M0@users.noreply.github.com> Date: Wed, 31 Jan 2024 21:14:24 -0500 Subject: [PATCH 20/28] Fix display of TypedDict.__readonly_keys__ (#328) --- doc/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/index.rst b/doc/index.rst index 76ba1a50..4985762e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -373,7 +373,7 @@ Special typing primitives or lower and fails with a :py:exc:`TypeError` in Python 3.13 and higher. ``typing_extensions`` supports the experimental :data:`ReadOnly` qualifier - proposed by :pep:`705`. It is reflected in the following attributes:: + proposed by :pep:`705`. It is reflected in the following attributes: .. attribute:: __readonly_keys__ From 05ffab5fda8510249ec10bc138c72678e3f6d2d9 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Tue, 13 Feb 2024 11:28:16 +0100 Subject: [PATCH 21/28] Catch a deprecation warning on Python 3.13 (#331) --- src/test_typing_extensions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 58dc1851..6fd3b324 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3553,7 +3553,8 @@ def test_basics_functional_syntax(self): @skipIf(sys.version_info < (3, 13), "Change in behavior in 3.13") def test_keywords_syntax_raises_on_3_13(self): with self.assertRaises(TypeError): - Emp = TypedDict('Emp', name=str, id=int) + with self.assertWarns(DeprecationWarning): + Emp = TypedDict('Emp', name=str, id=int) @skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs") def test_basics_keywords_syntax(self): From ff530f50e2e1440e870dfecd2f59a5ae9d2a4244 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Tue, 13 Feb 2024 11:43:48 +0100 Subject: [PATCH 22/28] Update GitHub Actions versions (#332) --- .github/workflows/ci.yml | 6 +++--- .github/workflows/package.yml | 4 ++-- .github/workflows/third_party.yml | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c3d990e..1dc21e06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -85,7 +85,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3" cache: "pip" @@ -122,7 +122,7 @@ jobs: issues: write steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 622861bc..6b55f10e 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3 @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3 diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index b086299d..92ce3676 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -100,7 +100,7 @@ jobs: with: path: typing-extensions-latest - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install typing_inspect test dependencies @@ -143,7 +143,7 @@ jobs: with: path: typing-extensions-latest - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -187,7 +187,7 @@ jobs: with: path: typing-extensions-latest - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -231,7 +231,7 @@ jobs: with: path: typing-extensions-latest - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Configure git for typed-argument-parser tests @@ -282,7 +282,7 @@ jobs: with: path: typing-extensions-latest - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -328,7 +328,7 @@ jobs: with: path: typing-extensions-latest - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install pdm for cattrs @@ -377,7 +377,7 @@ jobs: issues: write steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | From d6c50f585c386490d38ad6b8ce5543aed6e633a2 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 14 Feb 2024 08:52:43 -0800 Subject: [PATCH 23/28] Drop runtime error in PEP 705 implementation (#333) See https://discuss.python.org/t/pep-705-read-only-typeddict-items/37867/10 --- CHANGELOG.md | 3 +++ src/test_typing_extensions.py | 7 +++---- src/typing_extensions.py | 5 ----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be1c16d6..e052710c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- Drop runtime error when a mutable `TypedDict` key overrides a read-only + one. Type checkers should still flag this as an error. Patch by Jelle + Zijlstra. - Speedup `issubclass()` checks against simple runtime-checkable protocols by around 6% (backporting https://github.com/python/cpython/pull/112717, by Alex Waygood). diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 6fd3b324..2e8fe7b3 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4143,13 +4143,12 @@ class Child2(Base2): self.assertEqual(Child1.__readonly_keys__, frozenset({'a'})) self.assertEqual(Child1.__mutable_keys__, frozenset({'b'})) - def test_cannot_make_mutable_key_readonly(self): + def test_make_mutable_key_readonly(self): class Base(TypedDict): a: int - with self.assertRaises(TypeError): - class Child(Base): - a: ReadOnly[int] + class Child(Base): + a: ReadOnly[int] # type checker error, but allowed at runtime def test_can_make_readonly_key_mutable(self): class Base(TypedDict): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 4007594c..a016e07b 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -942,11 +942,6 @@ def __new__(cls, name, bases, ns, *, total=True): else: optional_keys.add(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) From 566e01e7a798abfcf88849814918fd8413b8d18b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 15 Feb 2024 20:21:55 -0800 Subject: [PATCH 24/28] Add support for TypeIs (PEP 742) (#330) * Add support for TypeNarrower (PEP 742) * Use TypeIs --- CHANGELOG.md | 2 + doc/index.rst | 6 +++ src/test_typing_extensions.py | 46 ++++++++++++++++- src/typing_extensions.py | 93 +++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e052710c..8be5ebd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +- Add support for PEP 742, adding `typing_extensions.TypeIs`. Patch + by Jelle Zijlstra. - Drop runtime error when a mutable `TypedDict` key overrides a read-only one. Type checkers should still flag this as an error. Patch by Jelle Zijlstra. diff --git a/doc/index.rst b/doc/index.rst index 4985762e..b1e2477b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -350,6 +350,12 @@ Special typing primitives See :py:data:`typing.TypeGuard` and :pep:`647`. In ``typing`` since 3.10. +.. data:: TypeIs + + See :pep:`742`. Similar to :data:`TypeGuard`, but allows more type narrowing. + + .. versionadded:: 4.10.0 + .. class:: TypedDict(dict, total=True) See :py:class:`typing.TypedDict` and :pep:`589`. In ``typing`` since 3.8. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 2e8fe7b3..12677fd0 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -36,7 +36,7 @@ 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 from typing_extensions import clear_overloads, get_overloads, overload -from typing_extensions import NamedTuple +from typing_extensions import NamedTuple, TypeIs from typing_extensions import override, deprecated, Buffer, TypeAliasType, TypeVar, get_protocol_members, is_protocol from typing_extensions import Doc from _typed_dict_test_helper import Foo, FooGeneric, VeryAnnotated @@ -4774,6 +4774,50 @@ def test_no_isinstance(self): issubclass(int, TypeGuard) +class TypeIsTests(BaseTestCase): + def test_basics(self): + TypeIs[int] # OK + self.assertEqual(TypeIs[int], TypeIs[int]) + + def foo(arg) -> TypeIs[int]: ... + self.assertEqual(gth(foo), {'return': TypeIs[int]}) + + def test_repr(self): + if hasattr(typing, 'TypeIs'): + mod_name = 'typing' + else: + mod_name = 'typing_extensions' + self.assertEqual(repr(TypeIs), f'{mod_name}.TypeIs') + cv = TypeIs[int] + self.assertEqual(repr(cv), f'{mod_name}.TypeIs[int]') + cv = TypeIs[Employee] + self.assertEqual(repr(cv), f'{mod_name}.TypeIs[{__name__}.Employee]') + cv = TypeIs[Tuple[int]] + self.assertEqual(repr(cv), f'{mod_name}.TypeIs[typing.Tuple[int]]') + + def test_cannot_subclass(self): + with self.assertRaises(TypeError): + class C(type(TypeIs)): + pass + with self.assertRaises(TypeError): + class C(type(TypeIs[int])): + pass + + def test_cannot_init(self): + with self.assertRaises(TypeError): + TypeIs() + with self.assertRaises(TypeError): + type(TypeIs)() + with self.assertRaises(TypeError): + type(TypeIs[Optional[int]])() + + def test_no_isinstance(self): + with self.assertRaises(TypeError): + isinstance(1, TypeIs[int]) + with self.assertRaises(TypeError): + issubclass(int, TypeIs) + + class LiteralStringTests(BaseTestCase): def test_basics(self): class Foo: diff --git a/src/typing_extensions.py b/src/typing_extensions.py index a016e07b..3ef09259 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -83,6 +83,7 @@ 'TypeAlias', 'TypeAliasType', 'TypeGuard', + 'TypeIs', 'TYPE_CHECKING', 'Never', 'NoReturn', @@ -1822,6 +1823,98 @@ def is_str(val: Union[str, float]): PEP 647 (User-Defined Type Guards). """) +# 3.13+ +if hasattr(typing, 'TypeIs'): + TypeIs = typing.TypeIs +# 3.9 +elif sys.version_info[:2] >= (3, 9): + @_ExtensionsSpecialForm + def TypeIs(self, parameters): + """Special typing form used to annotate the return type of a user-defined + type narrower function. ``TypeIs`` only accepts a single type argument. + At runtime, functions marked this way should return a boolean. + + ``TypeIs`` aims to benefit *type narrowing* -- a technique used by static + type checkers to determine a more precise type of an expression within a + program's code flow. Usually type narrowing is done by analyzing + conditional code flow and applying the narrowing to a block of code. The + conditional expression here is sometimes referred to as a "type guard". + + Sometimes it would be convenient to use a user-defined boolean function + as a type guard. Such a function should use ``TypeIs[...]`` as its + return type to alert static type checkers to this intention. + + Using ``-> TypeIs`` tells the static type checker that for a given + function: + + 1. The return value is a boolean. + 2. If the return value is ``True``, the type of its argument + is the intersection of the type inside ``TypeGuard`` and the argument's + previously known type. + + For example:: + + def is_awaitable(val: object) -> TypeIs[Awaitable[Any]]: + return hasattr(val, '__await__') + + def f(val: Union[int, Awaitable[int]]) -> int: + if is_awaitable(val): + assert_type(val, Awaitable[int]) + else: + assert_type(val, int) + + ``TypeIs`` also works with type variables. For more information, see + PEP 742 (Narrowing types with TypeIs). + """ + item = typing._type_check(parameters, f'{self} accepts only a single type.') + return typing._GenericAlias(self, (item,)) +# 3.8 +else: + class _TypeIsForm(_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,)) + + TypeIs = _TypeIsForm( + 'TypeIs', + doc="""Special typing form used to annotate the return type of a user-defined + type narrower function. ``TypeIs`` only accepts a single type argument. + At runtime, functions marked this way should return a boolean. + + ``TypeIs`` aims to benefit *type narrowing* -- a technique used by static + type checkers to determine a more precise type of an expression within a + program's code flow. Usually type narrowing is done by analyzing + conditional code flow and applying the narrowing to a block of code. The + conditional expression here is sometimes referred to as a "type guard". + + Sometimes it would be convenient to use a user-defined boolean function + as a type guard. Such a function should use ``TypeIs[...]`` as its + return type to alert static type checkers to this intention. + + Using ``-> TypeIs`` tells the static type checker that for a given + function: + + 1. The return value is a boolean. + 2. If the return value is ``True``, the type of its argument + is the intersection of the type inside ``TypeGuard`` and the argument's + previously known type. + + For example:: + + def is_awaitable(val: object) -> TypeIs[Awaitable[Any]]: + return hasattr(val, '__await__') + + def f(val: Union[int, Awaitable[int]]) -> int: + if is_awaitable(val): + assert_type(val, Awaitable[int]) + else: + assert_type(val, int) + + ``TypeIs`` also works with type variables. For more information, see + PEP 742 (Narrowing types with TypeIs). + """) + # Vendored from cpython typing._SpecialFrom class _SpecialForm(typing._Final, _root=True): From 9f040ab8c6f859e8ce956331b496e6a98a33e6f6 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 15 Feb 2024 20:38:14 -0800 Subject: [PATCH 25/28] Fix changelog entry and __mutable_keys__ tracking for PEP 705 (#334) --- CHANGELOG.md | 2 +- src/test_typing_extensions.py | 6 ++++++ src/typing_extensions.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be5ebd6..02416f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Add support for PEP 742, adding `typing_extensions.TypeIs`. Patch by Jelle Zijlstra. -- Drop runtime error when a mutable `TypedDict` key overrides a read-only +- Drop runtime error when a read-only `TypedDict` item overrides a mutable one. Type checkers should still flag this as an error. Patch by Jelle Zijlstra. - Speedup `issubclass()` checks against simple runtime-checkable protocols by diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 12677fd0..53d905e0 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4147,9 +4147,15 @@ def test_make_mutable_key_readonly(self): class Base(TypedDict): a: int + self.assertEqual(Base.__readonly_keys__, frozenset()) + self.assertEqual(Base.__mutable_keys__, frozenset({'a'})) + class Child(Base): a: ReadOnly[int] # type checker error, but allowed at runtime + self.assertEqual(Child.__readonly_keys__, frozenset({'a'})) + self.assertEqual(Child.__mutable_keys__, frozenset()) + def test_can_make_readonly_key_mutable(self): class Base(TypedDict): a: ReadOnly[int] diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 3ef09259..f39d4c7f 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -943,6 +943,7 @@ def __new__(cls, name, bases, ns, *, total=True): else: optional_keys.add(annotation_key) if ReadOnly in qualifiers: + mutable_keys.discard(annotation_key) readonly_keys.add(annotation_key) else: mutable_keys.add(annotation_key) From b7bf949d669dbe19537f7608e00f7b8368fdfb39 Mon Sep 17 00:00:00 2001 From: Zixuan Li <39874143+PIG208@users.noreply.github.com> Date: Sat, 17 Feb 2024 19:26:50 -0500 Subject: [PATCH 26/28] Add support for PEP 728 (#329) Signed-off-by: Zixuan James Li --- CHANGELOG.md | 2 + doc/index.rst | 37 ++++++++ src/test_typing_extensions.py | 154 ++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 32 ++++++- 4 files changed, 222 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02416f46..cd38e40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +- Add support for PEP 728, supporting the `closed` keyword argument and the + special `__extra_items__` key for TypedDict. Patch by Zixuan James Li. - Add support for PEP 742, adding `typing_extensions.TypeIs`. Patch by Jelle Zijlstra. - Drop runtime error when a read-only `TypedDict` item overrides a mutable diff --git a/doc/index.rst b/doc/index.rst index b1e2477b..4bd8c702 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -394,6 +394,38 @@ Special typing primitives are mutable if they do not carry the :data:`ReadOnly` qualifier. .. versionadded:: 4.9.0 + + The experimental ``closed`` keyword argument and the special key + ``__extra_items__`` proposed in :pep:`728` are supported. + + When ``closed`` is unspecified or ``closed=False`` is given, + ``__extra_items__`` behaves like a regular key. Otherwise, this becomes a + special key that does not show up in ``__readonly_keys__``, + ``__mutable_keys__``, ``__required_keys__``, ``__optional_keys``, or + ``__annotations__``. + + For runtime introspection, two attributes can be looked at: + + .. attribute:: __closed__ + + A boolean flag indicating whether the current ``TypedDict`` is + considered closed. This is not inherited by the ``TypedDict``'s + subclasses. + + .. versionadded:: 4.10.0 + + .. attribute:: __extra_items__ + + The type annotation of the extra items allowed on the ``TypedDict``. + This attribute defaults to ``None`` on a TypedDict that has itself and + all its bases non-closed. This default is different from ``type(None)`` + that represents ``__extra_items__: None`` defined on a closed + ``TypedDict``. + + If ``__extra_items__`` is not defined or inherited on a closed + ``TypedDict``, this defaults to ``Never``. + + .. versionadded:: 4.10.0 .. versionchanged:: 4.3.0 @@ -427,6 +459,11 @@ Special typing primitives Support for the :data:`ReadOnly` qualifier was added. + .. versionchanged:: 4.10.0 + + The keyword argument ``closed`` and the special key ``__extra_items__`` + when ``closed=True`` is given were supported. + .. 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 53d905e0..79c1b881 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -52,6 +52,9 @@ # 3.12 changes the representation of Unpack[] (PEP 692) TYPING_3_12_0 = sys.version_info[:3] >= (3, 12, 0) +# 3.13 drops support for the keyword argument syntax of TypedDict +TYPING_3_13_0 = sys.version_info[:3] >= (3, 13, 0) + # https://github.com/python/cpython/pull/27017 was backported into some 3.9 and 3.10 # versions, but not all HAS_FORWARD_MODULE = "module" in inspect.signature(typing._type_check).parameters @@ -3820,6 +3823,24 @@ class ChildWithInlineAndOptional(Untotal, Inline): {'inline': bool, 'untotal': str, 'child': bool}, ) + class Closed(TypedDict, closed=True): + __extra_items__: None + + class Unclosed(TypedDict, closed=False): + ... + + class ChildUnclosed(Closed, Unclosed): + ... + + self.assertFalse(ChildUnclosed.__closed__) + self.assertEqual(ChildUnclosed.__extra_items__, type(None)) + + class ChildClosed(Unclosed, Closed): + ... + + self.assertFalse(ChildClosed.__closed__) + self.assertEqual(ChildClosed.__extra_items__, type(None)) + wrong_bases = [ (One, Regular), (Regular, One), @@ -4178,6 +4199,139 @@ class AllTheThings(TypedDict): self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'})) self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'})) + def test_extra_keys_non_readonly(self): + class Base(TypedDict, closed=True): + __extra_items__: str + + class Child(Base): + a: NotRequired[int] + + self.assertEqual(Child.__required_keys__, frozenset({})) + self.assertEqual(Child.__optional_keys__, frozenset({'a'})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + + def test_extra_keys_readonly(self): + class Base(TypedDict, closed=True): + __extra_items__: ReadOnly[str] + + class Child(Base): + a: NotRequired[str] + + self.assertEqual(Child.__required_keys__, frozenset({})) + self.assertEqual(Child.__optional_keys__, frozenset({'a'})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a'})) + + def test_extra_key_required(self): + with self.assertRaisesRegex( + TypeError, + "Special key __extra_items__ does not support Required" + ): + TypedDict("A", {"__extra_items__": Required[int]}, closed=True) + + with self.assertRaisesRegex( + TypeError, + "Special key __extra_items__ does not support NotRequired" + ): + TypedDict("A", {"__extra_items__": NotRequired[int]}, closed=True) + + def test_regular_extra_items(self): + class ExtraReadOnly(TypedDict): + __extra_items__: ReadOnly[str] + + self.assertEqual(ExtraReadOnly.__required_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({})) + self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({})) + self.assertEqual(ExtraReadOnly.__extra_items__, None) + self.assertFalse(ExtraReadOnly.__closed__) + + class ExtraRequired(TypedDict): + __extra_items__: Required[str] + + self.assertEqual(ExtraRequired.__required_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraRequired.__optional_keys__, frozenset({})) + self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({})) + self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraRequired.__extra_items__, None) + self.assertFalse(ExtraRequired.__closed__) + + class ExtraNotRequired(TypedDict): + __extra_items__: NotRequired[str] + + self.assertEqual(ExtraNotRequired.__required_keys__, frozenset({})) + self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({})) + self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'})) + self.assertEqual(ExtraNotRequired.__extra_items__, None) + self.assertFalse(ExtraNotRequired.__closed__) + + def test_closed_inheritance(self): + class Base(TypedDict, closed=True): + __extra_items__: ReadOnly[Union[str, None]] + + self.assertEqual(Base.__required_keys__, frozenset({})) + self.assertEqual(Base.__optional_keys__, frozenset({})) + self.assertEqual(Base.__readonly_keys__, frozenset({})) + self.assertEqual(Base.__mutable_keys__, frozenset({})) + self.assertEqual(Base.__annotations__, {}) + self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]]) + self.assertTrue(Base.__closed__) + + class Child(Base): + a: int + __extra_items__: int + + self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(Child.__optional_keys__, frozenset({})) + self.assertEqual(Child.__readonly_keys__, frozenset({})) + self.assertEqual(Child.__mutable_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int}) + self.assertEqual(Child.__extra_items__, ReadOnly[Union[str, None]]) + self.assertFalse(Child.__closed__) + + class GrandChild(Child, closed=True): + __extra_items__: str + + self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(GrandChild.__optional_keys__, frozenset({})) + self.assertEqual(GrandChild.__readonly_keys__, frozenset({})) + self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a', "__extra_items__"})) + self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int}) + self.assertEqual(GrandChild.__extra_items__, str) + self.assertTrue(GrandChild.__closed__) + + def test_implicit_extra_items(self): + class Base(TypedDict): + a: int + + self.assertEqual(Base.__extra_items__, None) + self.assertFalse(Base.__closed__) + + class ChildA(Base, closed=True): + ... + + self.assertEqual(ChildA.__extra_items__, Never) + self.assertTrue(ChildA.__closed__) + + class ChildB(Base, closed=True): + __extra_items__: None + + self.assertEqual(ChildB.__extra_items__, type(None)) + self.assertTrue(ChildB.__closed__) + + @skipIf( + TYPING_3_13_0, + "The keyword argument alternative to define a " + "TypedDict type using the functional syntax is no longer supported" + ) + def test_backwards_compatibility(self): + with self.assertWarns(DeprecationWarning): + TD = TypedDict("TD", closed=int) + self.assertFalse(TD.__closed__) + self.assertEqual(TD.__annotations__, {"closed": int}) + class AnnotatedTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index f39d4c7f..f3132ea4 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -875,7 +875,7 @@ def _get_typeddict_qualifiers(annotation_type): break class _TypedDictMeta(type): - def __new__(cls, name, bases, ns, *, total=True): + def __new__(cls, name, bases, ns, *, total=True, closed=False): """Create new typed dict class object. This method is called when TypedDict is subclassed, @@ -920,6 +920,7 @@ def __new__(cls, name, bases, ns, *, total=True): optional_keys = set() readonly_keys = set() mutable_keys = set() + extra_items_type = None for base in bases: base_dict = base.__dict__ @@ -929,6 +930,26 @@ def __new__(cls, name, bases, ns, *, total=True): 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_extra_items_type = base_dict.get('__extra_items__', None) + if base_extra_items_type is not None: + extra_items_type = base_extra_items_type + + if closed and extra_items_type is None: + extra_items_type = Never + if closed and "__extra_items__" in own_annotations: + annotation_type = own_annotations.pop("__extra_items__") + qualifiers = set(_get_typeddict_qualifiers(annotation_type)) + if Required in qualifiers: + raise TypeError( + "Special key __extra_items__ does not support " + "Required" + ) + if NotRequired in qualifiers: + raise TypeError( + "Special key __extra_items__ does not support " + "NotRequired" + ) + extra_items_type = annotation_type annotations.update(own_annotations) for annotation_key, annotation_type in own_annotations.items(): @@ -956,6 +977,8 @@ def __new__(cls, name, bases, ns, *, total=True): tp_dict.__mutable_keys__ = frozenset(mutable_keys) if not hasattr(tp_dict, '__total__'): tp_dict.__total__ = total + tp_dict.__closed__ = closed + tp_dict.__extra_items__ = extra_items_type return tp_dict __call__ = dict # static method @@ -969,7 +992,7 @@ def __subclasscheck__(cls, other): _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) @_ensure_subclassable(lambda bases: (_TypedDict,)) - def TypedDict(typename, fields=_marker, /, *, total=True, **kwargs): + def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs): """A simple typed namespace. At runtime it is equivalent to a plain dict. TypedDict creates a dictionary type such that a type checker will expect all @@ -1029,6 +1052,9 @@ class Point2D(TypedDict): "using the functional syntax, pass an empty dictionary, e.g. " ) + example + "." warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2) + if closed is not False and closed is not True: + kwargs["closed"] = closed + closed = False fields = kwargs elif kwargs: raise TypeError("TypedDict takes either a dict or keyword arguments," @@ -1050,7 +1076,7 @@ class Point2D(TypedDict): # Setting correct module is necessary to make typed dict classes pickleable. ns['__module__'] = module - td = _TypedDictMeta(typename, (), ns, total=total) + td = _TypedDictMeta(typename, (), ns, total=total, closed=closed) td.__orig_bases__ = (TypedDict,) return td From 06b23e3f05fd0f929dbaea17ae51621dcc8434ab Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sat, 17 Feb 2024 18:56:17 -0800 Subject: [PATCH 27/28] Release 4.10.0rc1 (#340) --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd38e40f..23715b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# Release 4.10.0rc1 (February 17, 2024) - Add support for PEP 728, supporting the `closed` keyword argument and the special `__extra_items__` key for TypedDict. Patch by Zixuan James Li. diff --git a/pyproject.toml b/pyproject.toml index 5bea3e9b..6351ba23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.9.0" +version = "4.10.0rc1" description = "Backported and Experimental Type Hints for Python 3.8+" readme = "README.md" requires-python = ">=3.8" From ed81f2b2043f60b0c159914e264e127f5d0b4cda Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sun, 25 Feb 2024 14:08:26 -0800 Subject: [PATCH 28/28] Prepare release 4.10.0 (#343) --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23715b6d..07fc328d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# Release 4.10.0 (February 24, 2024) + +This feature release adds support for PEP 728 (TypedDict with extra +items) and PEP 742 (``TypeIs``). + +There are no changes since 4.10.0rc1. + # Release 4.10.0rc1 (February 17, 2024) - Add support for PEP 728, supporting the `closed` keyword argument and the diff --git a/pyproject.toml b/pyproject.toml index 6351ba23..e0ef3432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.10.0rc1" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" readme = "README.md" requires-python = ">=3.8"