diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b2fca67..5b5ac6c7 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 @@ -10,6 +12,9 @@ on: permissions: contents: read +env: + PIP_DISABLE_PIP_VERSION_CHECK: 1 + concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true @@ -18,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: @@ -25,7 +38,7 @@ jobs: # Python version, because typing sometimes changed between bugfix releases. # For available versions, see: # https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json - python-version: ["3.7", "3.7.1", "3.8", "3.8.0", "3.9", "3.9.0", "3.10", "3.10.0", "3.11", "3.11.0", "3.12-dev", "pypy3.9"] + python-version: ["3.7", "3.7.1", "3.8", "3.8.0", "3.9", "3.9.0", "3.10", "3.10.0", "3.11", "3.11.0", "3.12", "pypy3.9"] runs-on: ubuntu-20.04 @@ -36,9 +49,10 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Test typing_extensions - continue-on-error: ${{ matrix.python-version == '3.12-dev' }} + 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 @@ -48,6 +62,9 @@ jobs: linting: name: Lint + # no reason to run this as a cron job + if: github.event_name != 'schedule' + runs-on: ubuntu-latest steps: @@ -61,14 +78,42 @@ jobs: - name: Install dependencies run: | - pip install --upgrade pip pip install -r test-requirements.txt # not included in test-requirements.txt as it depends on typing-extensions, # so it's a pain to have it installed locally pip install flake8-noqa - name: Lint implementation - run: flake8 + run: flake8 --color always - name: Lint tests - run: flake8 --config=.flake8-tests src/test_typing_extensions.py + 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..cde11c14 --- /dev/null +++ b/.github/workflows/third_party.yml @@ -0,0 +1,339 @@ +# 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 pdm for cattrs + run: pip install pdm + - name: Add latest typing-extensions as a dependency + run: | + pdm remove typing-extensions + pdm add --dev ./typing-extensions-latest + - name: Install cattrs test dependencies + run: pdm install --dev -G :all + - name: List all installed dependencies + run: pdm list -vv + - name: Run cattrs tests + run: pdm 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", + }) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6165d9c..ecaea2ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# Release 4.6.3 (June 1, 2023) + +- 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. +- 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. + # Release 4.6.2 (May 25, 2023) - Fix use of `@deprecated` on classes with `__new__` but no `__init__`. 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/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. 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 diff --git a/pyproject.toml b/pyproject.toml index 74ec5ed0..3858e80d 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.2" +version = "4.6.3" description = "Backported and Experimental Type Hints for Python 3.7+" readme = "README.md" requires-python = ">=3.7" diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index fd2a91c3..f9c3389c 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1696,6 +1696,12 @@ class NT(NamedTuple): y: int +skip_if_py312b1 = skipIf( + sys.version_info == (3, 12, 0, 'beta', 1), + "CPython had bugs in 3.12.0b1" +) + + class ProtocolTests(BaseTestCase): def test_runtime_alias(self): self.assertIs(runtime, runtime_checkable) @@ -1896,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): @@ -1948,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): @@ -1965,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): @@ -1986,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): @@ -2022,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): @@ -2267,6 +2328,56 @@ class Foo: ... del f.x self.assertNotIsInstance(f, HasX) + @skip_if_py312b1 + def test_runtime_checkable_generic_non_protocol(self): + # Make sure this doesn't raise AttributeError + with self.assertRaisesRegex( + TypeError, + "@runtime_checkable can be only applied to protocol classes", + ): + @runtime_checkable + class Foo(Generic[T]): ... + + def test_runtime_checkable_generic(self): + @runtime_checkable + class Foo(Protocol[T]): + def meth(self) -> T: ... + + class Impl: + def meth(self) -> int: ... + + self.assertIsSubclass(Impl, Foo) + + class NotImpl: + def method(self) -> int: ... + + self.assertNotIsSubclass(NotImpl, Foo) + + if sys.version_info >= (3, 12): + exec(textwrap.dedent( + """ + @skip_if_py312b1 + def test_pep695_generics_can_be_runtime_checkable(self): + @runtime_checkable + class HasX(Protocol): + x: int + + class Bar[T]: + x: T + def __init__(self, x): + self.x = x + + class Capybara[T]: + y: str + def __init__(self, y): + self.y = y + + self.assertIsInstance(Bar(1), HasX) + self.assertNotIsInstance(Capybara('a'), HasX) + """ + )) + + @skip_if_py312b1 def test_protocols_isinstance_generic_classes(self): T = TypeVar("T") @@ -2379,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): @@ -2712,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