From dbf3174c496ecbeea192da0f5ffa824b262116f7 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 23 Sep 2024 11:32:44 -0700 Subject: [PATCH 1/9] Fixes for Python 3.14 and PEP 649 I ran the typeguard test suite on the current CPython main branch to look for possible breakage from PEP 649 and 749, which makes large changes to how annotations work. I found three problems: - DeprecationWarnings from uses of ForwardRef._evaluate, which we're deprecating. Made a change to use the new public API for ForwardRef.evaluate instead. - A test failure that showed "annotationlib" as the module name for a forward ref. I made it so that it doesn't use __module__ for ForwardRef objects. - One remaining test failure that I haven't tracked down: tests/test_instrumentation.py::test_typevar_forwardref[importhook] It's apparently due to caching (it doesn't fail if I run only that one test), but I haven't tracked down the exact cause. Since the APIs in Python 3.14 may still change, I'll make this a draft PR for now. --- src/typeguard/_utils.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/typeguard/_utils.py b/src/typeguard/_utils.py index 9bcc841..bd02de3 100644 --- a/src/typeguard/_utils.py +++ b/src/typeguard/_utils.py @@ -11,7 +11,15 @@ if TYPE_CHECKING: from ._memo import TypeCheckMemo -if sys.version_info >= (3, 13): +if sys.version_info >= (3, 14): + from typing import get_args, get_origin + + def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: + return forwardref.evaluate( + globals=memo.globals, locals=memo.locals, type_params=() + ) + +elif sys.version_info >= (3, 13): from typing import get_args, get_origin def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: @@ -87,7 +95,11 @@ def get_type_name(type_: Any) -> str: name += f"[{formatted_args}]" - module = getattr(type_, "__module__", None) + # For ForwardRefs, use the module stored on the object if available + if hasattr(type_, "__forward_module__"): + module = type_.__forward_module__ + else: + module = getattr(type_, "__module__", None) if module and module not in (None, "typing", "typing_extensions", "builtins"): name = module + "." + name From 130446bcd911eb8db0cacd9883f8e6eaceb2dc8a Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 23 Sep 2024 11:43:12 -0700 Subject: [PATCH 2/9] changelog --- docs/versionhistory.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 64e33d8..b49087d 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -8,6 +8,8 @@ This library adheres to - Fixed basic support for intersection protocols (`#490 `_; PR by @antonagestam) +- Fix display of module name for forward references + (`#492 `_; PR by @JelleZijlstra) **4.3.0** (2024-05-27) From 90d672f7f38ea3526385f51f0dfdbd386cb05644 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 23 Sep 2024 12:02:40 -0700 Subject: [PATCH 3/9] Also test 3.14-only behavior --- src/typeguard/_decorators.py | 5 ++++- tests/test_instrumentation.py | 36 +++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/typeguard/_decorators.py b/src/typeguard/_decorators.py index af6f82b..0218f4b 100644 --- a/src/typeguard/_decorators.py +++ b/src/typeguard/_decorators.py @@ -117,7 +117,10 @@ def instrument(f: T_CallableOrType) -> FunctionType | str: new_function.__module__ = f.__module__ new_function.__name__ = f.__name__ new_function.__qualname__ = f.__qualname__ - new_function.__annotations__ = f.__annotations__ + if sys.version_info >= (3, 14): + new_function.__annotate__ = f.__annotate__ + else: + new_function.__annotations__ = f.__annotations__ new_function.__doc__ = f.__doc__ new_function.__defaults__ = f.__defaults__ new_function.__kwdefaults__ = f.__kwdefaults__ diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index 74bab3e..8865ff0 100644 --- a/tests/test_instrumentation.py +++ b/tests/test_instrumentation.py @@ -35,27 +35,38 @@ def method(request: FixtureRequest) -> str: return request.param -@pytest.fixture(scope="module") -def dummymodule(method: str): +def _fixture_module(name: str, method: str): config.debug_instrumentation = True sys.path.insert(0, str(this_dir)) try: - sys.modules.pop("dummymodule", None) + sys.modules.pop(name, None) if cached_module_path.exists(): cached_module_path.unlink() if method == "typechecked": - return import_module("dummymodule") + return import_module(name) - with install_import_hook(["dummymodule"]): + with install_import_hook([name]): with warnings.catch_warnings(): warnings.filterwarnings("error", module="typeguard") - module = import_module("dummymodule") + module = import_module(name) return module finally: sys.path.remove(str(this_dir)) +@pytest.fixture(scope="module") +def dummymodule(method: str): + return _fixture_module("dummymodule", method) + + +@pytest.fixture(scope="module") +def deferredannos(method: str): + if sys.version_info < (3, 14): + raise pytest.skip("Deferred annotations are only supported in Python 3.14+") + return _fixture_module("deferredannos", method) + + def test_type_checked_func(dummymodule): assert dummymodule.type_checked_func(2, 3) == 6 @@ -342,3 +353,16 @@ def test_suppress_annotated_assignment(dummymodule): def test_suppress_annotated_multi_assignment(dummymodule): with suppress_type_checks(): assert dummymodule.multi_assign_single_value() == (6, 6, 6) + + +class TestUsesForwardRef: + def test_success(self, deferredannos): + obj = deferredannos.NotYetDefined() + assert deferredannos.uses_forwardref(obj) is obj + + def test_failure(self, deferredannos): + with pytest.raises( + TypeCheckError, + match=r'argument "x" \(int\) is not an instance of deferredannos.NotYetDefined', + ): + deferredannos.uses_forwardref(1) From ec1b0498b526cd380d02849c64197ad69422a9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 19 Jan 2025 21:54:08 +0200 Subject: [PATCH 4/9] Added Python 3.14 to tox environments --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7c89494..0a53fe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ strict = true pretty = true [tool.tox] -env_list = ["py39", "py310", "py311", "py312", "py313"] +env_list = ["py39", "py310", "py311", "py312", "py313", "py314"] skip_missing_interpreters = true [tool.tox.env_run_base] From a7dba0727fc3ac46090ed8d95e50adc75a66d12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 26 Jan 2025 21:34:48 +0200 Subject: [PATCH 5/9] Fixed test_typevar_forwardref --- src/typeguard/_checkers.py | 2 +- tests/test_instrumentation.py | 33 ++++++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/typeguard/_checkers.py b/src/typeguard/_checkers.py index 5e34036..5be0fd0 100644 --- a/src/typeguard/_checkers.py +++ b/src/typeguard/_checkers.py @@ -533,7 +533,7 @@ def check_typevar( ) -> None: if origin_type.__bound__ is not None: annotation = ( - Type[origin_type.__bound__] if subclass_check else origin_type.__bound__ + type[origin_type.__bound__] if subclass_check else origin_type.__bound__ ) check_type_internal(value, annotation, memo) elif origin_type.__constraints__: diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index c723f7b..7ba75d7 100644 --- a/tests/test_instrumentation.py +++ b/tests/test_instrumentation.py @@ -1,4 +1,5 @@ import asyncio +import importlib import sys import warnings from importlib import import_module @@ -8,15 +9,16 @@ import pytest from pytest import FixtureRequest -from typeguard import TypeCheckError, config, install_import_hook, suppress_type_checks +from typeguard import TypeCheckError, install_import_hook, suppress_type_checks from typeguard._importhook import OPTIMIZATION pytestmark = pytest.mark.filterwarnings("error:no type annotations present") this_dir = Path(__file__).parent dummy_module_path = this_dir / "dummymodule.py" -cached_module_path = Path( +instrumented_cached_module_path = Path( cache_from_source(str(dummy_module_path), optimization=OPTIMIZATION) ) +cached_module_path = Path(cache_from_source(str(dummy_module_path))) # This block here is to test the recipe mentioned in the user guide if "pytest" in sys.modules: @@ -36,20 +38,32 @@ def method(request: FixtureRequest) -> str: def _fixture_module(name: str, method: str): - config.debug_instrumentation = True + # config.debug_instrumentation = True sys.path.insert(0, str(this_dir)) try: - sys.modules.pop(name, None) - if cached_module_path.exists(): - cached_module_path.unlink() - + # sys.modules.pop(name, None) if method == "typechecked": - return import_module(name) + if cached_module_path.exists(): + cached_module_path.unlink() + + if name in sys.modules: + module = import_module(name) + importlib.reload(module) + else: + module = import_module(name) + return module + + if instrumented_cached_module_path.exists(): + instrumented_cached_module_path.unlink() with install_import_hook([name]): with warnings.catch_warnings(): warnings.filterwarnings("error", module="typeguard") - module = import_module(name) + if name in sys.modules: + module = import_module(name) + importlib.reload(module) + else: + module = import_module(name) return module finally: sys.path.remove(str(this_dir)) @@ -346,6 +360,7 @@ def test_literal_in_union(dummymodule): def test_typevar_forwardref(dummymodule): + print(f"id of typevar_forwardref: {id(dummymodule.typevar_forwardref):x}") instance = dummymodule.typevar_forwardref(dummymodule.DummyClass) assert isinstance(instance, dummymodule.DummyClass) From a56bde05e1f189f9a80bbff25e2fef6677902087 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Jan 2025 20:39:06 -0800 Subject: [PATCH 6/9] Add missing file --- tests/deferredannos.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/deferredannos.py diff --git a/tests/deferredannos.py b/tests/deferredannos.py new file mode 100644 index 0000000..e802c56 --- /dev/null +++ b/tests/deferredannos.py @@ -0,0 +1,10 @@ +from typeguard import typechecked + + +@typechecked +def uses_forwardref(x: NotYetDefined) -> NotYetDefined: + return x + + +class NotYetDefined: + pass From eb869e58ec69b73cfb680e63bd93cb346748d339 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 29 Jan 2025 20:51:37 -0800 Subject: [PATCH 7/9] noqa --- tests/deferredannos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/deferredannos.py b/tests/deferredannos.py index e802c56..0d2a167 100644 --- a/tests/deferredannos.py +++ b/tests/deferredannos.py @@ -2,7 +2,7 @@ @typechecked -def uses_forwardref(x: NotYetDefined) -> NotYetDefined: +def uses_forwardref(x: NotYetDefined) -> NotYetDefined: # noqa: F821 return x From a098aceed683871c515b61a408a6df3ed52096e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 30 Jan 2025 11:53:46 +0200 Subject: [PATCH 8/9] Added Python 3.14 to the CI test matrix --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6cbe7dd..e92f419 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", pypy-3.10] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy-3.10] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 739ec42048b072e272c4482e1eee8459027f402c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 30 Jan 2025 11:54:51 +0200 Subject: [PATCH 9/9] Added the Python 3.14 classifier --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0a53fe7..fb9780e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] requires-python = ">= 3.9" dependencies = [