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 diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 4241d82..79a6e20 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -9,6 +9,8 @@ This library adheres to - Fixed ``TypeCheckError`` in unpacking assignment involving properties of a parameter of the function (`#506 `_; regression introduced in v4.4.1) +- Fixed display of module name for forward references + (`#492 `_; PR by @JelleZijlstra) **4.4.1** (2024-11-03) @@ -35,8 +37,6 @@ This library adheres to (`#465 `_) - Fixed basic support for intersection protocols (`#490 `_; PR by @antonagestam) -- Fixed protocol checks running against the class of an instance and not the instance - itself (this produced wrong results for non-method member checks) **4.3.0** (2024-05-27) diff --git a/pyproject.toml b/pyproject.toml index 7c89494..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 = [ @@ -99,7 +100,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] 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/src/typeguard/_decorators.py b/src/typeguard/_decorators.py index a6c20cb..c92d72b 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/src/typeguard/_utils.py b/src/typeguard/_utils.py index e8f9b03..69043ce 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: @@ -85,7 +93,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 diff --git a/tests/deferredannos.py b/tests/deferredannos.py new file mode 100644 index 0000000..0d2a167 --- /dev/null +++ b/tests/deferredannos.py @@ -0,0 +1,10 @@ +from typeguard import typechecked + + +@typechecked +def uses_forwardref(x: NotYetDefined) -> NotYetDefined: # noqa: F821 + return x + + +class NotYetDefined: + pass diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index 8f7f29d..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: @@ -35,27 +37,50 @@ def method(request: FixtureRequest) -> str: return request.param -@pytest.fixture(scope="module") -def dummymodule(method: str): - config.debug_instrumentation = True +def _fixture_module(name: str, method: str): + # config.debug_instrumentation = True sys.path.insert(0, str(this_dir)) try: - sys.modules.pop("dummymodule", None) - if cached_module_path.exists(): - cached_module_path.unlink() - + # sys.modules.pop(name, None) if method == "typechecked": - return import_module("dummymodule") + 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(["dummymodule"]): + with install_import_hook([name]): with warnings.catch_warnings(): warnings.filterwarnings("error", module="typeguard") - module = import_module("dummymodule") + 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)) +@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 @@ -335,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) @@ -347,3 +373,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)