From 3e04a2dcffc837dc531e4a253d1a2c131cb4b994 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 06:13:20 -0700 Subject: [PATCH 01/37] Support 3.12 (#173) --- .github/workflows/ci.yml | 1 - CHANGELOG.md | 1 + pyproject.toml | 1 + tox.ini | 2 +- 4 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b00fad4..4e887892 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,6 @@ jobs: allow-prereleases: true - name: Test typing_extensions - continue-on-error: ${{ matrix.python-version == '3.12' }} run: | # Be wary of running `pip install` here, since it becomes easy for us to # accidentally pick up typing_extensions as installed by a dependency diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d5f370..25e977f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Skip a problematic test on Python 3.12.0b1. The test fails on 3.12.0b1 due to a bug in CPython, which will be fixed in 3.12.0b2. The `typing_extensions` test suite now passes on 3.12.0b1. +- Declare support for Python 3.12. Patch by Jelle Zijlstra. # Release 4.6.2 (May 25, 2023) diff --git a/pyproject.toml b/pyproject.toml index 74ec5ed0..382bd52b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development", ] diff --git a/tox.ini b/tox.ini index 04c4cf16..3d583efc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = True -envlist = py37, py38, py39, py310, py311 +envlist = py37, py38, py39, py310, py311, py312 [testenv] changedir = src From 0d453f507d21197fcb2bb9a0a2b6fcbb816821c9 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 11:50:44 -0700 Subject: [PATCH 02/37] Add more detailed versioning policy (#197) --- CONTRIBUTING.md | 7 +++++-- doc/index.rst | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2585ac70..3b1a093b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,11 +23,14 @@ CPython's `main` branch. # Versioning scheme Starting with version 4.0.0, `typing_extensions` uses -[Semantic Versioning](https://semver.org/). The major version is incremented for all -backwards-incompatible changes. +[Semantic Versioning](https://semver.org/). See the documentation +for more detail. # Workflow for PyPI releases +- Make sure you follow the versioning policy in the documentation + (e.g., release candidates before any feature release) + - Ensure that GitHub Actions reports no errors. - Update the version number in `typing_extensions/pyproject.toml` and in diff --git a/doc/index.rst b/doc/index.rst index e790a2fd..6b1a6f0b 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -37,6 +37,17 @@ In view of the wide usage of ``typing_extensions`` across the ecosystem, we are highly hesitant to break backwards compatibility, and we do not expect to increase the major version number in the foreseeable future. +Feature releases, with version numbers of the form 4.N.0, are made at +irregular intervals when enough new features accumulate. Before a +feature release, at least one release candidate (with a version number +of the form 4.N.0rc1) should be released to give downstream users time +to test. After at least a week of testing, the new feature version +may then be released. If necessary, additional release candidates can +be added. + +Bugfix releases, with version numbers of the form 4.N.1 or higher, +may be made if bugs are discovered after a feature release. + Before version 4.0.0, the versioning scheme loosely followed the Python version from which features were backported; for example, ``typing_extensions`` 3.10.0.0 was meant to reflect ``typing`` as of From bb75d261196aa6f520d1b80b373891c860ebb71b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 26 May 2023 23:12:31 -0700 Subject: [PATCH 03/37] Sync LICENSE with CPython (#205) --- CHANGELOG.md | 3 +++ LICENSE | 33 +++++++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e977f9..ae47941d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- Sync the repository's LICENSE file with that of CPython. + `typing_extensions` is distributed under the same license as + CPython itself. - Skip a problematic test on Python 3.12.0b1. The test fails on 3.12.0b1 due to a bug in CPython, which will be fixed in 3.12.0b2. The `typing_extensions` test suite now passes on 3.12.0b1. diff --git a/LICENSE b/LICENSE index 1df6b3b8..f26bcf4d 100644 --- a/LICENSE +++ b/LICENSE @@ -2,12 +2,12 @@ A. HISTORY OF THE SOFTWARE ========================== Python was created in the early 1990s by Guido van Rossum at Stichting -Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands as a successor of a language called ABC. Guido remains Python's principal author, although it includes many contributions from others. In 1995, Guido continued his work on Python at the Corporation for -National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) in Reston, Virginia where he released several versions of the software. @@ -19,7 +19,7 @@ https://www.python.org/psf/) was formed, a non-profit organization created specifically to own Python-related Intellectual Property. Zope Corporation was a sponsoring member of the PSF. -All Python releases are Open Source (see http://www.opensource.org for +All Python releases are Open Source (see https://opensource.org for the Open Source Definition). Historically, most, but not all, Python releases have also been GPL-compatible; the table below summarizes the various releases. @@ -59,6 +59,17 @@ direction to make these releases possible. B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON =============================================================== +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 -------------------------------------------- @@ -73,7 +84,7 @@ analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python alone or in any derivative version, provided, however, that PSF's License Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation; +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; All Rights Reserved" are retained in Python alone or in any derivative version prepared by Licensee. @@ -252,3 +263,17 @@ FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. From 295705fd25ab2a20b5267977da32305cb6455ba5 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 31 May 2023 19:29:56 +0100 Subject: [PATCH 04/37] Add a cron job for testing third-party users of typing_extensions (#206) Co-authored-by: Jelle Zijlstra Co-authored-by: Sebastian Rittau --- .github/workflows/ci.yml | 42 ++++ .github/workflows/third_party.yml | 337 ++++++++++++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 .github/workflows/third_party.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e887892..0b94d670 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: Test and lint on: + schedule: + - cron: "0 2 * * *" # 2am UTC push: branches: - main @@ -21,6 +23,14 @@ jobs: tests: name: Run tests + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.repository == 'python/typing_extensions' + || github.event_name != 'schedule' + }} + strategy: fail-fast: false matrix: @@ -51,6 +61,9 @@ jobs: linting: name: Lint + # no reason to run this as a cron job + if: github.event_name != 'schedule' + runs-on: ubuntu-latest steps: @@ -74,3 +87,32 @@ jobs: - name: Lint tests run: flake8 --config=.flake8-tests src/test_typing_extensions.py --color always + + create-issue-on-failure: + name: Create an issue if daily tests failed + runs-on: ubuntu-latest + + needs: [tests] + + if: >- + ${{ + github.repository == 'python/typing_extensions' + && always() + && github.event_name == 'schedule' + && needs.tests.result == 'failure' + }} + + permissions: + issues: write + + steps: + - uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.create({ + owner: "python", + repo: "typing_extensions", + title: `Daily tests failed on ${new Date().toDateString()}`, + body: "Runs listed here: https://github.com/python/typing_extensions/actions/workflows/ci.yml", + }) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml new file mode 100644 index 00000000..19be0cf3 --- /dev/null +++ b/.github/workflows/third_party.yml @@ -0,0 +1,337 @@ +# This workflow is a daily cron job, +# running the tests of various third-party libraries that use us. +# This helps us spot regressions early, +# and helps flag when third-party libraries are making incorrect assumptions +# that might cause them to break when we cut a new release. + +name: Third-party tests + +on: + schedule: + - cron: "30 2 * * *" # 02:30 UTC + pull_request: + paths: + - ".github/workflows/third_party.yml" + workflow_dispatch: + +permissions: + contents: read + +env: + PIP_DISABLE_PIP_VERSION_CHECK: 1 + FORCE_COLOR: 1 + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + pydantic: + name: pydantic tests + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.repository == 'python/typing_extensions' + || github.event_name != 'schedule' + }} + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout pydantic + uses: actions/checkout@v3 + with: + repository: pydantic/pydantic + - name: Checkout typing_extensions + uses: actions/checkout@v3 + with: + path: typing-extensions-latest + - name: Setup pdm for pydantic tests + uses: pdm-project/setup-pdm@v3 + with: + python-version: ${{ matrix.python-version }} + cache: true + - name: Add local version of typing_extensions as a dependency + run: pdm add ./typing-extensions-latest + - name: Install pydantic test dependencies + run: pdm install -G testing -G email + - name: List installed dependencies + run: pdm list -vv # pdm equivalent to `pip list` + - name: Run pydantic tests + run: pdm run pytest + + typing_inspect: + name: typing_inspect tests + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.repository == 'python/typing_extensions' + || github.event_name != 'schedule' + }} + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout typing_inspect + uses: actions/checkout@v3 + with: + repository: ilevkivskyi/typing_inspect + - name: Checkout typing_extensions + uses: actions/checkout@v3 + with: + path: typing-extensions-latest + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install typing_inspect test dependencies + run: pip install -r test-requirements.txt + - name: Install typing_extensions latest + run: pip install ./typing-extensions-latest + - name: List all installed dependencies + run: pip freeze --all + - name: Run typing_inspect tests + run: pytest + + pyanalyze: + name: pyanalyze tests + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.repository == 'python/typing_extensions' + || github.event_name != 'schedule' + }} + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out pyanalyze + uses: actions/checkout@v3 + with: + repository: quora/pyanalyze + - name: Checkout typing_extensions + uses: actions/checkout@v3 + with: + path: typing-extensions-latest + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install pyanalyze test requirements + run: pip install .[tests] + - name: Install typing_extensions latest + run: pip install ./typing-extensions-latest + - name: List all installed dependencies + run: pip freeze --all + - name: Run pyanalyze tests + run: pytest pyanalyze/ + + typeguard: + name: typeguard tests + if: false # TODO: unskip when typeguard's tests pass on typing_extensions>=4.6.0 + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out typeguard + uses: actions/checkout@v3 + with: + repository: agronholm/typeguard + - name: Checkout typing_extensions + uses: actions/checkout@v3 + with: + path: typing-extensions-latest + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install typeguard test requirements + run: pip install -e .[test] + - name: Install typing_extensions latest + run: pip install ./typing-extensions-latest + - name: List all installed dependencies + run: pip freeze --all + - name: Run typeguard tests + run: pytest + + typed-argument-parser: + name: typed-argument-parser tests + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.repository == 'python/typing_extensions' + || github.event_name != 'schedule' + }} + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Check out typed-argument-parser + uses: actions/checkout@v3 + with: + repository: swansonk14/typed-argument-parser + - name: Checkout typing_extensions + uses: actions/checkout@v3 + with: + path: typing-extensions-latest + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Configure git for typed-argument-parser tests + # typed-argument parser does this in their CI, + # and the tests fail unless we do this + run: | + git config --global user.email "you@example.com" + git config --global user.name "Your Name" + - name: Install typed-argument-parser test requirements + run: | + pip install -e . + pip install pytest + - name: Install typing_extensions latest + run: pip install ./typing-extensions-latest + - name: List all installed dependencies + run: pip freeze --all + - name: Run typed-argument-parser tests + run: pytest + + stubtest: + name: stubtest tests + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.repository == 'python/typing_extensions' + || github.event_name != 'schedule' + }} + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout mypy for stubtest tests + uses: actions/checkout@v3 + with: + repository: python/mypy + - name: Checkout typing_extensions + uses: actions/checkout@v3 + with: + path: typing-extensions-latest + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install mypy test requirements + run: | + pip install -r test-requirements.txt + pip install -e . + - name: Install typing_extensions latest + run: pip install ./typing-extensions-latest + - name: List all installed dependencies + run: pip freeze --all + - name: Run stubtest tests + run: pytest ./mypy/test/teststubtest.py + + cattrs: + name: cattrs tests + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.repository == 'python/typing_extensions' + || github.event_name != 'schedule' + }} + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout cattrs + uses: actions/checkout@v3 + with: + repository: python-attrs/cattrs + - name: Checkout typing_extensions + uses: actions/checkout@v3 + with: + path: typing-extensions-latest + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install poetry for cattrs + run: pip install poetry + - name: Add latest typing-extensions as a dependency + run: poetry add ./typing-extensions-latest + - name: Install cattrs test dependencies + run: poetry install -v --all-extras + - name: List all installed dependencies + run: poetry show + - name: Run cattrs tests + run: poetry run pytest tests + + create-issue-on-failure: + name: Create an issue if daily tests failed + runs-on: ubuntu-latest + + needs: + - pydantic + - typing_inspect + - pyanalyze + - typeguard + - typed-argument-parser + - stubtest + - cattrs + + if: >- + ${{ + github.repository == 'python/typing_extensions' + && always() + && github.event_name == 'schedule' + && ( + needs.pydantic.result == 'failure' + || needs.typing_inspect.result == 'failure' + || needs.pyanalyze.result == 'failure' + || needs.typeguard.result == 'failure' + || needs.typed-argument-parser.result == 'failure' + || needs.stubtest.result == 'failure' + || needs.cattrs.result == 'failure' + ) + }} + + permissions: + issues: write + + steps: + - uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.create({ + owner: "python", + repo: "typing_extensions", + title: `Third-party tests failed on ${new Date().toDateString()}`, + body: "Runs listed here: https://github.com/python/typing_extensions/actions/workflows/third_party.yml", + }) From cef8f0ed1ac803acac5b6201451b09538de15357 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 2 Jun 2023 00:31:35 +0100 Subject: [PATCH 05/37] Third-party tests: cattrs has switched to pdm (#209) --- .github/workflows/third_party.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index 19be0cf3..cde11c14 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -281,16 +281,18 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install poetry for cattrs - run: pip install poetry + - name: Install pdm for cattrs + run: pip install pdm - name: Add latest typing-extensions as a dependency - run: poetry add ./typing-extensions-latest + run: | + pdm remove typing-extensions + pdm add --dev ./typing-extensions-latest - name: Install cattrs test dependencies - run: poetry install -v --all-extras + run: pdm install --dev -G :all - name: List all installed dependencies - run: poetry show + run: pdm list -vv - name: Run cattrs tests - run: poetry run pytest tests + run: pdm run pytest tests create-issue-on-failure: name: Create an issue if daily tests failed From f9d21b1aaaed1e0eacd12acda1c6d665a87a25e8 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 2 Jun 2023 00:35:00 +0100 Subject: [PATCH 06/37] Backport CPython PR 105152 (#208) --- CHANGELOG.md | 7 ++ src/test_typing_extensions.py | 116 ++++++++++++++++++++++++++++------ src/typing_extensions.py | 36 +++++------ 3 files changed, 120 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae47941d..4bd5cad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Unreleased +- Fix a regression introduced in v4.6.0 in the implementation of + runtime-checkable protocols. The regression meant + that doing `class Foo(X, typing_extensions.Protocol)`, where `X` was a class that + had `abc.ABCMeta` as its metaclass, would then cause subsequent + `isinstance(1, X)` calls to erroneously raise `TypeError`. Patch by + Alex Waygood (backporting the CPython PR + https://github.com/python/cpython/pull/105152). - Sync the repository's LICENSE file with that of CPython. `typing_extensions` is distributed under the same license as CPython itself. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 24f51e65..f9c3389c 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1698,7 +1698,7 @@ class NT(NamedTuple): skip_if_py312b1 = skipIf( sys.version_info == (3, 12, 0, 'beta', 1), - "CPython had a bug in 3.12.0b1" + "CPython had bugs in 3.12.0b1" ) @@ -1902,40 +1902,75 @@ def x(self): ... self.assertIsSubclass(C, P) self.assertIsSubclass(C, PG) self.assertIsSubclass(BadP, PG) - with self.assertRaises(TypeError): + + no_subscripted_generics = ( + "Subscripted generics cannot be used with class and instance checks" + ) + + with self.assertRaisesRegex(TypeError, no_subscripted_generics): issubclass(C, PG[T]) - with self.assertRaises(TypeError): + with self.assertRaisesRegex(TypeError, no_subscripted_generics): issubclass(C, PG[C]) - with self.assertRaises(TypeError): + + only_runtime_checkable_protocols = ( + "Instance and class checks can only be used with " + "@runtime_checkable protocols" + ) + + with self.assertRaisesRegex(TypeError, only_runtime_checkable_protocols): issubclass(C, BadP) - with self.assertRaises(TypeError): + with self.assertRaisesRegex(TypeError, only_runtime_checkable_protocols): issubclass(C, BadPG) - with self.assertRaises(TypeError): + + with self.assertRaisesRegex(TypeError, no_subscripted_generics): issubclass(P, PG[T]) - with self.assertRaises(TypeError): + with self.assertRaisesRegex(TypeError, no_subscripted_generics): issubclass(PG, PG[int]) + only_classes_allowed = r"issubclass\(\) arg 1 must be a class" + + with self.assertRaisesRegex(TypeError, only_classes_allowed): + issubclass(1, P) + with self.assertRaisesRegex(TypeError, only_classes_allowed): + issubclass(1, PG) + with self.assertRaisesRegex(TypeError, only_classes_allowed): + issubclass(1, BadP) + with self.assertRaisesRegex(TypeError, only_classes_allowed): + issubclass(1, BadPG) + def test_protocols_issubclass_non_callable(self): class C: x = 1 + @runtime_checkable class PNonCall(Protocol): x = 1 - with self.assertRaises(TypeError): + + non_callable_members_illegal = ( + "Protocols with non-method members don't support issubclass()" + ) + + with self.assertRaisesRegex(TypeError, non_callable_members_illegal): issubclass(C, PNonCall) + self.assertIsInstance(C(), PNonCall) PNonCall.register(C) - with self.assertRaises(TypeError): + + with self.assertRaisesRegex(TypeError, non_callable_members_illegal): issubclass(C, PNonCall) + self.assertIsInstance(C(), PNonCall) + # check that non-protocol subclasses are not affected class D(PNonCall): ... + self.assertNotIsSubclass(C, D) self.assertNotIsInstance(C(), D) D.register(C) self.assertIsSubclass(C, D) self.assertIsInstance(C(), D) - with self.assertRaises(TypeError): + + with self.assertRaisesRegex(TypeError, non_callable_members_illegal): issubclass(D, PNonCall) def test_no_weird_caching_with_issubclass_after_isinstance(self): @@ -1954,7 +1989,10 @@ def __init__(self) -> None: # as the cached result of the isinstance() check immediately above # would mean the issubclass() call would short-circuit # before we got to the "raise TypeError" line - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + "Protocols with non-method members don't support issubclass()" + ): issubclass(Eggs, Spam) def test_no_weird_caching_with_issubclass_after_isinstance_2(self): @@ -1971,7 +2009,10 @@ class Eggs: ... # as the cached result of the isinstance() check immediately above # would mean the issubclass() call would short-circuit # before we got to the "raise TypeError" line - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + "Protocols with non-method members don't support issubclass()" + ): issubclass(Eggs, Spam) def test_no_weird_caching_with_issubclass_after_isinstance_3(self): @@ -1992,7 +2033,10 @@ def __getattr__(self, attr): # as the cached result of the isinstance() check immediately above # would mean the issubclass() call would short-circuit # before we got to the "raise TypeError" line - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + "Protocols with non-method members don't support issubclass()" + ): issubclass(Eggs, Spam) def test_protocols_isinstance(self): @@ -2028,13 +2072,24 @@ def __init__(self): for proto in P, PG, WeirdProto, WeirdProto2, WeirderProto: with self.subTest(klass=klass.__name__, proto=proto.__name__): self.assertIsInstance(klass(), proto) - with self.assertRaises(TypeError): + + no_subscripted_generics = ( + "Subscripted generics cannot be used with class and instance checks" + ) + + with self.assertRaisesRegex(TypeError, no_subscripted_generics): isinstance(C(), PG[T]) - with self.assertRaises(TypeError): + with self.assertRaisesRegex(TypeError, no_subscripted_generics): isinstance(C(), PG[C]) - with self.assertRaises(TypeError): + + only_runtime_checkable_msg = ( + "Instance and class checks can only be used " + "with @runtime_checkable protocols" + ) + + with self.assertRaisesRegex(TypeError, only_runtime_checkable_msg): isinstance(C(), BadP) - with self.assertRaises(TypeError): + with self.assertRaisesRegex(TypeError, only_runtime_checkable_msg): isinstance(C(), BadPG) def test_protocols_isinstance_properties_and_descriptors(self): @@ -2435,12 +2490,13 @@ def __subclasshook__(cls, other): self.assertIsSubclass(OKClass, C) self.assertNotIsSubclass(BadClass, C) + @skip_if_py312b1 def test_issubclass_fails_correctly(self): @runtime_checkable class P(Protocol): x = 1 class C: pass - with self.assertRaises(TypeError): + with self.assertRaisesRegex(TypeError, r"issubclass\(\) arg 1 must be a class"): issubclass(C(), P) def test_defining_generic_protocols(self): @@ -2768,6 +2824,30 @@ def __call__(self, *args: Unpack[Ts]) -> T: ... self.assertEqual(Y.__parameters__, ()) self.assertEqual(Y.__args__, (int, bytes, memoryview)) + @skip_if_py312b1 + def test_interaction_with_isinstance_checks_on_superclasses_with_ABCMeta(self): + # Ensure the cache is empty, or this test won't work correctly + collections.abc.Sized._abc_registry_clear() + + class Foo(collections.abc.Sized, Protocol): pass + + # CPython gh-105144: this previously raised TypeError + # if a Protocol subclass of Sized had been created + # before any isinstance() checks against Sized + self.assertNotIsInstance(1, collections.abc.Sized) + + @skip_if_py312b1 + def test_interaction_with_isinstance_checks_on_superclasses_with_ABCMeta_2(self): + # Ensure the cache is empty, or this test won't work correctly + collections.abc.Sized._abc_registry_clear() + + class Foo(typing.Sized, Protocol): pass + + # CPython gh-105144: this previously raised TypeError + # if a Protocol subclass of Sized had been created + # before any isinstance() checks against Sized + self.assertNotIsInstance(1, typing.Sized) + class Point2DGeneric(Generic[T], TypedDict): a: T diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 9aa84d7e..1b92c396 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -547,7 +547,7 @@ def _caller(depth=2): Protocol = typing.Protocol runtime_checkable = typing.runtime_checkable else: - def _allow_reckless_class_checks(depth=4): + def _allow_reckless_class_checks(depth=3): """Allow instance and class checks for special stdlib modules. The abc and functools modules indiscriminately call isinstance() and issubclass() on the whole MRO of a user class, which may contain protocols. @@ -572,14 +572,22 @@ def __init__(cls, *args, **kwargs): ) def __subclasscheck__(cls, other): + if not isinstance(other, type): + # Same error message as for issubclass(1, int). + raise TypeError('issubclass() arg 1 must be a class') if ( getattr(cls, '_is_protocol', False) - and not cls.__callable_proto_members_only__ - and not _allow_reckless_class_checks(depth=3) + and not _allow_reckless_class_checks() ): - raise TypeError( - "Protocols with non-method members don't support issubclass()" - ) + if not cls.__callable_proto_members_only__: + raise TypeError( + "Protocols with non-method members don't support issubclass()" + ) + if not getattr(cls, '_is_runtime_protocol', False): + raise TypeError( + "Instance and class checks can only be used with " + "@runtime_checkable protocols" + ) return super().__subclasscheck__(other) def __instancecheck__(cls, instance): @@ -591,7 +599,7 @@ def __instancecheck__(cls, instance): if ( not getattr(cls, '_is_runtime_protocol', False) and - not _allow_reckless_class_checks(depth=2) + not _allow_reckless_class_checks() ): raise TypeError("Instance and class checks can only be used with" " @runtime_checkable protocols") @@ -632,18 +640,6 @@ def _proto_hook(cls, other): if not cls.__dict__.get('_is_protocol', False): return NotImplemented - # First, perform various sanity checks. - if not getattr(cls, '_is_runtime_protocol', False): - if _allow_reckless_class_checks(): - return NotImplemented - raise TypeError("Instance and class checks can only be used with" - " @runtime_checkable protocols") - - if not isinstance(other, type): - # Same error message as for issubclass(1, int). - raise TypeError('issubclass() arg 1 must be a class') - - # Second, perform the actual structural compatibility check. for attr in cls.__protocol_attrs__: for base in other.__mro__: # Check if the members appears in the class dictionary... @@ -658,8 +654,6 @@ def _proto_hook(cls, other): isinstance(annotations, collections.abc.Mapping) and attr in annotations and issubclass(other, (typing.Generic, _ProtocolMeta)) - # All subclasses of Generic have an _is_proto attribute on 3.8+ - # But not on 3.7 and getattr(other, "_is_protocol", False) ): break From aaaa807797b9a4d8a60de4bd7ea594d77499744c Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 1 Jun 2023 16:56:59 -0700 Subject: [PATCH 07/37] Fixup changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4d6973..8d36fe6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# Unreleased + +- Declare support for Python 3.12. Patch by Jelle Zijlstra. + # Release 4.6.3 (June 1, 2023) - Fix a regression introduced in v4.6.0 in the implementation of @@ -13,7 +17,6 @@ - Skip a problematic test on Python 3.12.0b1. The test fails on 3.12.0b1 due to a bug in CPython, which will be fixed in 3.12.0b2. The `typing_extensions` test suite now passes on 3.12.0b1. -- Declare support for Python 3.12. Patch by Jelle Zijlstra. # Release 4.6.2 (May 25, 2023) From 0f9fb788f94f2e77e3d1c01e27b8bcf02382e427 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 2 Jun 2023 03:04:15 -0700 Subject: [PATCH 08/37] Document how to run tests (#211) --- CONTRIBUTING.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b1a093b..57b10388 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,25 @@ Starting with version 4.0.0, `typing_extensions` uses [Semantic Versioning](https://semver.org/). See the documentation for more detail. +# Running tests + +Testing `typing_extensions` can be tricky because many development tools depend on +`typing_extensions`, so you may end up testing some installed version of the library, +rather than your local code. + +The simplest way to run the tests locally is: + +- `cd src/` +- `python test_typing_extensions.py` + +Alternatively, you can invoke `unittest` explicitly: + +- `python -m unittest test_typing_extensions.py` + +Running these commands in the `src/` directory ensures that the local file +`typing_extensions.py` is used, instead of any other version of the library you +may have installed. + # Workflow for PyPI releases - Make sure you follow the versioning policy in the documentation From d95cc228ea96feec105592a9902e5b2d6cc048a9 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 2 Jun 2023 03:05:52 -0700 Subject: [PATCH 09/37] Fix tests on Python 3.13, add note on TypedDict kwargs (#212) Fixes #204 --- CHANGELOG.md | 3 +++ doc/index.rst | 7 +++++++ src/test_typing_extensions.py | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d36fe6e..39bf2b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Unreleased - Declare support for Python 3.12. Patch by Jelle Zijlstra. +- Fix tests on Python 3.13, which removes support for creating + `TypedDict` classes through the keyword-argument syntax. Patch by + Jelle Zijlstra. # Release 4.6.3 (June 1, 2023) diff --git a/doc/index.rst b/doc/index.rst index 6b1a6f0b..177f8a14 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -276,6 +276,13 @@ Special typing primitives ``typing_extensions`` backport provides all of these features and bugfixes on all Python versions. + Historically, ``TypedDict`` has supported an alternative creation syntax + where the fields are supplied as keyword arguments (e.g., + ``TypedDict("TD", a=int, b=str)``). In CPython, this feature was deprecated + in Python 3.11 and removed in Python 3.13. ``typing_extensions.TypedDict`` + 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. + .. versionchanged:: 4.3.0 Added support for generic ``TypedDict``\ s. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index f9c3389c..62c70be6 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2879,6 +2879,12 @@ def test_basics_iterable_syntax(self): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) self.assertEqual(Emp.__total__, True) + @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) + + @skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs") def test_basics_keywords_syntax(self): with self.assertWarns(DeprecationWarning): Emp = TypedDict('Emp', name=str, id=int) @@ -2895,6 +2901,7 @@ def test_basics_keywords_syntax(self): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) self.assertEqual(Emp.__total__, True) + @skipIf(sys.version_info >= (3, 13), "3.13 removes support for kwargs") def test_typeddict_special_keyword_names(self): with self.assertWarns(DeprecationWarning): TD = TypedDict("TD", cls=type, self=object, typename=str, _typename=int, From 7c4bc2e883f1ae3b35d7575317de935ec813b92d Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 3 Jun 2023 12:03:21 +0100 Subject: [PATCH 10/37] Skip `cattrs` tests on 3.7 (#214) Temporary workaround for #213 until the tests pass on 3.7 again --- .github/workflows/third_party.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index cde11c14..4cc74225 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -265,7 +265,9 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + # TODO: add 3.7 back to this matrix when tests pass on 3.7 again + # (issue #213) + python-version: ["3.8", "3.9", "3.10", "3.11"] runs-on: ubuntu-latest timeout-minutes: 60 steps: From ed09c9f4b8222a79810ec89c02ba50e72ec59604 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 4 Jun 2023 14:12:47 +0100 Subject: [PATCH 11/37] Remove some unnecessary `exec()`s in the test suite (#219) --- src/test_typing_extensions.py | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 62c70be6..a9fdcc0f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1320,24 +1320,18 @@ def test_isinstance_collections(self): issubclass(collections.Counter, typing_extensions.Counter[str]) def test_awaitable(self): - ns = {} - exec( - "async def foo() -> typing_extensions.Awaitable[int]:\n" - " return await AwaitableWrapper(42)\n", - globals(), ns) - foo = ns['foo'] + async def foo() -> typing_extensions.Awaitable[int]: + return await AwaitableWrapper(42) + g = foo() self.assertIsInstance(g, typing_extensions.Awaitable) self.assertNotIsInstance(foo, typing_extensions.Awaitable) g.send(None) # Run foo() till completion, to avoid warning. def test_coroutine(self): - ns = {} - exec( - "async def foo():\n" - " return\n", - globals(), ns) - foo = ns['foo'] + async def foo(): + return + g = foo() self.assertIsInstance(g, typing_extensions.Coroutine) with self.assertRaises(TypeError): @@ -1457,10 +1451,10 @@ class MyCounter(typing_extensions.Counter[int]): self.assertIsInstance(d, typing_extensions.Counter) def test_async_generator(self): - ns = {} - exec("async def f():\n" - " yield 42\n", globals(), ns) - g = ns['f']() + async def f(): + yield 42 + + g = f() self.assertIsSubclass(type(g), typing_extensions.AsyncGenerator) def test_no_async_generator_instantiation(self): @@ -1478,9 +1472,8 @@ def asend(self, value): def athrow(self, typ, val=None, tb=None): pass - ns = {} - exec('async def g(): yield 0', globals(), ns) - g = ns['g'] + async def g(): yield 0 + self.assertIsSubclass(G, typing_extensions.AsyncGenerator) self.assertIsSubclass(G, typing_extensions.AsyncIterable) self.assertIsSubclass(G, collections.abc.AsyncGenerator) From afabbbdd6df2dfc76b06f91b3e1feffe7d046dbc Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 5 Jun 2023 10:48:01 +0100 Subject: [PATCH 12/37] Allow Protocols to inherit from typing_extensions.Buffer or collections.abc.Buffer (#220) --- CHANGELOG.md | 3 +++ src/test_typing_extensions.py | 40 +++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 3 ++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bf2b53..13a9c3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - Fix tests on Python 3.13, which removes support for creating `TypedDict` classes through the keyword-argument syntax. Patch by Jelle Zijlstra. +- Allow `Protocol` classes to inherit from `typing_extensions.Buffer` or + `collections.abc.Buffer`. Patch by Alex Waygood (backporting + https://github.com/python/cpython/pull/104827, by Jelle Zijlstra). # Release 4.6.3 (June 1, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index a9fdcc0f..5fa9c0c3 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2737,6 +2737,28 @@ def close(self): self.assertIsSubclass(B, Custom) self.assertNotIsSubclass(A, Custom) + @skipUnless( + hasattr(collections.abc, "Buffer"), + "needs collections.abc.Buffer to exist" + ) + @skip_if_py312b1 + def test_collections_abc_buffer_protocol_allowed(self): + @runtime_checkable + class ReleasableBuffer(collections.abc.Buffer, Protocol): + def __release_buffer__(self, mv: memoryview) -> None: ... + + class C: pass + class D: + def __buffer__(self, flags: int) -> memoryview: + return memoryview(b'') + def __release_buffer__(self, mv: memoryview) -> None: + pass + + self.assertIsSubclass(D, ReleasableBuffer) + self.assertIsInstance(D(), ReleasableBuffer) + self.assertNotIsSubclass(C, ReleasableBuffer) + self.assertNotIsInstance(C(), ReleasableBuffer) + def test_builtin_protocol_allowlist(self): with self.assertRaises(TypeError): class CustomProtocol(TestCase, Protocol): @@ -2745,6 +2767,24 @@ class CustomProtocol(TestCase, Protocol): class CustomContextManager(typing.ContextManager, Protocol): pass + @skip_if_py312b1 + def test_typing_extensions_protocol_allowlist(self): + @runtime_checkable + class ReleasableBuffer(Buffer, Protocol): + def __release_buffer__(self, mv: memoryview) -> None: ... + + class C: pass + class D: + def __buffer__(self, flags: int) -> memoryview: + return memoryview(b'') + def __release_buffer__(self, mv: memoryview) -> None: + pass + + self.assertIsSubclass(D, ReleasableBuffer) + self.assertIsInstance(D(), ReleasableBuffer) + self.assertNotIsSubclass(C, ReleasableBuffer) + self.assertNotIsInstance(C(), ReleasableBuffer) + def test_non_runtime_protocol_isinstance_check(self): class P(Protocol): x: int diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 1b92c396..5ac6dcf2 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -453,9 +453,10 @@ def clear_overloads(): _PROTO_ALLOWLIST = { 'collections.abc': [ 'Callable', 'Awaitable', 'Iterable', 'Iterator', 'AsyncIterable', - 'Hashable', 'Sized', 'Container', 'Collection', 'Reversible', + 'Hashable', 'Sized', 'Container', 'Collection', 'Reversible', 'Buffer', ], 'contextlib': ['AbstractContextManager', 'AbstractAsyncContextManager'], + 'typing_extensions': ['Buffer'], } From 0363a6d51884017b8b1a77a0e38fe02327edeb14 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 5 Jun 2023 15:14:34 +0100 Subject: [PATCH 13/37] Backport recent fixes to `Protocol` from 3.12 (#218) --- .github/workflows/third_party.yml | 4 +- CHANGELOG.md | 4 ++ src/test_typing_extensions.py | 75 +++++++++++++++++++++++++++++++ src/typing_extensions.py | 38 ++++++++++------ 4 files changed, 105 insertions(+), 16 deletions(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index 4cc74225..cde11c14 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -265,9 +265,7 @@ jobs: strategy: fail-fast: false matrix: - # TODO: add 3.7 back to this matrix when tests pass on 3.7 again - # (issue #213) - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a9c3fc..a424b723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - Fix tests on Python 3.13, which removes support for creating `TypedDict` classes through the keyword-argument syntax. Patch by Jelle Zijlstra. +- Fix a regression introduced in v4.6.3 that meant that + ``issubclass(object, typing_extensions.Protocol)`` would erroneously raise + ``TypeError``. Patch by Alex Waygood (backporting the CPython PR + https://github.com/python/cpython/pull/105239). - Allow `Protocol` classes to inherit from `typing_extensions.Buffer` or `collections.abc.Buffer`. Patch by Alex Waygood (backporting https://github.com/python/cpython/pull/104827, by Jelle Zijlstra). diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 5fa9c0c3..37d4c5d7 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1,6 +1,7 @@ import sys import os import abc +import gc import io import contextlib import collections @@ -1931,6 +1932,80 @@ def x(self): ... with self.assertRaisesRegex(TypeError, only_classes_allowed): issubclass(1, BadPG) + @skip_if_py312b1 + def test_issubclass_and_isinstance_on_Protocol_itself(self): + class C: + def x(self): pass + + self.assertNotIsSubclass(object, Protocol) + self.assertNotIsInstance(object(), Protocol) + + self.assertNotIsSubclass(str, Protocol) + self.assertNotIsInstance('foo', Protocol) + + self.assertNotIsSubclass(C, Protocol) + self.assertNotIsInstance(C(), Protocol) + + only_classes_allowed = r"issubclass\(\) arg 1 must be a class" + + with self.assertRaisesRegex(TypeError, only_classes_allowed): + issubclass(1, Protocol) + with self.assertRaisesRegex(TypeError, only_classes_allowed): + issubclass('foo', Protocol) + with self.assertRaisesRegex(TypeError, only_classes_allowed): + issubclass(C(), Protocol) + + T = TypeVar('T') + + @runtime_checkable + class EmptyProtocol(Protocol): pass + + @runtime_checkable + class SupportsStartsWith(Protocol): + def startswith(self, x: str) -> bool: ... + + @runtime_checkable + class SupportsX(Protocol[T]): + def x(self): ... + + for proto in EmptyProtocol, SupportsStartsWith, SupportsX: + with self.subTest(proto=proto.__name__): + self.assertIsSubclass(proto, Protocol) + + # gh-105237 / PR #105239: + # check that the presence of Protocol subclasses + # where `issubclass(X, )` evaluates to True + # doesn't influence the result of `issubclass(X, Protocol)` + + self.assertIsSubclass(object, EmptyProtocol) + self.assertIsInstance(object(), EmptyProtocol) + self.assertNotIsSubclass(object, Protocol) + self.assertNotIsInstance(object(), Protocol) + + self.assertIsSubclass(str, SupportsStartsWith) + self.assertIsInstance('foo', SupportsStartsWith) + self.assertNotIsSubclass(str, Protocol) + self.assertNotIsInstance('foo', Protocol) + + self.assertIsSubclass(C, SupportsX) + self.assertIsInstance(C(), SupportsX) + self.assertNotIsSubclass(C, Protocol) + self.assertNotIsInstance(C(), Protocol) + + @skip_if_py312b1 + def test_isinstance_checks_not_at_whim_of_gc(self): + self.addCleanup(gc.enable) + gc.disable() + + with self.assertRaisesRegex( + TypeError, + "Protocols can only inherit from other protocols" + ): + class Foo(collections.abc.Mapping, Protocol): + pass + + self.assertNotIsInstance([], collections.abc.Mapping) + def test_protocols_issubclass_non_callable(self): class C: x = 1 diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 5ac6dcf2..135eefd9 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -562,6 +562,25 @@ def _no_init(self, *args, **kwargs): class _ProtocolMeta(abc.ABCMeta): # This metaclass is somewhat unfortunate, # but is necessary for several reasons... + def __new__(mcls, name, bases, namespace, **kwargs): + if name == "Protocol" and len(bases) < 2: + pass + elif Protocol in bases: + for base in bases: + if not ( + base in {object, typing.Generic} + or base.__name__ in _PROTO_ALLOWLIST.get(base.__module__, []) + or ( + isinstance(base, _ProtocolMeta) + and getattr(base, "_is_protocol", False) + ) + ): + raise TypeError( + f"Protocols can only inherit from other protocols, " + f"got {base!r}" + ) + return super().__new__(mcls, name, bases, namespace, **kwargs) + def __init__(cls, *args, **kwargs): super().__init__(*args, **kwargs) if getattr(cls, "_is_protocol", False): @@ -573,6 +592,8 @@ def __init__(cls, *args, **kwargs): ) def __subclasscheck__(cls, other): + if cls is Protocol: + return type.__subclasscheck__(cls, other) if not isinstance(other, type): # Same error message as for issubclass(1, int). raise TypeError('issubclass() arg 1 must be a class') @@ -594,6 +615,8 @@ def __subclasscheck__(cls, other): def __instancecheck__(cls, instance): # We need this method for situations where attributes are # assigned in __init__. + if cls is Protocol: + return type.__instancecheck__(cls, instance) if not getattr(cls, "_is_protocol", False): # i.e., it's a concrete subclass of a protocol return super().__instancecheck__(instance) @@ -662,15 +685,6 @@ def _proto_hook(cls, other): return NotImplemented return True - def _check_proto_bases(cls): - for base in cls.__bases__: - if not (base in (object, typing.Generic) or - base.__module__ in _PROTO_ALLOWLIST and - base.__name__ in _PROTO_ALLOWLIST[base.__module__] or - isinstance(base, _ProtocolMeta) and base._is_protocol): - raise TypeError('Protocols can only inherit from other' - f' protocols, got {repr(base)}') - if sys.version_info >= (3, 8): class Protocol(typing.Generic, metaclass=_ProtocolMeta): __doc__ = typing.Protocol.__doc__ @@ -693,8 +707,7 @@ def __init_subclass__(cls, *args, **kwargs): if not cls._is_protocol: return - # ... otherwise check consistency of bases, and prohibit instantiation. - _check_proto_bases(cls) + # ... otherwise prohibit instantiation. if cls.__init__ is Protocol.__init__: cls.__init__ = _no_init @@ -789,8 +802,7 @@ def __init_subclass__(cls, *args, **kwargs): if not cls._is_protocol: return - # Check consistency of bases. - _check_proto_bases(cls) + # Prohibit instantiation if cls.__init__ is Protocol.__init__: cls.__init__ = _no_init From 8a1ffe6277ed8a4edab40d70dfbfba8bbd25193a Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 7 Jun 2023 14:11:39 +0100 Subject: [PATCH 14/37] Fix pydantic tests (#224) --- .github/workflows/third_party.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index cde11c14..2e3ee2ad 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -54,7 +54,6 @@ jobs: uses: pdm-project/setup-pdm@v3 with: python-version: ${{ matrix.python-version }} - cache: true - name: Add local version of typing_extensions as a dependency run: pdm add ./typing-extensions-latest - name: Install pydantic test dependencies From 76c971e8d859310747ba8636a99c8d2e9d616f16 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 8 Jun 2023 10:54:16 +0100 Subject: [PATCH 15/37] Backport some new `Protocol` tests from CPython (#227) --- src/test_typing_extensions.py | 65 +++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 37d4c5d7..4bec45f6 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2956,6 +2956,71 @@ class Foo(typing.Sized, Protocol): pass # before any isinstance() checks against Sized self.assertNotIsInstance(1, typing.Sized) + def test_empty_protocol_decorated_with_final(self): + @final + @runtime_checkable + class EmptyProtocol(Protocol): ... + + self.assertIsSubclass(object, EmptyProtocol) + self.assertIsInstance(object(), EmptyProtocol) + + def test_protocol_decorated_with_final_callable_members(self): + @final + @runtime_checkable + class ProtocolWithMethod(Protocol): + def startswith(self, string: str) -> bool: ... + + self.assertIsSubclass(str, ProtocolWithMethod) + self.assertNotIsSubclass(int, ProtocolWithMethod) + self.assertIsInstance('foo', ProtocolWithMethod) + self.assertNotIsInstance(42, ProtocolWithMethod) + + def test_protocol_decorated_with_final_noncallable_members(self): + @final + @runtime_checkable + class ProtocolWithNonCallableMember(Protocol): + x: int + + class Foo: + x = 42 + + only_callable_members_please = ( + r"Protocols with non-method members don't support issubclass()" + ) + + with self.assertRaisesRegex(TypeError, only_callable_members_please): + issubclass(Foo, ProtocolWithNonCallableMember) + + with self.assertRaisesRegex(TypeError, only_callable_members_please): + issubclass(int, ProtocolWithNonCallableMember) + + self.assertIsInstance(Foo(), ProtocolWithNonCallableMember) + self.assertNotIsInstance(42, ProtocolWithNonCallableMember) + + def test_protocol_decorated_with_final_mixed_members(self): + @final + @runtime_checkable + class ProtocolWithMixedMembers(Protocol): + x: int + def method(self) -> None: ... + + class Foo: + x = 42 + def method(self) -> None: ... + + only_callable_members_please = ( + r"Protocols with non-method members don't support issubclass()" + ) + + with self.assertRaisesRegex(TypeError, only_callable_members_please): + issubclass(Foo, ProtocolWithMixedMembers) + + with self.assertRaisesRegex(TypeError, only_callable_members_please): + issubclass(int, ProtocolWithMixedMembers) + + self.assertIsInstance(Foo(), ProtocolWithMixedMembers) + self.assertNotIsInstance(42, ProtocolWithMixedMembers) + class Point2DGeneric(Generic[T], TypedDict): a: T From 159a0a69d209dc5de618f6f74a823c07f0111139 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 8 Jun 2023 10:56:50 +0100 Subject: [PATCH 16/37] Improve the cron-job workflows (#226) --- .github/workflows/ci.yml | 7 ++- .github/workflows/third_party.yml | 76 ++++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b94d670..af020c03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,11 @@ jobs: # if 'schedule' was the trigger, # don't run it on contributors' forks ${{ - github.repository == 'python/typing_extensions' - || github.event_name != 'schedule' + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) }} strategy: diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index 2e3ee2ad..a25f5d66 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -32,8 +32,11 @@ jobs: # if 'schedule' was the trigger, # don't run it on contributors' forks ${{ - github.repository == 'python/typing_extensions' - || github.event_name != 'schedule' + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) }} strategy: fail-fast: false @@ -69,8 +72,11 @@ jobs: # if 'schedule' was the trigger, # don't run it on contributors' forks ${{ - github.repository == 'python/typing_extensions' - || github.event_name != 'schedule' + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) }} strategy: fail-fast: false @@ -83,6 +89,7 @@ jobs: uses: actions/checkout@v3 with: repository: ilevkivskyi/typing_inspect + path: typing_inspect - name: Checkout typing_extensions uses: actions/checkout@v3 with: @@ -92,13 +99,15 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install typing_inspect test dependencies - run: pip install -r test-requirements.txt + run: pip install -r typing_inspect/test-requirements.txt - name: Install typing_extensions latest run: pip install ./typing-extensions-latest - name: List all installed dependencies run: pip freeze --all - name: Run typing_inspect tests - run: pytest + run: | + cd typing_inspect + pytest pyanalyze: name: pyanalyze tests @@ -106,8 +115,11 @@ jobs: # if 'schedule' was the trigger, # don't run it on contributors' forks ${{ - github.repository == 'python/typing_extensions' - || github.event_name != 'schedule' + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) }} strategy: fail-fast: false @@ -120,6 +132,7 @@ jobs: uses: actions/checkout@v3 with: repository: quora/pyanalyze + path: pyanalyze - name: Checkout typing_extensions uses: actions/checkout@v3 with: @@ -129,13 +142,15 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install pyanalyze test requirements - run: pip install .[tests] + run: pip install ./pyanalyze[tests] - name: Install typing_extensions latest run: pip install ./typing-extensions-latest - name: List all installed dependencies run: pip freeze --all - name: Run pyanalyze tests - run: pytest pyanalyze/ + run: | + cd pyanalyze + pytest pyanalyze/ typeguard: name: typeguard tests @@ -151,6 +166,7 @@ jobs: uses: actions/checkout@v3 with: repository: agronholm/typeguard + path: typeguard - name: Checkout typing_extensions uses: actions/checkout@v3 with: @@ -160,13 +176,15 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install typeguard test requirements - run: pip install -e .[test] + run: pip install -e ./typeguard[test] - name: Install typing_extensions latest run: pip install ./typing-extensions-latest - name: List all installed dependencies run: pip freeze --all - name: Run typeguard tests - run: pytest + run: | + cd typeguard + pytest typed-argument-parser: name: typed-argument-parser tests @@ -174,8 +192,11 @@ jobs: # if 'schedule' was the trigger, # don't run it on contributors' forks ${{ - github.repository == 'python/typing_extensions' - || github.event_name != 'schedule' + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) }} strategy: fail-fast: false @@ -188,6 +209,7 @@ jobs: uses: actions/checkout@v3 with: repository: swansonk14/typed-argument-parser + path: typed-argument-parser - name: Checkout typing_extensions uses: actions/checkout@v3 with: @@ -204,14 +226,16 @@ jobs: git config --global user.name "Your Name" - name: Install typed-argument-parser test requirements run: | - pip install -e . + pip install -e ./typed-argument-parser pip install pytest - name: Install typing_extensions latest run: pip install ./typing-extensions-latest - name: List all installed dependencies run: pip freeze --all - name: Run typed-argument-parser tests - run: pytest + run: | + cd typed-argument-parser + pytest stubtest: name: stubtest tests @@ -219,8 +243,11 @@ jobs: # if 'schedule' was the trigger, # don't run it on contributors' forks ${{ - github.repository == 'python/typing_extensions' - || github.event_name != 'schedule' + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) }} strategy: fail-fast: false @@ -233,6 +260,7 @@ jobs: uses: actions/checkout@v3 with: repository: python/mypy + path: mypy - name: Checkout typing_extensions uses: actions/checkout@v3 with: @@ -243,6 +271,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install mypy test requirements run: | + cd mypy pip install -r test-requirements.txt pip install -e . - name: Install typing_extensions latest @@ -250,7 +279,9 @@ jobs: - name: List all installed dependencies run: pip freeze --all - name: Run stubtest tests - run: pytest ./mypy/test/teststubtest.py + run: | + cd mypy + pytest ./mypy/test/teststubtest.py cattrs: name: cattrs tests @@ -258,8 +289,11 @@ jobs: # if 'schedule' was the trigger, # don't run it on contributors' forks ${{ - github.repository == 'python/typing_extensions' - || github.event_name != 'schedule' + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) }} strategy: fail-fast: false From 4fcf36af79cb560539a25000a2b0561d6cd317ec Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 8 Jun 2023 11:34:35 +0100 Subject: [PATCH 17/37] Reduce duplication in definitions of various special forms (#228) --- src/typing_extensions.py | 71 +++++++++++++--------------------------- 1 file changed, 23 insertions(+), 48 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 135eefd9..1f28e541 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1374,16 +1374,17 @@ def get_args(tp): return () +class _ExtensionsSpecialForm(typing._SpecialForm, _root=True): + def __repr__(self): + return 'typing_extensions.' + self._name + + # 3.10+ if hasattr(typing, 'TypeAlias'): TypeAlias = typing.TypeAlias # 3.9 elif sys.version_info[:2] >= (3, 9): - class _TypeAliasForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - - @_TypeAliasForm + @_ExtensionsSpecialForm def TypeAlias(self, parameters): """Special marker indicating that an assignment should be recognized as a proper type alias definition by type @@ -1398,21 +1399,19 @@ def TypeAlias(self, parameters): raise TypeError(f"{self} is not subscriptable") # 3.7-3.8 else: - class _TypeAliasForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - - TypeAlias = _TypeAliasForm('TypeAlias', - doc="""Special marker indicating that an assignment should - be recognized as a proper type alias definition by type - checkers. + TypeAlias = _ExtensionsSpecialForm( + 'TypeAlias', + doc="""Special marker indicating that an assignment should + be recognized as a proper type alias definition by type + checkers. - For example:: + For example:: - Predicate: TypeAlias = Callable[..., bool] + Predicate: TypeAlias = Callable[..., bool] - It's invalid when used anywhere except as in the example - above.""") + It's invalid when used anywhere except as in the example + above.""" + ) def _set_default(type_param, default): @@ -1727,7 +1726,7 @@ def _concatenate_getitem(self, parameters): _ConcatenateGenericAlias = typing._ConcatenateGenericAlias # noqa: F811 # 3.9 elif sys.version_info[:2] >= (3, 9): - @_TypeAliasForm + @_ExtensionsSpecialForm def Concatenate(self, parameters): """Used in conjunction with ``ParamSpec`` and ``Callable`` to represent a higher order function which adds, removes or transforms parameters of a @@ -1742,10 +1741,7 @@ def Concatenate(self, parameters): return _concatenate_getitem(self, parameters) # 3.7-8 else: - class _ConcatenateForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - + class _ConcatenateForm(_ExtensionsSpecialForm, _root=True): def __getitem__(self, parameters): return _concatenate_getitem(self, parameters) @@ -1767,11 +1763,7 @@ def __getitem__(self, parameters): TypeGuard = typing.TypeGuard # 3.9 elif sys.version_info[:2] >= (3, 9): - class _TypeGuardForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - - @_TypeGuardForm + @_ExtensionsSpecialForm def TypeGuard(self, parameters): """Special typing form used to annotate the return type of a user-defined type guard function. ``TypeGuard`` only accepts a single type argument. @@ -1819,11 +1811,7 @@ def is_str(val: Union[str, float]): return typing._GenericAlias(self, (item,)) # 3.7-3.8 else: - class _TypeGuardForm(typing._SpecialForm, _root=True): - - def __repr__(self): - return 'typing_extensions.' + self._name - + class _TypeGuardForm(_ExtensionsSpecialForm, _root=True): def __getitem__(self, parameters): item = typing._type_check(parameters, f'{self._name} accepts only a single type') @@ -1997,10 +1985,6 @@ def int_or_str(arg: int | str) -> None: Required = typing.Required NotRequired = typing.NotRequired elif sys.version_info[:2] >= (3, 9): - class _ExtensionsSpecialForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - @_ExtensionsSpecialForm def Required(self, parameters): """A special typing construct to mark a key of a total=False TypedDict @@ -2039,10 +2023,7 @@ class Movie(TypedDict): return typing._GenericAlias(self, (item,)) else: - class _RequiredForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - + class _RequiredForm(_ExtensionsSpecialForm, _root=True): def __getitem__(self, parameters): item = typing._type_check(parameters, f'{self._name} accepts only a single type.') @@ -2130,14 +2111,11 @@ def _is_unpack(obj): return get_origin(obj) is Unpack elif sys.version_info[:2] >= (3, 9): - class _UnpackSpecialForm(typing._SpecialForm, _root=True): + class _UnpackSpecialForm(_ExtensionsSpecialForm, _root=True): def __init__(self, getitem): super().__init__(getitem) self.__doc__ = _UNPACK_DOC - def __repr__(self): - return 'typing_extensions.' + self._name - class _UnpackAlias(typing._GenericAlias, _root=True): __class__ = typing.TypeVar @@ -2153,10 +2131,7 @@ def _is_unpack(obj): class _UnpackAlias(typing._GenericAlias, _root=True): __class__ = typing.TypeVar - class _UnpackForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - + class _UnpackForm(_ExtensionsSpecialForm, _root=True): def __getitem__(self, parameters): item = typing._type_check(parameters, f'{self._name} accepts only a single type.') From 4773f276b8a9cc6242b10ae81ccd9dc958cd2c86 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 8 Jun 2023 12:04:06 +0100 Subject: [PATCH 18/37] Docs: Add a recipe for robust runtime introspection (#225) --- doc/index.rst | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/doc/index.rst b/doc/index.rst index 177f8a14..f076ae33 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -73,6 +73,57 @@ the risk of compatibility issues: attributes directly. If some information is not available through a public attribute, consider opening an issue in CPython to add such an API. +Here is an example recipe for a general-purpose function that could be used for +reasonably performant runtime introspection of typing objects. The function +will be resilient against any potential changes in ``typing_extensions`` that +alter whether an object is reimplemented in ``typing_extensions``, rather than +simply being re-exported from the :mod:`typing` module:: + + import functools + import typing + import typing_extensions + from typing import Tuple, Any + + # Use an unbounded cache for this function, for optimal performance + @functools.lru_cache(maxsize=None) + def get_typing_objects_by_name_of(name: str) -> Tuple[Any, ...]: + result = tuple( + getattr(module, name) + # You could potentially also include mypy_extensions here, + # if your library supports mypy_extensions + for module in (typing, typing_extensions) + if hasattr(module, name) + ) + if not result: + raise ValueError( + f"Neither typing nor typing_extensions has an object called {name!r}" + ) + return result + + + # Use a cache here as well, but make it a bounded cache + # (the default cache size is 128) + @functools.lru_cache() + def is_typing_name(obj: object, name: str) -> bool: + return any(obj is thing for thing in get_typing_objects_by_name_of(name)) + +Example usage:: + + >>> import typing, typing_extensions + >>> from functools import partial + >>> from typing_extensions import get_origin + >>> is_literal = partial(is_typing_name, name="Literal") + >>> is_literal(typing.Literal) + True + >>> is_literal(typing_extensions.Literal) + True + >>> is_literal(typing.Any) + False + >>> is_literal(get_origin(typing.Literal[42])) + True + >>> is_literal(get_origin(typing_extensions.Final[42])) + False + Python version support ---------------------- From da859745c9ee7e6f8adf20ddbf36b1d11f4614ea Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 8 Jun 2023 12:51:46 +0100 Subject: [PATCH 19/37] Further deduplicate implementation of special forms (#229) --- src/typing_extensions.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 1f28e541..9e33402d 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -201,17 +201,19 @@ def __new__(cls, *args, **kwargs): ClassVar = typing.ClassVar + +class _ExtensionsSpecialForm(typing._SpecialForm, _root=True): + def __repr__(self): + return 'typing_extensions.' + self._name + + # On older versions of typing there is an internal class named "Final". # 3.8+ if hasattr(typing, 'Final') and sys.version_info[:2] >= (3, 7): Final = typing.Final # 3.7 else: - class _FinalForm(typing._SpecialForm, _root=True): - - def __repr__(self): - return 'typing_extensions.' + self._name - + class _FinalForm(_ExtensionsSpecialForm, _root=True): def __getitem__(self, parameters): item = typing._type_check(parameters, f'{self._name} accepts only a single type.') @@ -303,14 +305,11 @@ def __eq__(self, other): def __hash__(self): return hash(frozenset(_value_and_type_iter(self.__args__))) - class _LiteralForm(typing._SpecialForm, _root=True): + class _LiteralForm(_ExtensionsSpecialForm, _root=True): def __init__(self, doc: str): self._name = 'Literal' self._doc = self.__doc__ = doc - def __repr__(self): - return 'typing_extensions.' + self._name - def __getitem__(self, parameters): if not isinstance(parameters, tuple): parameters = (parameters,) @@ -1374,11 +1373,6 @@ def get_args(tp): return () -class _ExtensionsSpecialForm(typing._SpecialForm, _root=True): - def __repr__(self): - return 'typing_extensions.' + self._name - - # 3.10+ if hasattr(typing, 'TypeAlias'): TypeAlias = typing.TypeAlias From d8265618f2a32c1dff7b2a3594cd2842fb8b6828 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 9 Jun 2023 07:34:01 -0700 Subject: [PATCH 20/37] Reimplement TypedDict in a similar way to Python (#191) Co-authored-by: Alex Waygood --- CHANGELOG.md | 6 + doc/index.rst | 12 ++ src/_typed_dict_test_helper.py | 6 +- src/test_typing_extensions.py | 312 +++++++++++++++++++++++++++++---- src/typing_extensions.py | 201 ++++++++++----------- 5 files changed, 392 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a424b723..7f2bd903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Unreleased +- Align the implementation of `TypedDict` with the implementation in the + standard library on Python 3.9 and higher. + `typing_extensions.TypedDict` is now a function instead of a class. The + private functions `_check_fails`, `_dict_new`, and `_typeddict_new` + have been removed. `is_typeddict` now returns `False` when called with + `TypedDict` itself as the argument. Patch by Jelle Zijlstra. - Declare support for Python 3.12. Patch by Jelle Zijlstra. - Fix tests on Python 3.13, which removes support for creating `TypedDict` classes through the keyword-argument syntax. Patch by diff --git a/doc/index.rst b/doc/index.rst index f076ae33..60497375 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -347,6 +347,12 @@ Special typing primitives Support for the ``__orig_bases__`` attribute was added. + .. versionchanged:: 4.7.0 + + ``TypedDict`` is now a function rather than a class. + This brings ``typing_extensions.TypedDict`` closer to the implementation + of :py:mod:`typing.TypedDict` on Python 3.9 and higher. + .. class:: TypeVar(name, *constraints, bound=None, covariant=False, contravariant=False, infer_variance=False, default=...) @@ -680,6 +686,12 @@ Functions .. versionadded:: 4.1.0 + .. versionchanged:: 4.7.0 + + :func:`is_typeddict` now returns ``False`` when called with + :data:`TypedDict` itself as the argument, consistent with the + behavior of :py:func:`typing.is_typeddict`. + .. function:: reveal_type(obj) See :py:func:`typing.reveal_type`. In ``typing`` since 3.11. diff --git a/src/_typed_dict_test_helper.py b/src/_typed_dict_test_helper.py index 7ffc5e1d..c5582b15 100644 --- a/src/_typed_dict_test_helper.py +++ b/src/_typed_dict_test_helper.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Generic, Optional, T -from typing_extensions import TypedDict +from typing_extensions import TypedDict, Annotated, Required # this class must not be imported into test_typing_extensions.py at top level, otherwise @@ -16,3 +16,7 @@ class Foo(TypedDict): class FooGeneric(TypedDict, Generic[T]): a: Optional[T] + + +class VeryAnnotated(TypedDict, total=False): + a: Annotated[Annotated[Annotated[Required[int], "a"], "b"], "c"] diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 4bec45f6..129cdcaa 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -38,7 +38,7 @@ from typing_extensions import clear_overloads, get_overloads, overload from typing_extensions import NamedTuple from typing_extensions import override, deprecated, Buffer, TypeAliasType, TypeVar -from _typed_dict_test_helper import Foo, FooGeneric +from _typed_dict_test_helper import Foo, FooGeneric, VeryAnnotated # Flags used to mark tests that only apply after a specific # version of the typing module. @@ -1148,10 +1148,26 @@ class NontotalMovie(TypedDict, total=False): title: Required[str] year: int +class ParentNontotalMovie(TypedDict, total=False): + title: Required[str] + +class ChildTotalMovie(ParentNontotalMovie): + year: NotRequired[int] + +class ParentDeeplyAnnotatedMovie(TypedDict): + title: Annotated[Annotated[Required[str], "foobar"], "another level"] + +class ChildDeeplyAnnotatedMovie(ParentDeeplyAnnotatedMovie): + year: NotRequired[Annotated[int, 2000]] + class AnnotatedMovie(TypedDict): title: Annotated[Required[str], "foobar"] year: NotRequired[Annotated[int, 2000]] +class WeirdlyQuotedMovie(TypedDict): + title: Annotated['Annotated[Required[str], "foobar"]', "another level"] + year: NotRequired['Annotated[int, 2000]'] + gth = get_type_hints @@ -3036,8 +3052,7 @@ class BarGeneric(FooGeneric[T], total=False): class TypedDictTests(BaseTestCase): - - def test_basics_iterable_syntax(self): + def test_basics_functional_syntax(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) self.assertIsSubclass(Emp, dict) self.assertIsSubclass(Emp, typing.MutableMapping) @@ -3091,7 +3106,6 @@ def test_typeddict_special_keyword_names(self): self.assertEqual(a['fields'], [('bar', tuple)]) self.assertEqual(a['_fields'], {'baz', set}) - @skipIf(hasattr(typing, 'TypedDict'), "Should be tested by upstream") def test_typeddict_create_errors(self): with self.assertRaises(TypeError): TypedDict.__new__() @@ -3101,12 +3115,7 @@ def test_typeddict_create_errors(self): TypedDict('Emp', [('name', str)], None) with self.assertWarns(DeprecationWarning): - Emp = TypedDict(_typename='Emp', name=str, id=int) - self.assertEqual(Emp.__name__, 'Emp') - self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) - - with self.assertWarns(DeprecationWarning): - Emp = TypedDict('Emp', _fields={'name': str, 'id': int}) + Emp = TypedDict('Emp', name=str, id=int) self.assertEqual(Emp.__name__, 'Emp') self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) @@ -3135,7 +3144,7 @@ def test_typeddict_errors(self): def test_py36_class_syntax_usage(self): self.assertEqual(LabelPoint2D.__name__, 'LabelPoint2D') self.assertEqual(LabelPoint2D.__module__, __name__) - self.assertEqual(get_type_hints(LabelPoint2D), {'x': int, 'y': int, 'label': str}) + self.assertEqual(LabelPoint2D.__annotations__, {'x': int, 'y': int, 'label': str}) self.assertEqual(LabelPoint2D.__bases__, (dict,)) self.assertEqual(LabelPoint2D.__total__, True) self.assertNotIsSubclass(LabelPoint2D, typing.Sequence) @@ -3147,11 +3156,9 @@ def test_py36_class_syntax_usage(self): def test_pickle(self): global EmpD # pickle wants to reference the class by name - EmpD = TypedDict('EmpD', {"name": str, "id": int}) + EmpD = TypedDict('EmpD', {'name': str, 'id': int}) jane = EmpD({'name': 'jane', 'id': 37}) - point = Point2DGeneric(a=5.0, b=3.0) for proto in range(pickle.HIGHEST_PROTOCOL + 1): - # Test non-generic TypedDict z = pickle.dumps(jane, proto) jane2 = pickle.loads(z) self.assertEqual(jane2, jane) @@ -3159,17 +3166,20 @@ def test_pickle(self): ZZ = pickle.dumps(EmpD, proto) EmpDnew = pickle.loads(ZZ) self.assertEqual(EmpDnew({'name': 'jane', 'id': 37}), jane) - # and generic TypedDict - y = pickle.dumps(point, proto) - point2 = pickle.loads(y) - self.assertEqual(point, point2) + + def test_pickle_generic(self): + point = Point2DGeneric(a=5.0, b=3.0) + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + z = pickle.dumps(point, proto) + point2 = pickle.loads(z) + self.assertEqual(point2, point) self.assertEqual(point2, {'a': 5.0, 'b': 3.0}) - YY = pickle.dumps(Point2DGeneric, proto) - Point2DGenericNew = pickle.loads(YY) + ZZ = pickle.dumps(Point2DGeneric, proto) + Point2DGenericNew = pickle.loads(ZZ) self.assertEqual(Point2DGenericNew({'a': 5.0, 'b': 3.0}), point) def test_optional(self): - EmpD = TypedDict('EmpD', {"name": str, "id": int}) + EmpD = TypedDict('EmpD', {'name': str, 'id': int}) self.assertEqual(typing.Optional[EmpD], typing.Union[None, EmpD]) self.assertNotEqual(typing.List[EmpD], typing.Tuple[EmpD]) @@ -3189,25 +3199,30 @@ def test_total(self): self.assertEqual(Options.__optional_keys__, {'log_level', 'log_path'}) def test_optional_keys(self): + class Point2Dor3D(Point2D, total=False): + z: int + assert Point2Dor3D.__required_keys__ == frozenset(['x', 'y']) assert Point2Dor3D.__optional_keys__ == frozenset(['z']) - def test_required_notrequired_keys(self): - assert NontotalMovie.__required_keys__ == frozenset({'title'}) - assert NontotalMovie.__optional_keys__ == frozenset({'year'}) + def test_keys_inheritance(self): + class BaseAnimal(TypedDict): + name: str - assert TotalMovie.__required_keys__ == frozenset({'title'}) - assert TotalMovie.__optional_keys__ == frozenset({'year'}) + class Animal(BaseAnimal, total=False): + voice: str + tail: bool + class Cat(Animal): + fur_color: str - def test_keys_inheritance(self): assert BaseAnimal.__required_keys__ == frozenset(['name']) assert BaseAnimal.__optional_keys__ == frozenset([]) - assert get_type_hints(BaseAnimal) == {'name': str} + assert BaseAnimal.__annotations__ == {'name': str} assert Animal.__required_keys__ == frozenset(['name']) assert Animal.__optional_keys__ == frozenset(['tail', 'voice']) - assert get_type_hints(Animal) == { + assert Animal.__annotations__ == { 'name': str, 'tail': bool, 'voice': str, @@ -3215,19 +3230,168 @@ def test_keys_inheritance(self): assert Cat.__required_keys__ == frozenset(['name', 'fur_color']) assert Cat.__optional_keys__ == frozenset(['tail', 'voice']) - assert get_type_hints(Cat) == { + assert Cat.__annotations__ == { 'fur_color': str, 'name': str, 'tail': bool, 'voice': str, } + def test_required_notrequired_keys(self): + self.assertEqual(NontotalMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(NontotalMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(TotalMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(TotalMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(VeryAnnotated.__required_keys__, + frozenset()) + self.assertEqual(VeryAnnotated.__optional_keys__, + frozenset({"a"})) + + self.assertEqual(AnnotatedMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(AnnotatedMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(WeirdlyQuotedMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(WeirdlyQuotedMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(ChildTotalMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(ChildTotalMovie.__optional_keys__, + frozenset({"year"})) + + self.assertEqual(ChildDeeplyAnnotatedMovie.__required_keys__, + frozenset({"title"})) + self.assertEqual(ChildDeeplyAnnotatedMovie.__optional_keys__, + frozenset({"year"})) + + def test_multiple_inheritance(self): + class One(TypedDict): + one: int + class Two(TypedDict): + two: str + class Untotal(TypedDict, total=False): + untotal: str + Inline = TypedDict('Inline', {'inline': bool}) + class Regular: + pass + + class Child(One, Two): + child: bool + self.assertEqual( + Child.__required_keys__, + frozenset(['one', 'two', 'child']), + ) + self.assertEqual( + Child.__optional_keys__, + frozenset([]), + ) + self.assertEqual( + Child.__annotations__, + {'one': int, 'two': str, 'child': bool}, + ) + + class ChildWithOptional(One, Untotal): + child: bool + self.assertEqual( + ChildWithOptional.__required_keys__, + frozenset(['one', 'child']), + ) + self.assertEqual( + ChildWithOptional.__optional_keys__, + frozenset(['untotal']), + ) + self.assertEqual( + ChildWithOptional.__annotations__, + {'one': int, 'untotal': str, 'child': bool}, + ) + + class ChildWithTotalFalse(One, Untotal, total=False): + child: bool + self.assertEqual( + ChildWithTotalFalse.__required_keys__, + frozenset(['one']), + ) + self.assertEqual( + ChildWithTotalFalse.__optional_keys__, + frozenset(['untotal', 'child']), + ) + self.assertEqual( + ChildWithTotalFalse.__annotations__, + {'one': int, 'untotal': str, 'child': bool}, + ) + + class ChildWithInlineAndOptional(Untotal, Inline): + child: bool + self.assertEqual( + ChildWithInlineAndOptional.__required_keys__, + frozenset(['inline', 'child']), + ) + self.assertEqual( + ChildWithInlineAndOptional.__optional_keys__, + frozenset(['untotal']), + ) + self.assertEqual( + ChildWithInlineAndOptional.__annotations__, + {'inline': bool, 'untotal': str, 'child': bool}, + ) + + wrong_bases = [ + (One, Regular), + (Regular, One), + (One, Two, Regular), + (Inline, Regular), + (Untotal, Regular), + ] + for bases in wrong_bases: + with self.subTest(bases=bases): + with self.assertRaisesRegex( + TypeError, + 'cannot inherit from both a TypedDict type and a non-TypedDict', + ): + class Wrong(*bases): + pass + def test_is_typeddict(self): - assert is_typeddict(Point2D) is True - assert is_typeddict(Point2Dor3D) is True - assert is_typeddict(Union[str, int]) is False + self.assertIs(is_typeddict(Point2D), True) + self.assertIs(is_typeddict(Point2Dor3D), True) + self.assertIs(is_typeddict(Union[str, int]), False) # classes, not instances - assert is_typeddict(Point2D()) is False + self.assertIs(is_typeddict(Point2D()), False) + call_based = TypedDict('call_based', {'a': int}) + self.assertIs(is_typeddict(call_based), True) + self.assertIs(is_typeddict(call_based()), False) + + T = TypeVar("T") + class BarGeneric(TypedDict, Generic[T]): + a: T + self.assertIs(is_typeddict(BarGeneric), True) + self.assertIs(is_typeddict(BarGeneric[int]), False) + self.assertIs(is_typeddict(BarGeneric()), False) + + if hasattr(typing, "TypeAliasType"): + ns = {"TypedDict": TypedDict} + exec("""if True: + class NewGeneric[T](TypedDict): + a: T + """, ns) + NewGeneric = ns["NewGeneric"] + self.assertIs(is_typeddict(NewGeneric), True) + self.assertIs(is_typeddict(NewGeneric[int]), False) + self.assertIs(is_typeddict(NewGeneric()), False) + + # The TypedDict constructor is not itself a TypedDict + self.assertIs(is_typeddict(TypedDict), False) + if hasattr(typing, "TypedDict"): + self.assertIs(is_typeddict(typing.TypedDict), False) @skipUnless(TYPING_3_8_0, "Python 3.8+ required") def test_is_typeddict_against_typeddict_from_typing(self): @@ -3266,6 +3430,24 @@ class FooBarGeneric(BarGeneric[int]): {'a': typing.Optional[T], 'b': int, 'c': str} ) + @skipUnless(TYPING_3_12_0, "PEP 695 required") + def test_pep695_generic_typeddict(self): + ns = {"TypedDict": TypedDict} + exec("""if True: + class A[T](TypedDict): + a: T + """, ns) + A = ns["A"] + T, = A.__type_params__ + self.assertIsInstance(T, TypeVar) + self.assertEqual(T.__name__, 'T') + self.assertEqual(A.__bases__, (Generic, dict)) + self.assertEqual(A.__orig_bases__, (TypedDict, Generic[T])) + self.assertEqual(A.__mro__, (A, Generic, dict, object)) + self.assertEqual(A.__parameters__, (T,)) + self.assertEqual(A[str].__parameters__, ()) + self.assertEqual(A[str].__args__, (str,)) + def test_generic_inheritance(self): class A(TypedDict, Generic[T]): a: T @@ -3331,11 +3513,11 @@ class Point3D(Point2DGeneric[T], Generic[T, KT]): self.assertEqual(Point3D.__total__, True) self.assertEqual(Point3D.__optional_keys__, frozenset()) self.assertEqual(Point3D.__required_keys__, frozenset(['a', 'b', 'c'])) - assert Point3D.__annotations__ == { + self.assertEqual(Point3D.__annotations__, { 'a': T, 'b': T, 'c': KT, - } + }) self.assertEqual(Point3D[int, str].__origin__, Point3D) with self.assertRaises(TypeError): @@ -3370,6 +3552,64 @@ class WithImplicitAny(B): with self.assertRaises(TypeError): WithImplicitAny[str] + @skipUnless(TYPING_3_9_0, "Was changed in 3.9") + def test_non_generic_subscript(self): + # For backward compatibility, subscription works + # on arbitrary TypedDict types. + # (But we don't attempt to backport this misfeature onto 3.7 and 3.8.) + class TD(TypedDict): + a: T + A = TD[int] + self.assertEqual(A.__origin__, TD) + self.assertEqual(A.__parameters__, ()) + self.assertEqual(A.__args__, (int,)) + a = A(a=1) + self.assertIs(type(a), dict) + self.assertEqual(a, {'a': 1}) + + def test_orig_bases(self): + T = TypeVar('T') + + class Parent(TypedDict): + pass + + class Child(Parent): + pass + + class OtherChild(Parent): + pass + + class MixedChild(Child, OtherChild, Parent): + pass + + class GenericParent(TypedDict, Generic[T]): + pass + + class GenericChild(GenericParent[int]): + pass + + class OtherGenericChild(GenericParent[str]): + pass + + class MixedGenericChild(GenericChild, OtherGenericChild, GenericParent[float]): + pass + + class MultipleGenericBases(GenericParent[int], GenericParent[float]): + pass + + CallTypedDict = TypedDict('CallTypedDict', {}) + + self.assertEqual(Parent.__orig_bases__, (TypedDict,)) + self.assertEqual(Child.__orig_bases__, (Parent,)) + self.assertEqual(OtherChild.__orig_bases__, (Parent,)) + self.assertEqual(MixedChild.__orig_bases__, (Child, OtherChild, Parent,)) + self.assertEqual(GenericParent.__orig_bases__, (TypedDict, Generic[T])) + self.assertEqual(GenericChild.__orig_bases__, (GenericParent[int],)) + self.assertEqual(OtherGenericChild.__orig_bases__, (GenericParent[str],)) + self.assertEqual(MixedGenericChild.__orig_bases__, (GenericChild, OtherGenericChild, GenericParent[float])) + self.assertEqual(MultipleGenericBases.__orig_bases__, (GenericParent[int], GenericParent[float])) + self.assertEqual(CallTypedDict.__orig_bases__, (TypedDict,)) + class AnnotatedTests(BaseTestCase): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 9e33402d..e8ce9ee1 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -918,113 +918,56 @@ def __round__(self, ndigits: int = 0) -> T_co: _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict else: - def _check_fails(cls, other): - try: - if _caller() not in {'abc', 'functools', 'typing'}: - # Typed dicts are only for static structural subtyping. - raise TypeError('TypedDict does not support instance and class checks') - except (AttributeError, ValueError): - pass - return False - - def _dict_new(*args, **kwargs): - if not args: - raise TypeError('TypedDict.__new__(): not enough arguments') - _, args = args[0], args[1:] # allow the "cls" keyword be passed - return dict(*args, **kwargs) - - _dict_new.__text_signature__ = '($cls, _typename, _fields=None, /, **kwargs)' - - def _typeddict_new(*args, total=True, **kwargs): - if not args: - raise TypeError('TypedDict.__new__(): not enough arguments') - _, args = args[0], args[1:] # allow the "cls" keyword be passed - if args: - typename, args = args[0], args[1:] # allow the "_typename" keyword be passed - elif '_typename' in kwargs: - typename = kwargs.pop('_typename') - warnings.warn("Passing '_typename' as keyword argument is deprecated", - DeprecationWarning, stacklevel=2) - else: - raise TypeError("TypedDict.__new__() missing 1 required positional " - "argument: '_typename'") - if args: - try: - fields, = args # allow the "_fields" keyword be passed - except ValueError: - raise TypeError('TypedDict.__new__() takes from 2 to 3 ' - f'positional arguments but {len(args) + 2} ' - 'were given') - elif '_fields' in kwargs and len(kwargs) == 1: - fields = kwargs.pop('_fields') - warnings.warn("Passing '_fields' as keyword argument is deprecated", - DeprecationWarning, stacklevel=2) - else: - fields = None - - if fields is None: - fields = kwargs - elif kwargs: - raise TypeError("TypedDict takes either a dict or keyword arguments," - " but not both") - - if kwargs: - warnings.warn( - "The kwargs-based syntax for TypedDict definitions is deprecated, " - "may be removed in a future version, and may not be " - "understood by third-party type checkers.", - DeprecationWarning, - stacklevel=2, - ) + # 3.10.0 and later + _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters - ns = {'__annotations__': dict(fields)} - module = _caller() - if module is not None: - # Setting correct module is necessary to make typed dict classes pickleable. - ns['__module__'] = module + if sys.version_info >= (3, 8): + _fake_name = "Protocol" + else: + _fake_name = "_Protocol" - return _TypedDictMeta(typename, (), ns, total=total) + class _TypedDictMeta(type): + def __new__(cls, name, bases, ns, total=True): + """Create new typed dict class object. - _typeddict_new.__text_signature__ = ('($cls, _typename, _fields=None,' - ' /, *, total=True, **kwargs)') + This method is called when TypedDict is subclassed, + or when TypedDict is instantiated. This way + TypedDict supports all three syntax forms described in its docstring. + Subclasses and instances of TypedDict return actual dictionaries. + """ + for base in bases: + if type(base) is not _TypedDictMeta and base is not typing.Generic: + raise TypeError('cannot inherit from both a TypedDict type ' + 'and a non-TypedDict base class') - _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters + if any(issubclass(b, typing.Generic) for b in bases): + generic_base = (typing.Generic,) + else: + generic_base = () - class _TypedDictMeta(type): - def __init__(cls, name, bases, ns, total=True): - super().__init__(name, bases, ns) + # typing.py generally doesn't let you inherit from plain Generic, unless + # the name of the class happens to be "Protocol" (or "_Protocol" on 3.7). + tp_dict = type.__new__(_TypedDictMeta, _fake_name, (*generic_base, dict), ns) + tp_dict.__name__ = name + if tp_dict.__qualname__ == _fake_name: + tp_dict.__qualname__ = name - def __new__(cls, name, bases, ns, total=True): - # Create new typed dict class object. - # This method is called directly when TypedDict is subclassed, - # or via _typeddict_new when TypedDict is instantiated. This way - # TypedDict supports all three syntaxes described in its docstring. - # Subclasses and instances of TypedDict return actual dictionaries - # via _dict_new. - ns['__new__'] = _typeddict_new if name == 'TypedDict' else _dict_new - # Don't insert typing.Generic into __bases__ here, - # or Generic.__init_subclass__ will raise TypeError - # in the super().__new__() call. - # Instead, monkey-patch __bases__ onto the class after it's been created. - tp_dict = super().__new__(cls, name, (dict,), ns) - - is_generic = any(issubclass(base, typing.Generic) for base in bases) - - if is_generic: - tp_dict.__bases__ = (typing.Generic, dict) - _maybe_adjust_parameters(tp_dict) - else: - # generic TypedDicts get __orig_bases__ from Generic - tp_dict.__orig_bases__ = bases or (TypedDict,) + if not hasattr(tp_dict, '__orig_bases__'): + tp_dict.__orig_bases__ = bases annotations = {} own_annotations = ns.get('__annotations__', {}) msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" - kwds = {"module": tp_dict.__module__} if _TAKES_MODULE else {} - own_annotations = { - n: typing._type_check(tp, msg, **kwds) - for n, tp in own_annotations.items() - } + if _TAKES_MODULE: + own_annotations = { + n: typing._type_check(tp, msg, module=tp_dict.__module__) + for n, tp in own_annotations.items() + } + else: + own_annotations = { + n: typing._type_check(tp, msg) + for n, tp in own_annotations.items() + } required_keys = set() optional_keys = set() @@ -1058,15 +1001,19 @@ def __new__(cls, name, bases, ns, total=True): tp_dict.__total__ = total return tp_dict - __instancecheck__ = __subclasscheck__ = _check_fails + __call__ = dict # static method - TypedDict = _TypedDictMeta('TypedDict', (dict,), {}) - TypedDict.__module__ = __name__ - TypedDict.__doc__ = \ - """A simple typed name space. At runtime it is equivalent to a plain dict. + def __subclasscheck__(cls, other): + # Typed dicts are only for static structural subtyping. + raise TypeError('TypedDict does not support instance and class checks') + + __instancecheck__ = __subclasscheck__ + + def TypedDict(__typename, __fields=None, *, total=True, **kwargs): + """A simple typed namespace. At runtime it is equivalent to a plain dict. TypedDict creates a dictionary type that expects all of its - instances to have a certain set of keys, with each key + instances to have a certain set of keys, where each key is associated with a value of a consistent type. This expectation is not checked at runtime but is only enforced by type checkers. Usage:: @@ -1083,14 +1030,49 @@ class Point2D(TypedDict): The type info can be accessed via the Point2D.__annotations__ dict, and the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets. - TypedDict supports two additional equivalent forms:: + TypedDict supports an additional equivalent form:: - Point2D = TypedDict('Point2D', x=int, y=int, label=str) Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str}) - The class syntax is only supported in Python 3.6+, while two other - syntax forms work for Python 2.7 and 3.2+ + By default, all keys must be present in a TypedDict. It is possible + to override this by specifying totality. + Usage:: + + class point2D(TypedDict, total=False): + x: int + y: int + + This means that a point2D TypedDict can have any of the keys omitted. A type + checker is only expected to support a literal False or True as the value of + the total argument. True is the default, and makes all items defined in the + class body be required. """ + if __fields is None: + __fields = kwargs + elif kwargs: + raise TypeError("TypedDict takes either a dict or keyword arguments," + " but not both") + if kwargs: + 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 " + "understood by third-party type checkers.", + DeprecationWarning, + stacklevel=2, + ) + + ns = {'__annotations__': dict(__fields)} + module = _caller() + if module is not None: + # Setting correct module is necessary to make typed dict classes pickleable. + ns['__module__'] = module + + td = _TypedDictMeta(__typename, (), ns, total=total) + td.__orig_bases__ = (TypedDict,) + return td + + _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) + TypedDict.__mro_entries__ = lambda bases: (_TypedDict,) if hasattr(typing, "_TypedDictMeta"): _TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta) @@ -1108,7 +1090,10 @@ class Film(TypedDict): is_typeddict(Film) # => True is_typeddict(Union[list, str]) # => False """ - return isinstance(tp, tuple(_TYPEDDICT_TYPES)) + # On 3.8, this would otherwise return True + if hasattr(typing, "TypedDict") and tp is typing.TypedDict: + return False + return isinstance(tp, _TYPEDDICT_TYPES) if hasattr(typing, "assert_type"): From a2c6c887775adf1bda33fdc91722e6937bac7415 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 9 Jun 2023 15:39:09 +0100 Subject: [PATCH 21/37] Don't reimplement `runtime_checkable` on py38+ (#194) --- CHANGELOG.md | 4 ++++ src/test_typing_extensions.py | 18 +++++++++++++++++- src/typing_extensions.py | 5 ++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2bd903..5526ef9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Unreleased +- Due to changes in the implementation of `typing_extensions.Protocol`, + `typing.runtime_checkable` can now be used on `typing_extensions.Protocol` + (previously, users had to use `typing_extensions.runtime_checkable` if they + were using `typing_extensions.Protocol`). - Align the implementation of `TypedDict` with the implementation in the standard library on Python 3.9 and higher. `typing_extensions.TypedDict` is now a function instead of a class. The diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 129cdcaa..71b70b72 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1783,6 +1783,22 @@ def __init__(self): self.assertIsInstance(Bar(), Foo) self.assertNotIsInstance(object(), Foo) + @skipUnless( + hasattr(typing, "runtime_checkable"), + "Test is only relevant if typing.runtime_checkable exists" + ) + def test_typing_dot_runtimecheckable_on_Protocol(self): + @typing.runtime_checkable + class Foo(Protocol): + x: int + + class Bar: + def __init__(self): + self.x = 42 + + self.assertIsInstance(Bar(), Foo) + self.assertNotIsInstance(object(), Foo) + def test_no_instantiation(self): class P(Protocol): pass with self.assertRaises(TypeError): @@ -4743,7 +4759,7 @@ def test_typing_extensions_defers_when_possible(self): exclude |= {'final', 'Any', 'NewType'} if sys.version_info < (3, 12): exclude |= { - 'Protocol', 'runtime_checkable', 'SupportsAbs', 'SupportsBytes', + 'Protocol', 'SupportsAbs', 'SupportsBytes', 'SupportsComplex', 'SupportsFloat', 'SupportsIndex', 'SupportsInt', 'SupportsRound', 'TypedDict', 'is_typeddict', 'NamedTuple', 'Unpack', } diff --git a/src/typing_extensions.py b/src/typing_extensions.py index e8ce9ee1..21fa2829 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -545,7 +545,6 @@ def _caller(depth=2): # so we backport the 3.12 version of Protocol to Python <=3.11 if sys.version_info >= (3, 12): Protocol = typing.Protocol - runtime_checkable = typing.runtime_checkable else: def _allow_reckless_class_checks(depth=3): """Allow instance and class checks for special stdlib modules. @@ -805,6 +804,10 @@ def __init_subclass__(cls, *args, **kwargs): if cls.__init__ is Protocol.__init__: cls.__init__ = _no_init + +if sys.version_info >= (3, 8): + runtime_checkable = typing.runtime_checkable +else: def runtime_checkable(cls): """Mark a protocol class as a runtime protocol, so that it can be used with isinstance() and issubclass(). Raise TypeError From 520dcd1e2ac32d332d08345d5459ca3bde76a98b Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 9 Jun 2023 07:46:21 -0700 Subject: [PATCH 22/37] Re-export all names from typing (#196) * Re-export all names from typing Fixes #50. I decided to exclude top-level names that have a definite removal timeline (io, re, ByteString); there's no point in re-exporting them now. Many other names are deprecated without a definite removal plan (PEP 585). If they do get removed eventually, typing-extensions can simply re-export the builtin/collections.abc names. In the documentation, I add a separate section listing all the names that are pure aliases for typing. This makes it easier to distinguish the cases where there is something more interesting going on in typing-extensions. Co-authored-by: Alex Waygood Co-authored-by: Shantanu <12621235+hauntsaninja@users.noreply.github.com> --- CHANGELOG.md | 3 + doc/index.rst | 349 ++++++++++++++++++++++++++++------ src/test_typing_extensions.py | 14 ++ src/typing_extensions.py | 82 ++++++++ 4 files changed, 390 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5526ef9f..2df212bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- `typing_extensions` now re-exports all names in the standard library's + `typing` module, except the deprecated `ByteString`. Patch by Jelle + Zijlstra. - Due to changes in the implementation of `typing_extensions.Protocol`, `typing.runtime_checkable` can now be used on `typing_extensions.Protocol` (previously, users had to use `typing_extensions.runtime_checkable` if they diff --git a/doc/index.rst b/doc/index.rst index 60497375..8d1f9a80 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -22,6 +22,14 @@ figured out how to deal with that possibility. Bugfixes and new typing features that don't require a PEP may be added to ``typing_extensions`` once they are merged into CPython's main branch. +``typing_extensions`` also re-exports all names from the :py:mod:`typing` module, +including those that have always been present in the module. This allows users to +import names from ``typing_extensions`` without having to remember exactly when +each object was added to :py:mod:`typing`. There are a few exceptions: +:py:class:`typing.ByteString`, which is deprecated and due to be removed in Python +3.14, is not re-exported. Similarly, the ``typing.io`` and ``typing.re`` submodules, +which are removed in Python 3.13, are excluded. + Versioning and backwards compatibility -------------------------------------- @@ -161,10 +169,6 @@ Special typing primitives Added to support inheritance from ``Any``. -.. data:: ClassVar - - See :py:data:`typing.ClassVar` and :pep:`526`. In ``typing`` since 3.5.3. - .. data:: Concatenate See :py:data:`typing.Concatenate` and :pep:`612`. In ``typing`` since 3.10. @@ -229,10 +233,6 @@ Special typing primitives The improvements from Python 3.10 and 3.11 were backported. -.. data:: NoReturn - - See :py:data:`typing.NoReturn`. In ``typing`` since 3.5.4 and 3.6.2. - .. data:: NotRequired See :py:data:`typing.NotRequired` and :pep:`655`. In ``typing`` since 3.11. @@ -294,10 +294,6 @@ Special typing primitives .. versionadded:: 4.0.0 -.. class:: Type - - See :py:class:`typing.Type`. In ``typing`` since 3.5.2. - .. data:: TypeAlias See :py:data:`typing.TypeAlias` and :pep:`613`. In ``typing`` since 3.10. @@ -408,22 +404,6 @@ Special typing primitives Generic concrete collections ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. class:: ChainMap - - See :py:class:`typing.ChainMap`. In ``typing`` since 3.5.4 and 3.6.1. - -.. class:: Counter - - See :py:class:`typing.Counter`. In ``typing`` since 3.5.4 and 3.6.1. - -.. class:: DefaultDict - - See :py:class:`typing.DefaultDict`. In ``typing`` since 3.5.2. - -.. class:: Deque - - See :py:class:`typing.Deque`. In ``typing`` since 3.5.4 and 3.6.1. - .. class:: OrderedDict See :py:class:`typing.OrderedDict`. In ``typing`` since 3.7.2. @@ -431,26 +411,6 @@ Generic concrete collections Abstract Base Classes ~~~~~~~~~~~~~~~~~~~~~ -.. class:: AsyncContextManager - - See :py:class:`typing.AsyncContextManager`. In ``typing`` since 3.5.4 and 3.6.2. - -.. class:: AsyncGenerator - - See :py:class:`typing.AsyncGenerator`. In ``typing`` since 3.6.1. - -.. class:: AsyncIterable - - See :py:class:`typing.AsyncIterable`. In ``typing`` since 3.5.2. - -.. class:: AsyncIterator - - See :py:class:`typing.AsyncIterator`. In ``typing`` since 3.5.2. - -.. class:: Awaitable - - See :py:class:`typing.Awaitable`. In ``typing`` since 3.5.2. - .. class:: Buffer See :py:class:`collections.abc.Buffer`. Added to the standard library @@ -458,14 +418,6 @@ Abstract Base Classes .. versionadded:: 4.6.0 -.. class:: ContextManager - - See :py:class:`typing.ContextManager`. In ``typing`` since 3.5.4. - -.. class:: Coroutine - - See :py:class:`typing.Coroutine`. In ``typing`` since 3.5.3. - Protocols ~~~~~~~~~ @@ -698,13 +650,294 @@ Functions .. versionadded:: 4.1.0 -Other -~~~~~ +Pure aliases +~~~~~~~~~~~~ + +These are simply re-exported from the :mod:`typing` module on all supported +versions of Python. They are listed here for completeness. + +.. class:: AbstractSet + + See :py:class:`typing.AbstractSet`. + + .. versionadded:: 4.7.0 + +.. data:: AnyStr + + See :py:data:`typing.AnyStr`. + + .. versionadded:: 4.7.0 + +.. class:: AsyncContextManager + + See :py:class:`typing.AsyncContextManager`. In ``typing`` since 3.5.4 and 3.6.2. + +.. class:: AsyncGenerator + + See :py:class:`typing.AsyncGenerator`. In ``typing`` since 3.6.1. + +.. class:: AsyncIterable + + See :py:class:`typing.AsyncIterable`. In ``typing`` since 3.5.2. + +.. class:: AsyncIterator + + See :py:class:`typing.AsyncIterator`. In ``typing`` since 3.5.2. + +.. class:: Awaitable + + See :py:class:`typing.Awaitable`. In ``typing`` since 3.5.2. + +.. class:: BinaryIO + + See :py:class:`typing.BinaryIO`. + + .. versionadded:: 4.7.0 + +.. data:: Callable + + See :py:data:`typing.Callable`. + + .. versionadded:: 4.7.0 + +.. class:: ChainMap + + See :py:class:`typing.ChainMap`. In ``typing`` since 3.5.4 and 3.6.1. + +.. data:: ClassVar + + See :py:data:`typing.ClassVar` and :pep:`526`. In ``typing`` since 3.5.3. + +.. class:: Collection + + See :py:class:`typing.Collection`. + + .. versionadded:: 4.7.0 + +.. class:: Container + + See :py:class:`typing.Container`. + + .. versionadded:: 4.7.0 + +.. class:: ContextManager + + See :py:class:`typing.ContextManager`. In ``typing`` since 3.5.4. + +.. class:: Coroutine + + See :py:class:`typing.Coroutine`. In ``typing`` since 3.5.3. + +.. class:: Counter + + See :py:class:`typing.Counter`. In ``typing`` since 3.5.4 and 3.6.1. + +.. class:: DefaultDict + + See :py:class:`typing.DefaultDict`. In ``typing`` since 3.5.2. + +.. class:: Deque + + See :py:class:`typing.Deque`. In ``typing`` since 3.5.4 and 3.6.1. + +.. class:: Dict + + See :py:class:`typing.Dict`. + + .. versionadded:: 4.7.0 + +.. class:: ForwardRef + + See :py:class:`typing.ForwardRef`. + + .. versionadded:: 4.7.0 + +.. class:: FrozenSet + + See :py:class:`typing.FrozenSet`. + + .. versionadded:: 4.7.0 + +.. class:: Generator + + See :py:class:`typing.Generator`. + + .. versionadded:: 4.7.0 + +.. class:: Generic + + See :py:class:`typing.Generic`. + + .. versionadded:: 4.7.0 + +.. class:: Hashable + + See :py:class:`typing.Hashable`. + + .. versionadded:: 4.7.0 + +.. class:: IO + + See :py:class:`typing.IO`. + + .. versionadded:: 4.7.0 + +.. class:: ItemsView + + See :py:class:`typing.ItemsView`. + + .. versionadded:: 4.7.0 + +.. class:: Iterable + + See :py:class:`typing.Iterable`. + + .. versionadded:: 4.7.0 + +.. class:: Iterator + + See :py:class:`typing.Iterator`. + + .. versionadded:: 4.7.0 + +.. class:: KeysView + + See :py:class:`typing.KeysView`. + + .. versionadded:: 4.7.0 + +.. class:: List + + See :py:class:`typing.List`. + + .. versionadded:: 4.7.0 + +.. class:: Mapping + + See :py:class:`typing.Mapping`. + + .. versionadded:: 4.7.0 + +.. class:: MappingView + + See :py:class:`typing.MappingView`. + + .. versionadded:: 4.7.0 + +.. class:: Match + + See :py:class:`typing.Match`. + + .. versionadded:: 4.7.0 + +.. class:: MutableMapping + + See :py:class:`typing.MutableMapping`. + + .. versionadded:: 4.7.0 + +.. class:: MutableSequence + + See :py:class:`typing.MutableSequence`. + + .. versionadded:: 4.7.0 + +.. class:: MutableSet + + See :py:class:`typing.MutableSet`. + + .. versionadded:: 4.7.0 + +.. data:: NoReturn + + See :py:data:`typing.NoReturn`. In ``typing`` since 3.5.4 and 3.6.2. + +.. data:: Optional + + See :py:data:`typing.Optional`. + + .. versionadded:: 4.7.0 + +.. class:: Pattern + + See :py:class:`typing.Pattern`. + + .. versionadded:: 4.7.0 + +.. class:: Reversible + + See :py:class:`typing.Reversible`. + + .. versionadded:: 4.7.0 + +.. class:: Sequence + + See :py:class:`typing.Sequence`. + + .. versionadded:: 4.7.0 + +.. class:: Set + + See :py:class:`typing.Set`. + + .. versionadded:: 4.7.0 + +.. class:: Sized + + See :py:class:`typing.Sized`. + + .. versionadded:: 4.7.0 .. class:: Text See :py:class:`typing.Text`. In ``typing`` since 3.5.2. +.. class:: TextIO + + See :py:class:`typing.TextIO`. + + .. versionadded:: 4.7.0 + +.. data:: Tuple + + See :py:data:`typing.Tuple`. + + .. versionadded:: 4.7.0 + +.. class:: Type + + See :py:class:`typing.Type`. In ``typing`` since 3.5.2. + .. data:: TYPE_CHECKING See :py:data:`typing.TYPE_CHECKING`. In ``typing`` since 3.5.2. + +.. data:: Union + + See :py:data:`typing.Union`. + + .. versionadded:: 4.7.0 + +.. class:: ValuesView + + See :py:class:`typing.ValuesView`. + + .. versionadded:: 4.7.0 + +.. function:: cast + + See :py:func:`typing.cast`. + + .. versionadded:: 4.7.0 + +.. decorator:: no_type_check + + See :py:func:`typing.no_type_check`. + + .. versionadded:: 4.7.0 + +.. decorator:: no_type_check_decorator + + See :py:func:`typing.no_type_check_decorator`. + + .. versionadded:: 4.7.0 diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 71b70b72..102f0c1b 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -4683,6 +4683,20 @@ class CustomerModel(ModelBase, init=False): class AllTests(BaseTestCase): + def test_drop_in_for_typing(self): + # Check that the typing_extensions.__all__ is a superset of + # typing.__all__. + t_all = set(typing.__all__) + te_all = set(typing_extensions.__all__) + exceptions = {"ByteString"} + self.assertGreaterEqual(te_all, t_all - exceptions) + # Deprecated, to be removed in 3.14 + self.assertFalse(hasattr(typing_extensions, "ByteString")) + # These were never included in `typing.__all__`, + # and have been removed in Python 3.13 + self.assertNotIn('re', te_all) + self.assertNotIn('io', te_all) + def test_typing_extensions_includes_standard(self): a = typing_extensions.__all__ self.assertIn('ClassVar', a) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 21fa2829..449ea5ec 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -85,6 +85,45 @@ 'NoReturn', 'Required', 'NotRequired', + + # Pure aliases, have always been in typing + 'AbstractSet', + 'AnyStr', + 'BinaryIO', + 'Callable', + 'Collection', + 'Container', + 'Dict', + 'ForwardRef', + 'FrozenSet', + 'Generator', + 'Generic', + 'Hashable', + 'IO', + 'ItemsView', + 'Iterable', + 'Iterator', + 'KeysView', + 'List', + 'Mapping', + 'MappingView', + 'Match', + 'MutableMapping', + 'MutableSequence', + 'MutableSet', + 'Optional', + 'Pattern', + 'Reversible', + 'Sequence', + 'Set', + 'Sized', + 'TextIO', + 'Tuple', + 'Union', + 'ValuesView', + 'cast', + 'no_type_check', + 'no_type_check_decorator', ] # for backward compatibility @@ -2837,3 +2876,46 @@ def __ror__(self, left): if not _is_unionable(left): return NotImplemented return typing.Union[left, self] + + +# Aliases for items that have always been in typing. +# Explicitly assign these (rather than using `from typing import *` at the top), +# so that we get a CI error if one of these is deleted from typing.py +# in a future version of Python +AbstractSet = typing.AbstractSet +AnyStr = typing.AnyStr +BinaryIO = typing.BinaryIO +Callable = typing.Callable +Collection = typing.Collection +Container = typing.Container +Dict = typing.Dict +ForwardRef = typing.ForwardRef +FrozenSet = typing.FrozenSet +Generator = typing.Generator +Generic = typing.Generic +Hashable = typing.Hashable +IO = typing.IO +ItemsView = typing.ItemsView +Iterable = typing.Iterable +Iterator = typing.Iterator +KeysView = typing.KeysView +List = typing.List +Mapping = typing.Mapping +MappingView = typing.MappingView +Match = typing.Match +MutableMapping = typing.MutableMapping +MutableSequence = typing.MutableSequence +MutableSet = typing.MutableSet +Optional = typing.Optional +Pattern = typing.Pattern +Reversible = typing.Reversible +Sequence = typing.Sequence +Set = typing.Set +Sized = typing.Sized +TextIO = typing.TextIO +Tuple = typing.Tuple +Union = typing.Union +ValuesView = typing.ValuesView +cast = typing.cast +no_type_check = typing.no_type_check +no_type_check_decorator = typing.no_type_check_decorator From f78d8a102eb61ffd996da0688dab81e91aaaa0aa Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sun, 11 Jun 2023 11:16:29 +0100 Subject: [PATCH 23/37] Skip cattrs tests on 3.8 (#231) Temporary workaround for the the failures in #230. --- .github/workflows/third_party.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index a25f5d66..fe1be655 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -298,7 +298,8 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + # TODO: Add 3.8 back to this matrix (see #230) + python-version: ["3.7", "3.9", "3.10", "3.11"] runs-on: ubuntu-latest timeout-minutes: 60 steps: From a12832998e87c49ec083710f0e776d35b0852451 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 12 Jun 2023 01:56:35 +0100 Subject: [PATCH 24/37] Revert "Skip cattrs tests on 3.8" (#234) Revert "Skip cattrs tests on 3.8 (#231)" This reverts commit f78d8a102eb61ffd996da0688dab81e91aaaa0aa. --- .github/workflows/third_party.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index fe1be655..a25f5d66 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -298,8 +298,7 @@ jobs: strategy: fail-fast: false matrix: - # TODO: Add 3.8 back to this matrix (see #230) - python-version: ["3.7", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] runs-on: ubuntu-latest timeout-minutes: 60 steps: From 890a03eaa1ffcdf6c094b991e404badb9caf35bc Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Mon, 12 Jun 2023 17:43:53 +0100 Subject: [PATCH 25/37] Unskip typeguard tests in the third-party workflow (#235) --- .github/workflows/third_party.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/third_party.yml b/.github/workflows/third_party.yml index a25f5d66..34256fa1 100644 --- a/.github/workflows/third_party.yml +++ b/.github/workflows/third_party.yml @@ -154,7 +154,16 @@ jobs: typeguard: name: typeguard tests - if: false # TODO: unskip when typeguard's tests pass on typing_extensions>=4.6.0 + if: >- + # if 'schedule' was the trigger, + # don't run it on contributors' forks + ${{ + github.event_name != 'schedule' + || ( + github.repository == 'python/typing_extensions' + && github.event_name == 'schedule' + ) + }} strategy: fail-fast: false matrix: From f9b83a235d28bf83f484520eb499e596e73fe45a Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Wed, 14 Jun 2023 13:36:16 +0100 Subject: [PATCH 26/37] Allow typing_extensions.Protocol and typing.Protocol to mix (#237) Fixes #236 Co-authored-by: Jelle Zijlstra --- CHANGELOG.md | 3 +++ doc/index.rst | 6 +++++ src/test_typing_extensions.py | 28 ++++++++++++++++++++ src/typing_extensions.py | 48 ++++++++++++++++++++++++++--------- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df212bb..191a583f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ - Allow `Protocol` classes to inherit from `typing_extensions.Buffer` or `collections.abc.Buffer`. Patch by Alex Waygood (backporting https://github.com/python/cpython/pull/104827, by Jelle Zijlstra). +- Allow classes to inherit from both `typing.Protocol` and `typing_extensions.Protocol` + simultaneously. Since v4.6.0, this caused `TypeError` to be raised due to a + metaclass conflict. Patch by Alex Waygood. # Release 4.6.3 (June 1, 2023) diff --git a/doc/index.rst b/doc/index.rst index 8d1f9a80..82109c6a 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -282,6 +282,12 @@ Special typing primitives Backported changes to runtime-checkable protocols from Python 3.12, including :pr-cpy:`103034` and :pr-cpy:`26067`. + .. versionchanged:: 4.7.0 + + Classes can now inherit from both :py:class:`typing.Protocol` and + ``typing_extensions.Protocol`` simultaneously. Previously, this led to + :py:exc:`TypeError` being raised due to a metaclass conflict. + .. data:: Required See :py:data:`typing.Required` and :pep:`655`. In ``typing`` since 3.11. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 102f0c1b..bd1aa0fd 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1799,6 +1799,34 @@ def __init__(self): self.assertIsInstance(Bar(), Foo) self.assertNotIsInstance(object(), Foo) + @skipUnless( + hasattr(typing, "Protocol"), + "Test is only relevant if typing.Protocol exists" + ) + def test_typing_Protocol_and_extensions_Protocol_can_mix(self): + class TypingProto(typing.Protocol): + x: int + + class ExtensionsProto(Protocol): + y: int + + class SubProto(TypingProto, ExtensionsProto, typing.Protocol): + z: int + + class SubProto2(TypingProto, ExtensionsProto, Protocol): + z: int + + class SubProto3(ExtensionsProto, TypingProto, typing.Protocol): + z: int + + class SubProto4(ExtensionsProto, TypingProto, Protocol): + z: int + + class Concrete(SubProto): pass + class Concrete2(SubProto2): pass + class Concrete3(SubProto3): pass + class Concrete4(SubProto4): pass + def test_no_instantiation(self): class P(Protocol): pass with self.assertRaises(TypeError): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 449ea5ec..e6c1ca80 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -596,30 +596,54 @@ def _no_init(self, *args, **kwargs): if type(self)._is_protocol: raise TypeError('Protocols cannot be instantiated') - class _ProtocolMeta(abc.ABCMeta): + if sys.version_info >= (3, 8): + # 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" + _typing_Protocol = typing.Protocol + _ProtocolMetaBase = type(_typing_Protocol) + + def _is_protocol(cls): + return ( + isinstance(cls, type) + and issubclass(cls, typing.Generic) + and getattr(cls, "_is_protocol", False) + ) + else: + _typing_Protocol = _marker + _ProtocolMetaBase = abc.ABCMeta + + def _is_protocol(cls): + return ( + isinstance(cls, _ProtocolMeta) + and getattr(cls, "_is_protocol", False) + ) + + class _ProtocolMeta(_ProtocolMetaBase): # This metaclass is somewhat unfortunate, # but is necessary for several reasons... + # + # NOTE: DO NOT call super() in any methods in this class + # That would call the methods on typing._ProtocolMeta on Python 3.8-3.11 + # and those are slow def __new__(mcls, name, bases, namespace, **kwargs): if name == "Protocol" and len(bases) < 2: pass - elif Protocol in bases: + elif {Protocol, _typing_Protocol} & set(bases): for base in bases: if not ( base in {object, typing.Generic} or base.__name__ in _PROTO_ALLOWLIST.get(base.__module__, []) - or ( - isinstance(base, _ProtocolMeta) - and getattr(base, "_is_protocol", False) - ) + or _is_protocol(base) ): raise TypeError( f"Protocols can only inherit from other protocols, " f"got {base!r}" ) - return super().__new__(mcls, name, bases, namespace, **kwargs) + return abc.ABCMeta.__new__(mcls, name, bases, namespace, **kwargs) def __init__(cls, *args, **kwargs): - super().__init__(*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() @@ -647,7 +671,7 @@ def __subclasscheck__(cls, other): "Instance and class checks can only be used with " "@runtime_checkable protocols" ) - return super().__subclasscheck__(other) + return abc.ABCMeta.__subclasscheck__(cls, other) def __instancecheck__(cls, instance): # We need this method for situations where attributes are @@ -656,7 +680,7 @@ def __instancecheck__(cls, instance): return type.__instancecheck__(cls, instance) if not getattr(cls, "_is_protocol", False): # i.e., it's a concrete subclass of a protocol - return super().__instancecheck__(instance) + return abc.ABCMeta.__instancecheck__(cls, instance) if ( not getattr(cls, '_is_runtime_protocol', False) and @@ -665,7 +689,7 @@ def __instancecheck__(cls, instance): raise TypeError("Instance and class checks can only be used with" " @runtime_checkable protocols") - if super().__instancecheck__(instance): + if abc.ABCMeta.__instancecheck__(cls, instance): return True for attr in cls.__protocol_attrs__: @@ -684,7 +708,7 @@ def __eq__(cls, other): # Hack so that typing.Generic.__class_getitem__ # treats typing_extensions.Protocol # as equivalent to typing.Protocol on Python 3.8+ - if super().__eq__(other) is True: + if abc.ABCMeta.__eq__(cls, other) is True: return True return ( cls is Protocol and other is getattr(typing, "Protocol", object()) From 38bb6e867be1331d708cddc0dbc9cef756f23224 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 16 Jun 2023 08:02:37 -0700 Subject: [PATCH 27/37] Add get_protocol_members and is_protocol (#238) Co-authored-by: Alex Waygood --- CHANGELOG.md | 5 +- doc/index.rst | 34 +++++++++ src/test_typing_extensions.py | 126 ++++++++++++++++++++++++++++++---- src/typing_extensions.py | 47 +++++++++++++ 4 files changed, 198 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 191a583f..38d2e258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- Add `typing_extensions.get_protocol_members` and + `typing_extensions.is_protocol` (backport of CPython PR #104878). + Patch by Jelle Zijlstra. - `typing_extensions` now re-exports all names in the standard library's `typing` module, except the deprecated `ByteString`. Patch by Jelle Zijlstra. @@ -17,7 +20,7 @@ - Fix tests on Python 3.13, which removes support for creating `TypedDict` classes through the keyword-argument syntax. Patch by Jelle Zijlstra. -- Fix a regression introduced in v4.6.3 that meant that +- Fix a regression introduced in v4.6.3 that meant that ``issubclass(object, typing_extensions.Protocol)`` would erroneously raise ``TypeError``. Patch by Alex Waygood (backporting the CPython PR https://github.com/python/cpython/pull/105239). diff --git a/doc/index.rst b/doc/index.rst index 82109c6a..9699de57 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -622,6 +622,24 @@ Functions .. versionadded:: 4.2.0 +.. function:: get_protocol_members(tp) + + Return the set of members defined in a :class:`Protocol`. This works with protocols + defined using either :class:`typing.Protocol` or :class:`typing_extensions.Protocol`. + + :: + + >>> from typing_extensions import Protocol, get_protocol_members + >>> class P(Protocol): + ... def a(self) -> str: ... + ... b: int + >>> get_protocol_members(P) + frozenset({'a', 'b'}) + + Raise :py:exc:`TypeError` for arguments that are not Protocols. + + .. versionadded:: 4.7.0 + .. function:: get_type_hints(obj, globalns=None, localns=None, include_extras=False) See :py:func:`typing.get_type_hints`. @@ -634,6 +652,22 @@ Functions Interaction with :data:`Required` and :data:`NotRequired`. +.. function:: is_protocol(tp) + + Determine if a type is a :class:`Protocol`. This works with protocols + defined using either :py:class:`typing.Protocol` or :class:`typing_extensions.Protocol`. + + For example:: + + class P(Protocol): + def a(self) -> str: ... + b: int + + is_protocol(P) # => True + is_protocol(int) # => False + + .. versionadded:: 4.7.0 + .. function:: is_typeddict(tp) See :py:func:`typing.is_typeddict`. In ``typing`` since 3.10. diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index bd1aa0fd..a5f47a2e 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -37,7 +37,7 @@ 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 override, deprecated, Buffer, TypeAliasType, TypeVar +from typing_extensions import override, deprecated, Buffer, TypeAliasType, TypeVar, get_protocol_members, is_protocol from _typed_dict_test_helper import Foo, FooGeneric, VeryAnnotated # Flags used to mark tests that only apply after a specific @@ -52,6 +52,10 @@ # 3.12 changes the representation of Unpack[] (PEP 692) TYPING_3_12_0 = sys.version_info[:3] >= (3, 12, 0) +only_with_typing_Protocol = skipUnless( + hasattr(typing, "Protocol"), "Only relevant when typing.Protocol exists" +) + # 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 @@ -1767,10 +1771,7 @@ class E(C, BP): pass self.assertNotIsInstance(D(), E) self.assertNotIsInstance(E(), D) - @skipUnless( - hasattr(typing, "Protocol"), - "Test is only relevant if typing.Protocol exists" - ) + @only_with_typing_Protocol def test_runtimecheckable_on_typing_dot_Protocol(self): @runtime_checkable class Foo(typing.Protocol): @@ -1783,10 +1784,7 @@ def __init__(self): self.assertIsInstance(Bar(), Foo) self.assertNotIsInstance(object(), Foo) - @skipUnless( - hasattr(typing, "runtime_checkable"), - "Test is only relevant if typing.runtime_checkable exists" - ) + @only_with_typing_Protocol def test_typing_dot_runtimecheckable_on_Protocol(self): @typing.runtime_checkable class Foo(Protocol): @@ -1799,10 +1797,7 @@ def __init__(self): self.assertIsInstance(Bar(), Foo) self.assertNotIsInstance(object(), Foo) - @skipUnless( - hasattr(typing, "Protocol"), - "Test is only relevant if typing.Protocol exists" - ) + @only_with_typing_Protocol def test_typing_Protocol_and_extensions_Protocol_can_mix(self): class TypingProto(typing.Protocol): x: int @@ -2992,6 +2987,111 @@ def __call__(self, *args: Unpack[Ts]) -> T: ... self.assertEqual(Y.__parameters__, ()) self.assertEqual(Y.__args__, (int, bytes, memoryview)) + def test_get_protocol_members(self): + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(object) + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(object()) + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(Protocol) + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(Generic) + + class P(Protocol): + a: int + def b(self) -> str: ... + @property + def c(self) -> int: ... + + self.assertEqual(get_protocol_members(P), {'a', 'b', 'c'}) + self.assertIsInstance(get_protocol_members(P), frozenset) + self.assertIsNot(get_protocol_members(P), P.__protocol_attrs__) + + class Concrete: + a: int + def b(self) -> str: return "capybara" + @property + def c(self) -> int: return 5 + + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(Concrete) + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(Concrete()) + + class ConcreteInherit(P): + a: int = 42 + def b(self) -> str: return "capybara" + @property + def c(self) -> int: return 5 + + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(ConcreteInherit) + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(ConcreteInherit()) + + @only_with_typing_Protocol + def test_get_protocol_members_typing(self): + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(typing.Protocol) + + class P(typing.Protocol): + a: int + def b(self) -> str: ... + @property + def c(self) -> int: ... + + self.assertEqual(get_protocol_members(P), {'a', 'b', 'c'}) + self.assertIsInstance(get_protocol_members(P), frozenset) + if hasattr(P, "__protocol_attrs__"): + self.assertIsNot(get_protocol_members(P), P.__protocol_attrs__) + + class Concrete: + a: int + def b(self) -> str: return "capybara" + @property + def c(self) -> int: return 5 + + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(Concrete) + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(Concrete()) + + class ConcreteInherit(P): + a: int = 42 + def b(self) -> str: return "capybara" + @property + def c(self) -> int: return 5 + + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(ConcreteInherit) + with self.assertRaisesRegex(TypeError, "not a Protocol"): + get_protocol_members(ConcreteInherit()) + + def test_is_protocol(self): + self.assertTrue(is_protocol(Proto)) + self.assertTrue(is_protocol(Point)) + self.assertFalse(is_protocol(Concrete)) + self.assertFalse(is_protocol(Concrete())) + self.assertFalse(is_protocol(Generic)) + self.assertFalse(is_protocol(object)) + + # Protocol is not itself a protocol + self.assertFalse(is_protocol(Protocol)) + + @only_with_typing_Protocol + def test_is_protocol_with_typing(self): + self.assertFalse(is_protocol(typing.Protocol)) + + class TypingProto(typing.Protocol): + a: int + + self.assertTrue(is_protocol(TypingProto)) + + class Concrete(TypingProto): + a: int + + self.assertFalse(is_protocol(Concrete)) + @skip_if_py312b1 def test_interaction_with_isinstance_checks_on_superclasses_with_ABCMeta(self): # Ensure the cache is empty, or this test won't work correctly diff --git a/src/typing_extensions.py b/src/typing_extensions.py index e6c1ca80..1b187ce3 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -65,8 +65,10 @@ 'get_args', 'get_origin', 'get_original_bases', + 'get_protocol_members', 'get_type_hints', 'IntVar', + 'is_protocol', 'is_typeddict', 'Literal', 'NewType', @@ -2902,6 +2904,51 @@ def __ror__(self, left): return typing.Union[left, self] +if hasattr(typing, "is_protocol"): + is_protocol = typing.is_protocol + get_protocol_members = typing.get_protocol_members +else: + def is_protocol(__tp: type) -> bool: + """Return True if the given type is a Protocol. + + Example:: + + >>> from typing_extensions import Protocol, is_protocol + >>> class P(Protocol): + ... def a(self) -> str: ... + ... b: int + >>> is_protocol(P) + True + >>> is_protocol(int) + False + """ + return ( + isinstance(__tp, type) + and getattr(__tp, '_is_protocol', False) + and __tp != Protocol + ) + + def get_protocol_members(__tp: type) -> typing.FrozenSet[str]: + """Return the set of members defined in a Protocol. + + Example:: + + >>> from typing_extensions import Protocol, get_protocol_members + >>> class P(Protocol): + ... def a(self) -> str: ... + ... b: int + >>> get_protocol_members(P) + frozenset({'a', 'b'}) + + Raise a TypeError for arguments that are not Protocols. + """ + if not is_protocol(__tp): + raise TypeError(f'{__tp!r} is not a Protocol') + if hasattr(__tp, '__protocol_attrs__'): + return frozenset(__tp.__protocol_attrs__) + return frozenset(_get_protocol_attrs(__tp)) + + # Aliases for items that have always been in typing. # Explicitly assign these (rather than using `from typing import *` at the top), # so that we get a CI error if one of these is deleted from typing.py From bc9ce4f9a4e1f38f85fab58328e8dd86741dab45 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 16 Jun 2023 08:15:21 -0700 Subject: [PATCH 28/37] Backport NamedTuple and TypedDict deprecations from Python 3.13 (#240) --- CHANGELOG.md | 9 +++ doc/index.rst | 25 ++++++++ src/test_typing_extensions.py | 102 ++++++++++++++++++++++++++++--- src/typing_extensions.py | 112 +++++++++++++++++++++++++++++----- 4 files changed, 224 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d2e258..33d0c509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ - Allow classes to inherit from both `typing.Protocol` and `typing_extensions.Protocol` simultaneously. Since v4.6.0, this caused `TypeError` to be raised due to a metaclass conflict. Patch by Alex Waygood. +- Backport several deprecations from CPython relating to unusual ways to + create `TypedDict`s and `NamedTuple`s. CPython PRs #105609 and #105780 + by Alex Waygood; `typing_extensions` backport by Jelle Zijlstra. + - Creating a `NamedTuple` using the functional syntax with keyword arguments + (`NT = NamedTuple("NT", a=int)`) is now deprecated. + - Creating a `NamedTuple` with zero fields using the syntax `NT = NamedTuple("NT")` + or `NT = NamedTuple("NT", None)` is now deprecated. + - Creating a `TypedDict` with zero fields using the syntax `TD = TypedDict("TD")` + or `TD = TypedDict("TD", None)` is now deprecated. # Release 4.6.3 (June 1, 2023) diff --git a/doc/index.rst b/doc/index.rst index 9699de57..756d1842 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -216,6 +216,22 @@ Special typing primitives Support for the ``__orig_bases__`` attribute was added. + .. versionchanged:: 4.7.0 + + The undocumented keyword argument syntax for creating NamedTuple classes + (``NT = NamedTuple("NT", x=int)``) is deprecated, and will be disallowed + in Python 3.15. Use the class-based syntax or the functional syntax instead. + + .. versionchanged:: 4.7.0 + + When using the functional syntax to create a NamedTuple class, failing to + pass a value to the 'fields' parameter (``NT = NamedTuple("NT")``) is + deprecated. Passing ``None`` to the 'fields' parameter + (``NT = NamedTuple("NT", None)``) is also deprecated. Both will be + disallowed in Python 3.15. To create a NamedTuple class with zero fields, + use ``class NT(NamedTuple): pass`` or ``NT = NamedTuple("NT", [])``. + + .. data:: Never See :py:data:`typing.Never`. In ``typing`` since 3.11. @@ -355,6 +371,15 @@ Special typing primitives This brings ``typing_extensions.TypedDict`` closer to the implementation of :py:mod:`typing.TypedDict` on Python 3.9 and higher. + .. versionchanged:: 4.7.0 + + When using the functional syntax to create a TypedDict class, failing to + pass a value to the 'fields' parameter (``TD = TypedDict("TD")``) is + deprecated. Passing ``None`` to the 'fields' parameter + (``TD = TypedDict("TD", None)``) is also deprecated. Both will be + disallowed in Python 3.15. To create a TypedDict class with 0 fields, + use ``class TD(TypedDict): pass`` or ``TD = TypedDict("TD", {})``. + .. 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 a5f47a2e..5814e00e 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3265,7 +3265,7 @@ def test_typeddict_create_errors(self): def test_typeddict_errors(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) - if sys.version_info >= (3, 12): + if sys.version_info >= (3, 13): self.assertEqual(TypedDict.__module__, 'typing') else: self.assertEqual(TypedDict.__module__, 'typing_extensions') @@ -3754,6 +3754,45 @@ class MultipleGenericBases(GenericParent[int], GenericParent[float]): self.assertEqual(MultipleGenericBases.__orig_bases__, (GenericParent[int], GenericParent[float])) self.assertEqual(CallTypedDict.__orig_bases__, (TypedDict,)) + def test_zero_fields_typeddicts(self): + T1 = TypedDict("T1", {}) + class T2(TypedDict): pass + try: + ns = {"TypedDict": TypedDict} + exec("class T3[tvar](TypedDict): pass", ns) + T3 = ns["T3"] + except SyntaxError: + class T3(TypedDict): pass + S = TypeVar("S") + class T4(TypedDict, Generic[S]): pass + + expected_warning = re.escape( + "Failing to pass a value for the 'fields' parameter is deprecated " + "and will be disallowed in Python 3.15. " + "To create a TypedDict class with 0 fields " + "using the functional syntax, " + "pass an empty dictionary, e.g. `T5 = TypedDict('T5', {})`." + ) + with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"): + T5 = TypedDict('T5') + + expected_warning = re.escape( + "Passing `None` as the 'fields' parameter is deprecated " + "and will be disallowed in Python 3.15. " + "To create a TypedDict class with 0 fields " + "using the functional syntax, " + "pass an empty dictionary, e.g. `T6 = TypedDict('T6', {})`." + ) + with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"): + T6 = TypedDict('T6', None) + + for klass in T1, T2, T3, T4, T5, T6: + with self.subTest(klass=klass.__name__): + self.assertEqual(klass.__annotations__, {}) + self.assertEqual(klass.__required_keys__, set()) + self.assertEqual(klass.__optional_keys__, set()) + self.assertIsInstance(klass(), dict) + class AnnotatedTests(BaseTestCase): @@ -4903,8 +4942,10 @@ def test_typing_extensions_defers_when_possible(self): exclude |= { 'Protocol', 'SupportsAbs', 'SupportsBytes', 'SupportsComplex', 'SupportsFloat', 'SupportsIndex', 'SupportsInt', - 'SupportsRound', 'TypedDict', 'is_typeddict', 'NamedTuple', 'Unpack', + 'SupportsRound', 'Unpack', } + if sys.version_info < (3, 13): + exclude |= {'NamedTuple', 'TypedDict', 'is_typeddict'} for item in typing_extensions.__all__: if item not in exclude and hasattr(typing, item): self.assertIs( @@ -5124,21 +5165,47 @@ class Group(NamedTuple): self.assertFalse(hasattr(Group, attr)) def test_namedtuple_keyword_usage(self): - LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int) + with self.assertWarnsRegex( + DeprecationWarning, + "Creating NamedTuple classes using keyword arguments is deprecated" + ): + LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int) + nick = LocalEmployee('Nick', 25) self.assertIsInstance(nick, tuple) self.assertEqual(nick.name, 'Nick') self.assertEqual(LocalEmployee.__name__, 'LocalEmployee') self.assertEqual(LocalEmployee._fields, ('name', 'age')) self.assertEqual(LocalEmployee.__annotations__, dict(name=str, age=int)) + with self.assertRaisesRegex( TypeError, - 'Either list of fields or keywords can be provided to NamedTuple, not both' + "Either list of fields or keywords can be provided to NamedTuple, not both" ): NamedTuple('Name', [('x', int)], y=str) + with self.assertRaisesRegex( + TypeError, + "Either list of fields or keywords can be provided to NamedTuple, not both" + ): + NamedTuple('Name', [], y=str) + + with self.assertRaisesRegex( + TypeError, + ( + r"Cannot pass `None` as the 'fields' parameter " + r"and also specify fields using keyword arguments" + ) + ): + NamedTuple('Name', None, x=int) + def test_namedtuple_special_keyword_names(self): - NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list) + with self.assertWarnsRegex( + DeprecationWarning, + "Creating NamedTuple classes using keyword arguments is deprecated" + ): + NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list) + self.assertEqual(NT.__name__, 'NT') self.assertEqual(NT._fields, ('cls', 'self', 'typename', 'fields')) a = NT(cls=str, self=42, typename='foo', fields=[('bar', tuple)]) @@ -5148,12 +5215,32 @@ def test_namedtuple_special_keyword_names(self): self.assertEqual(a.fields, [('bar', tuple)]) def test_empty_namedtuple(self): - NT = NamedTuple('NT') + expected_warning = re.escape( + "Failing to pass a value for the 'fields' parameter is deprecated " + "and will be disallowed in Python 3.15. " + "To create a NamedTuple class with 0 fields " + "using the functional syntax, " + "pass an empty list, e.g. `NT1 = NamedTuple('NT1', [])`." + ) + with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"): + NT1 = NamedTuple('NT1') + + expected_warning = re.escape( + "Passing `None` as the 'fields' parameter is deprecated " + "and will be disallowed in Python 3.15. " + "To create a NamedTuple class with 0 fields " + "using the functional syntax, " + "pass an empty list, e.g. `NT2 = NamedTuple('NT2', [])`." + ) + with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"): + NT2 = NamedTuple('NT2', None) + + NT3 = NamedTuple('NT2', []) class CNT(NamedTuple): pass # empty body - for struct in [NT, CNT]: + for struct in NT1, NT2, NT3, CNT: with self.subTest(struct=struct): self.assertEqual(struct._fields, ()) self.assertEqual(struct.__annotations__, {}) @@ -5196,7 +5283,6 @@ def test_copy_and_pickle(self): self.assertIsInstance(jane2, cls) def test_docstring(self): - self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__) self.assertIsInstance(NamedTuple.__doc__, str) @skipUnless(TYPING_3_8_0, "NamedTuple had a bad signature on <=3.7") diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 1b187ce3..0aba3604 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -972,7 +972,7 @@ def __round__(self, ndigits: int = 0) -> T_co: pass -if sys.version_info >= (3, 12): +if sys.version_info >= (3, 13): # 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" @@ -982,6 +982,7 @@ def __round__(self, ndigits: int = 0) -> T_co: # Generic TypedDicts are also impossible using typing.TypedDict on Python <3.11. # 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. TypedDict = typing.TypedDict _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict @@ -1077,13 +1078,14 @@ def __subclasscheck__(cls, other): __instancecheck__ = __subclasscheck__ - def TypedDict(__typename, __fields=None, *, total=True, **kwargs): + def TypedDict(__typename, __fields=_marker, *, total=True, **kwargs): """A simple typed namespace. At runtime it is equivalent to a plain dict. - TypedDict creates a dictionary type that expects all of its + TypedDict creates a dictionary type such that a type checker will expect all instances to have a certain set of keys, where each key is associated with a value of a consistent type. This expectation - is not checked at runtime but is only enforced by type checkers. + is not checked at runtime. + Usage:: class Point2D(TypedDict): @@ -1103,19 +1105,39 @@ class Point2D(TypedDict): Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str}) By default, all keys must be present in a TypedDict. It is possible - to override this by specifying totality. - Usage:: + to override this by specifying totality:: - class point2D(TypedDict, total=False): + class Point2D(TypedDict, total=False): x: int y: int - This means that a point2D TypedDict can have any of the keys omitted. A type + This means that a Point2D TypedDict can have any of the keys omitted. A type checker is only expected to support a literal False or True as the value of the total argument. True is the default, and makes all items defined in the class body be required. + + The Required and NotRequired special forms can also be used to mark + individual keys as being required or not required:: + + class Point2D(TypedDict): + x: int # the "x" key must always be present (Required is the default) + y: NotRequired[int] # the "y" key can be omitted + + See PEP 655 for more details on Required and NotRequired. """ - if __fields is None: + if __fields is _marker or __fields is None: + if __fields is _marker: + deprecated_thing = "Failing to pass a value for the 'fields' parameter" + else: + deprecated_thing = "Passing `None` as the 'fields' parameter" + + example = f"`{__typename} = TypedDict({__typename!r}, {{}})`" + deprecation_msg = ( + f"{deprecated_thing} is deprecated and will be disallowed in " + "Python 3.15. To create a TypedDict class with 0 fields " + "using the functional syntax, pass an empty dictionary, e.g. " + ) + example + "." + warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2) __fields = kwargs elif kwargs: raise TypeError("TypedDict takes either a dict or keyword arguments," @@ -2570,7 +2592,8 @@ def wrapper(*args, **kwargs): # In 3.11, the ability to define generic `NamedTuple`s was supported. # This was explicitly disallowed in 3.9-3.10, and only half-worked in <=3.8. # On 3.12, we added __orig_bases__ to call-based NamedTuples -if sys.version_info >= (3, 12): +# On 3.13, we deprecated kwargs-based NamedTuples +if sys.version_info >= (3, 13): NamedTuple = typing.NamedTuple else: def _make_nmtuple(name, types, module, defaults=()): @@ -2614,8 +2637,11 @@ def __new__(cls, typename, bases, ns): ) nm_tpl.__bases__ = bases if typing.Generic in bases: - class_getitem = typing.Generic.__class_getitem__.__func__ - nm_tpl.__class_getitem__ = classmethod(class_getitem) + if hasattr(typing, '_generic_class_getitem'): # 3.12+ + nm_tpl.__class_getitem__ = classmethod(typing._generic_class_getitem) + else: + 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: if key in _prohibited_namedtuple_fields: @@ -2626,17 +2652,71 @@ def __new__(cls, typename, bases, ns): nm_tpl.__init_subclass__() return nm_tpl - def NamedTuple(__typename, __fields=None, **kwargs): - if __fields is None: - __fields = kwargs.items() + def NamedTuple(__typename, __fields=_marker, **kwargs): + """Typed version of namedtuple. + + Usage:: + + class Employee(NamedTuple): + name: str + id: int + + This is equivalent to:: + + Employee = collections.namedtuple('Employee', ['name', 'id']) + + The resulting class has an extra __annotations__ attribute, giving a + dict that maps field names to types. (The field names are also in + the _fields attribute, which is part of the namedtuple API.) + An alternative equivalent functional syntax is also accepted:: + + Employee = NamedTuple('Employee', [('name', str), ('id', int)]) + """ + if __fields is _marker: + if kwargs: + deprecated_thing = "Creating NamedTuple classes using keyword arguments" + deprecation_msg = ( + "{name} is deprecated and will be disallowed in Python {remove}. " + "Use the class-based or functional syntax instead." + ) + else: + deprecated_thing = "Failing to pass a value for the 'fields' parameter" + example = f"`{__typename} = NamedTuple({__typename!r}, [])`" + deprecation_msg = ( + "{name} is deprecated and will be disallowed in Python {remove}. " + "To create a NamedTuple class with 0 fields " + "using the functional syntax, " + "pass an empty list, e.g. " + ) + example + "." + elif __fields is None: + if kwargs: + raise TypeError( + "Cannot pass `None` as the 'fields' parameter " + "and also specify fields using keyword arguments" + ) + else: + deprecated_thing = "Passing `None` as the 'fields' parameter" + example = f"`{__typename} = NamedTuple({__typename!r}, [])`" + deprecation_msg = ( + "{name} is deprecated and will be disallowed in Python {remove}. " + "To create a NamedTuple class with 0 fields " + "using the functional syntax, " + "pass an empty list, e.g. " + ) + example + "." elif kwargs: raise TypeError("Either list of fields or keywords" " can be provided to NamedTuple, not both") + if __fields is _marker or __fields is None: + warnings.warn( + deprecation_msg.format(name=deprecated_thing, remove="3.15"), + DeprecationWarning, + stacklevel=2, + ) + __fields = kwargs.items() nt = _make_nmtuple(__typename, __fields, module=_caller()) nt.__orig_bases__ = (NamedTuple,) return nt - NamedTuple.__doc__ = typing.NamedTuple.__doc__ _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) # On 3.8+, alter the signature so that it matches typing.NamedTuple. From af89916a21b0b720b413e5618dd119f55c53f7b7 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 16 Jun 2023 17:05:57 +0100 Subject: [PATCH 29/37] Fix edge-case `Protocol` bug on Python 3.7 (#242) --- CHANGELOG.md | 7 ++++ src/test_typing_extensions.py | 74 +++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 20 ++-------- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d0c509..83e81f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ or `NT = NamedTuple("NT", None)` is now deprecated. - Creating a `TypedDict` with zero fields using the syntax `TD = TypedDict("TD")` or `TD = TypedDict("TD", None)` is now deprecated. +- Fix bug on Python 3.7 where a protocol `X` that had a member `a` would not be + considered an implicit subclass of an unrelated protocol `Y` that only has a + member `a`. Where the members of `X` are a superset of the members of `Y`, + `X` should always be considered a subclass of `Y` iff `Y` is a + runtime-checkable protocol that only has callable members. Patch by Alex + Waygood (backporting CPython PR + https://github.com/python/cpython/pull/105835). # Release 4.6.3 (June 1, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 5814e00e..85e02b23 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1987,6 +1987,80 @@ def x(self): ... with self.assertRaisesRegex(TypeError, only_classes_allowed): issubclass(1, BadPG) + def test_implicit_issubclass_between_two_protocols(self): + @runtime_checkable + class CallableMembersProto(Protocol): + def meth(self): ... + + # All the below protocols should be considered "subclasses" + # of CallableMembersProto at runtime, + # even though none of them explicitly subclass CallableMembersProto + + class IdenticalProto(Protocol): + def meth(self): ... + + class SupersetProto(Protocol): + def meth(self): ... + def meth2(self): ... + + class NonCallableMembersProto(Protocol): + meth: Callable[[], None] + + class NonCallableMembersSupersetProto(Protocol): + meth: Callable[[], None] + meth2: Callable[[str, int], bool] + + class MixedMembersProto1(Protocol): + meth: Callable[[], None] + def meth2(self): ... + + class MixedMembersProto2(Protocol): + def meth(self): ... + meth2: Callable[[str, int], bool] + + for proto in ( + IdenticalProto, SupersetProto, NonCallableMembersProto, + NonCallableMembersSupersetProto, MixedMembersProto1, MixedMembersProto2 + ): + with self.subTest(proto=proto.__name__): + self.assertIsSubclass(proto, CallableMembersProto) + + # These two shouldn't be considered subclasses of CallableMembersProto, however, + # since they don't have the `meth` protocol member + + class EmptyProtocol(Protocol): ... + class UnrelatedProtocol(Protocol): + def wut(self): ... + + self.assertNotIsSubclass(EmptyProtocol, CallableMembersProto) + self.assertNotIsSubclass(UnrelatedProtocol, CallableMembersProto) + + # These aren't protocols at all (despite having annotations), + # so they should only be considered subclasses of CallableMembersProto + # if they *actually have an attribute* matching the `meth` member + # (just having an annotation is insufficient) + + class AnnotatedButNotAProtocol: + meth: Callable[[], None] + + class NotAProtocolButAnImplicitSubclass: + def meth(self): pass + + class NotAProtocolButAnImplicitSubclass2: + meth: Callable[[], None] + def meth(self): pass + + class NotAProtocolButAnImplicitSubclass3: + meth: Callable[[], None] + meth2: Callable[[int, str], bool] + def meth(self): pass + def meth(self, x, y): return True + + self.assertNotIsSubclass(AnnotatedButNotAProtocol, CallableMembersProto) + self.assertIsSubclass(NotAProtocolButAnImplicitSubclass, CallableMembersProto) + self.assertIsSubclass(NotAProtocolButAnImplicitSubclass2, CallableMembersProto) + self.assertIsSubclass(NotAProtocolButAnImplicitSubclass3, CallableMembersProto) + @skip_if_py312b1 def test_issubclass_and_isinstance_on_Protocol_itself(self): class C: diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 0aba3604..c37926d2 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -604,23 +604,10 @@ def _no_init(self, *args, **kwargs): # to mix without getting TypeErrors about "metaclass conflict" _typing_Protocol = typing.Protocol _ProtocolMetaBase = type(_typing_Protocol) - - def _is_protocol(cls): - return ( - isinstance(cls, type) - and issubclass(cls, typing.Generic) - and getattr(cls, "_is_protocol", False) - ) else: _typing_Protocol = _marker _ProtocolMetaBase = abc.ABCMeta - def _is_protocol(cls): - return ( - isinstance(cls, _ProtocolMeta) - and getattr(cls, "_is_protocol", False) - ) - class _ProtocolMeta(_ProtocolMetaBase): # This metaclass is somewhat unfortunate, # but is necessary for several reasons... @@ -634,9 +621,9 @@ def __new__(mcls, name, bases, namespace, **kwargs): elif {Protocol, _typing_Protocol} & set(bases): for base in bases: if not ( - base in {object, typing.Generic} + base in {object, typing.Generic, Protocol, _typing_Protocol} or base.__name__ in _PROTO_ALLOWLIST.get(base.__module__, []) - or _is_protocol(base) + or is_protocol(base) ): raise TypeError( f"Protocols can only inherit from other protocols, " @@ -740,8 +727,7 @@ def _proto_hook(cls, other): if ( isinstance(annotations, collections.abc.Mapping) and attr in annotations - and issubclass(other, (typing.Generic, _ProtocolMeta)) - and getattr(other, "_is_protocol", False) + and is_protocol(other) ): break else: From fd1f8538dfc79e11ae9b46af96397a60c680e24c Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 17 Jun 2023 14:41:08 +0100 Subject: [PATCH 30/37] Add a few more tests for interactions between `typing.Protocol` and `typing_extensions.Protocol` (#244) --- src/test_typing_extensions.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 85e02b23..d7e9dc44 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1817,11 +1817,37 @@ class SubProto3(ExtensionsProto, TypingProto, typing.Protocol): class SubProto4(ExtensionsProto, TypingProto, Protocol): z: int + for proto in ( + ExtensionsProto, SubProto, SubProto2, SubProto3, SubProto4 + ): + with self.subTest(proto=proto.__name__): + self.assertTrue(is_protocol(proto)) + if Protocol is not typing.Protocol: + self.assertIsInstance(proto, typing_extensions._ProtocolMeta) + self.assertIsInstance(proto.__protocol_attrs__, set) + with self.assertRaisesRegex( + TypeError, "Protocols cannot be instantiated" + ): + proto() + # check these don't raise + runtime_checkable(proto) + typing.runtime_checkable(proto) + class Concrete(SubProto): pass class Concrete2(SubProto2): pass class Concrete3(SubProto3): pass class Concrete4(SubProto4): pass + for cls in Concrete, Concrete2, Concrete3, Concrete4: + with self.subTest(cls=cls.__name__): + self.assertFalse(is_protocol(cls)) + # Check that this doesn't raise: + self.assertIsInstance(cls(), cls) + with self.assertRaises(TypeError): + runtime_checkable(cls) + with self.assertRaises(TypeError): + typing.runtime_checkable(cls) + def test_no_instantiation(self): class P(Protocol): pass with self.assertRaises(TypeError): From cfd49f81b6adf2246b8b3e3f4d3933d19f8e3546 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 17 Jun 2023 21:03:43 +0100 Subject: [PATCH 31/37] Document that `typing_extensions.Protocol` and `typing.Protocol` don't always play well together (#246) --- doc/index.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/index.rst b/doc/index.rst index 756d1842..5fd2b2e8 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -304,6 +304,12 @@ Special typing primitives ``typing_extensions.Protocol`` simultaneously. Previously, this led to :py:exc:`TypeError` being raised due to a metaclass conflict. + It is recommended to avoid doing this if possible. Not all features and + bugfixes that ``typing_extensions.Protocol`` backports from newer Python + versions are guaranteed to work if :py:class:`typing.Protocol` is also + present in a protocol class's :py:term:`method resolution order`. See + :issue:`245` for some examples. + .. data:: Required See :py:data:`typing.Required` and :pep:`655`. In ``typing`` since 3.11. From e69eea0043237af911353a44bfa50d94821f2af5 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 21 Jun 2023 07:50:58 -0700 Subject: [PATCH 32/37] Release 4.7.0rc1 (#248) --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e81f0c..7fd5ef1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Unreleased +# Release 4.7.0rc1 (June 21, 2023) - Add `typing_extensions.get_protocol_members` and `typing_extensions.is_protocol` (backport of CPython PR #104878). diff --git a/pyproject.toml b/pyproject.toml index de8921a5..f56332c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.6.3" +version = "4.7.0rc1" description = "Backported and Experimental Type Hints for Python 3.7+" readme = "README.md" requires-python = ">=3.7" From a65658fcbc0a86e529e1c46a4eaa5fee4f150607 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 21 Jun 2023 08:10:24 -0700 Subject: [PATCH 33/37] Update CONTRIBUTING.md (#249) - Tell future me how to use twine - Start using GitHub releases. Somebody (forgot who) asked for this. The advantage is that it gives a new channel to notify people about new releases. --- CONTRIBUTING.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57b10388..87aaccea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,8 +69,10 @@ may have installed. - Install the built distributions locally and test (if you were using `tox`, you already tested the source distribution). -- Run `twine upload dist/*`. +- Run `twine upload dist/*`. Remember to use `__token__` as the username + and pass your API token as the password. -- Tag the release. The tag should be just the version number, e.g. `4.1.1`. - -- `git push --tags` +- Create a new GitHub release at https://github.com/python/typing_extensions/releases/new. + Details: + - The tag should be just the version number, e.g. `4.1.1`. + - Copy the release notes from `CHANGELOG.md`. From e703629a339a670b44a3612f87bfa90a49c794f0 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 21 Jun 2023 08:46:02 -0700 Subject: [PATCH 34/37] README improvements (#250) - The mention of testing in the README (which is what appears on PyPI) felt out of place. Replace it with a more general link to CONTRIBUTING. - Mention why typing-extensions is special. - Add discussion of stub files to CONTRIBUTING. --- CONTRIBUTING.md | 8 ++++++++ README.md | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87aaccea..9d07313e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,14 @@ Starting with version 4.0.0, `typing_extensions` uses [Semantic Versioning](https://semver.org/). See the documentation for more detail. +# Type stubs + +A stub file for `typing_extensions` is maintained +[in typeshed](https://github.com/python/typeshed/blob/main/stdlib/typing_extensions.pyi). +Because of the special status that `typing_extensions` holds in the typing ecosystem, +the stubs are placed in the standard library in typeshed and distributed as +part of the stubs bundled with individual type checkers. + # Running tests Testing `typing_extensions` can be tricky because many development tools depend on diff --git a/README.md b/README.md index ddc11882..efd3a824 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ The `typing_extensions` module serves two related purposes: - Enable experimentation with new type system PEPs before they are accepted and added to the `typing` module. +`typing_extensions` is treated specially by static type checkers such as +mypy and pyright. Objects defined in `typing_extensions` are treated the same +way as equivalent forms in `typing`. + `typing_extensions` uses [Semantic Versioning](https://semver.org/). The major version will be incremented only for backwards-incompatible changes. @@ -29,7 +33,7 @@ where `x.y` is the first version that includes all features you need. See [the documentation](https://typing-extensions.readthedocs.io/en/latest/#) for a complete listing of module contents. -## Running tests +## Contributing -To run tests, navigate into the `src/` directory and run -`test_typing_extensions.py`. +See [CONTRIBUTING.md](https://github.com/python/typing_extensions/blob/main/CONTRIBUTING.md) +for how to contribute to `typing_extensions`. From e65b036661eb472a3682eca1ceb78eb57b21d200 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 23 Jun 2023 16:18:19 +0100 Subject: [PATCH 35/37] Backport CPython PR 105976 (#252) --- CHANGELOG.md | 10 ++++++++ src/test_typing_extensions.py | 44 +++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 27 +++++++++------------ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd5ef1f..54c4d1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# Unreleased + +- Fix bug where a `typing_extensions.Protocol` class that had one or more + non-callable members would raise `TypeError` when `issubclass()` + was called against it, even if it defined a custom `__subclasshook__` + method. The correct behaviour -- which has now been restored -- is not to + raise `TypeError` in these situations if a custom `__subclasshook__` method + is defined. Patch by Alex Waygood (backporting + https://github.com/python/cpython/pull/105976). + # Release 4.7.0rc1 (June 21, 2023) - Add `typing_extensions.get_protocol_members` and diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index d7e9dc44..c2ab6d7f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2713,6 +2713,50 @@ def __subclasshook__(cls, other): self.assertIsSubclass(OKClass, C) self.assertNotIsSubclass(BadClass, C) + @skipIf( + sys.version_info[:4] == (3, 12, 0, 'beta') and sys.version_info[4] < 4, + "Early betas of Python 3.12 had a bug" + ) + def test_custom_subclasshook_2(self): + @runtime_checkable + class HasX(Protocol): + # The presence of a non-callable member + # would mean issubclass() checks would fail with TypeError + # if it weren't for the custom `__subclasshook__` method + x = 1 + + @classmethod + def __subclasshook__(cls, other): + return hasattr(other, 'x') + + class Empty: pass + + class ImplementsHasX: + x = 1 + + self.assertIsInstance(ImplementsHasX(), HasX) + self.assertNotIsInstance(Empty(), HasX) + self.assertIsSubclass(ImplementsHasX, HasX) + self.assertNotIsSubclass(Empty, HasX) + + # isinstance() and issubclass() checks against this still raise TypeError, + # despite the presence of the custom __subclasshook__ method, + # as it's not decorated with @runtime_checkable + class NotRuntimeCheckable(Protocol): + @classmethod + def __subclasshook__(cls, other): + return hasattr(other, 'x') + + must_be_runtime_checkable = ( + "Instance and class checks can only be used " + "with @runtime_checkable protocols" + ) + + with self.assertRaisesRegex(TypeError, must_be_runtime_checkable): + issubclass(object, NotRuntimeCheckable) + with self.assertRaisesRegex(TypeError, must_be_runtime_checkable): + isinstance(object(), NotRuntimeCheckable) + @skip_if_py312b1 def test_issubclass_fails_correctly(self): @runtime_checkable diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c37926d2..b77c1fdb 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -644,14 +644,17 @@ def __init__(cls, *args, **kwargs): def __subclasscheck__(cls, other): if cls is Protocol: return type.__subclasscheck__(cls, other) - if not isinstance(other, type): - # Same error message as for issubclass(1, int). - raise TypeError('issubclass() arg 1 must be a class') if ( getattr(cls, '_is_protocol', False) and not _allow_reckless_class_checks() ): - if not cls.__callable_proto_members_only__: + if not isinstance(other, type): + # Same error message as for issubclass(1, int). + raise TypeError('issubclass() arg 1 must be a class') + if ( + not cls.__callable_proto_members_only__ + and cls.__dict__.get("__subclasshook__") is _proto_hook + ): raise TypeError( "Protocols with non-method members don't support issubclass()" ) @@ -752,12 +755,8 @@ def __init_subclass__(cls, *args, **kwargs): if '__subclasshook__' not in cls.__dict__: cls.__subclasshook__ = _proto_hook - # We have nothing more to do for non-protocols... - if not cls._is_protocol: - return - - # ... otherwise prohibit instantiation. - if cls.__init__ is Protocol.__init__: + # Prohibit instantiation for protocol classes + if cls._is_protocol and cls.__init__ is Protocol.__init__: cls.__init__ = _no_init else: @@ -847,12 +846,8 @@ def __init_subclass__(cls, *args, **kwargs): if '__subclasshook__' not in cls.__dict__: cls.__subclasshook__ = _proto_hook - # We have nothing more to do for non-protocols. - if not cls._is_protocol: - return - - # Prohibit instantiation - if cls.__init__ is Protocol.__init__: + # Prohibit instantiation for protocol classes + if cls._is_protocol and cls.__init__ is Protocol.__init__: cls.__init__ = _no_init From 3193f90d18e50a19725ed47bb8fe586c234b9449 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 23 Jun 2023 08:24:35 -0700 Subject: [PATCH 36/37] CHANGELOG: Mention expected end of 3.7 support (#253) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c4d1c1..475b7926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ -# Unreleased +# Release 4.7.0 (???) +- This is expected to be the last feature release supporting Python 3.7, + which reaches its end of life on June 27, 2023. Version 4.8.0 will support + only Python 3.8.0 and up. - Fix bug where a `typing_extensions.Protocol` class that had one or more non-callable members would raise `TypeError` when `issubclass()` was called against it, even if it defined a custom `__subclasshook__` From 633d2e2942372848d8d7859cf71a569259dba9ee Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 28 Jun 2023 13:52:52 -0700 Subject: [PATCH 37/37] 4.7.0 final (#255) --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 475b7926..262643ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Release 4.7.0 (???) +# Release 4.7.0 (June 28, 2023) - This is expected to be the last feature release supporting Python 3.7, which reaches its end of life on June 27, 2023. Version 4.8.0 will support diff --git a/pyproject.toml b/pyproject.toml index f56332c6..55b84997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi" # Project metadata [project] name = "typing_extensions" -version = "4.7.0rc1" +version = "4.7.0" description = "Backported and Experimental Type Hints for Python 3.7+" readme = "README.md" requires-python = ">=3.7"