From 9627e60716a11b1c5f2732d9447f21da06cd0645 Mon Sep 17 00:00:00 2001 From: "deepin-community-bot[bot]" <156989552+deepin-community-bot[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:26:30 +0000 Subject: [PATCH] feat: update python-typeguard to 4.4.4-1 --- .pre-commit-config.yaml | 4 +- .readthedocs.yml | 10 ++- debian/changelog | 15 +++++ debian/control | 2 +- docs/versionhistory.rst | 36 +++++++++-- pyproject.toml | 23 +++---- src/typeguard/_checkers.py | 30 ++++++--- src/typeguard/_config.py | 2 - src/typeguard/_decorators.py | 27 +++++--- src/typeguard/_transformer.py | 24 +++++-- src/typeguard/_union_transformer.py | 43 ------------- src/typeguard/_utils.py | 54 ++++++++-------- tests/deferredannos.py | 10 +++ tests/dummymodule.py | 8 +++ tests/mypy/test_type_annotations.py | 21 +++--- tests/pep695.py | 12 ++++ tests/test_checkers.py | 23 ++++++- tests/test_instrumentation.py | 99 +++++++++++++++++++++++++---- tests/test_transformer.py | 4 +- tests/test_typechecked.py | 15 +++++ tests/test_union_transformer.py | 44 ------------- 21 files changed, 315 insertions(+), 191 deletions(-) delete mode 100644 src/typeguard/_union_transformer.py create mode 100644 tests/deferredannos.py create mode 100644 tests/pep695.py delete mode 100644 tests/test_union_transformer.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02f80cf..b8ebde4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,14 +14,14 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.11.4 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.15.0 hooks: - id: mypy additional_dependencies: [ "typing_extensions" ] diff --git a/.readthedocs.yml b/.readthedocs.yml index 48ac4e1..1e57ebc 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,13 +4,11 @@ build: os: ubuntu-22.04 tools: python: "3.11" + jobs: + install: + - python -m pip install --no-cache-dir "pip >= 25.1" + - python -m pip install --upgrade --upgrade-strategy only-if-needed --no-cache-dir --group doc . sphinx: configuration: docs/conf.py fail_on_warning: true - -python: - install: - - method: pip - path: . - extra_requirements: [doc] diff --git a/debian/changelog b/debian/changelog index b313174..cecfe6d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,18 @@ +python-typeguard (4.4.4-1) unstable; urgency=medium + + * Team upload. + * New upstream release. + + -- Colin Watson Mon, 18 Aug 2025 15:37:57 +0100 + +python-typeguard (4.4.2-1) unstable; urgency=medium + + * Team upload. + * New upstream release. + - Switched to JSON output when running mypy (closes: #1098615). + + -- Colin Watson Mon, 24 Feb 2025 01:03:04 +0000 + python-typeguard (4.4.1-1) unstable; urgency=medium * Team upload. diff --git a/debian/control b/debian/control index 06f779c..7da421b 100644 --- a/debian/control +++ b/debian/control @@ -12,7 +12,7 @@ Build-Depends: mypy, python3-setuptools, python3-setuptools-scm, - python3-typing-extensions (>= 4.10.0), + python3-typing-extensions (>= 4.14.0), Rules-Requires-Root: no Standards-Version: 4.7.0 Homepage: https://github.com/agronholm/typeguard diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 45880a2..d5b4f88 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -4,6 +4,37 @@ Version history This library adheres to `Semantic Versioning 2.0 `_. +**4.4.4** (2026-06-18) + +- Fixed ``IndexError`` when using ``@typechecked`` on more than one function with the + same name under certain circumstances + (`#527 `_) +- Fixed ``TypeError`` during type checking when the value to check is a parametrized + generic class + (`#526 `_) + +**4.4.3** (2025-06-05) + +- Fixed ``@typechecked`` unable to find the target function or method if it or the + containing class had PEP 695 type parameters on them + (`#500 `_) +- Fixed handling of union types on Python 3.14 + (`#522 `_) +- Fixed ``__type_params__`` getting lost when a function is instrumented + +**4.4.2** (2025-02-16) + +- 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) +- Fixed ``TypeError`` when using an assignment expression + (`#510 `_; PR by @JohannesK71083) +- Fixed ``ValueError: no signature found for builtin`` when checking against a protocol + and a matching attribute in the subject is a built-in function + (`#504 `_) + **4.4.1** (2024-11-03) - Dropped Python 3.8 support @@ -22,9 +53,6 @@ This library adheres to - Fixed checks against annotations wrapped in ``NotRequired`` not being run unless the ``NotRequired`` is a forward reference (`#454 `_) -- Fixed the ``pytest_ignore_collect`` hook in the pytest plugin blocking default pytest - collection ignoring behavior by returning ``None`` instead of ``False`` - (PR by @mgorny) **4.4.0** (2024-10-27) @@ -32,8 +60,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..8763c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = [ - "setuptools >= 64", + "setuptools >= 77", "setuptools_scm[toml] >= 6.4" ] build-backend = "setuptools.build_meta" @@ -10,11 +10,10 @@ name = "typeguard" description = "Run-time type checker for Python" readme = "README.rst" authors = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] -license = {text = "MIT"} +license = "MIT" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", @@ -22,11 +21,12 @@ 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 = [ "importlib_metadata >= 3.6; python_version < '3.10'", - "typing_extensions >= 4.10.0", + "typing_extensions >= 4.14.0", ] dynamic = ["version"] @@ -36,7 +36,10 @@ Documentation = "https://typeguard.readthedocs.io/en/latest/" "Source code" = "https://github.com/agronholm/typeguard" "Issue tracker" = "https://github.com/agronholm/typeguard/issues" -[project.optional-dependencies] +[project.entry-points] +pytest11 = {typeguard = "typeguard._pytest_plugin"} + +[dependency-groups] test = [ "coverage[toml] >= 7", "pytest >= 7", @@ -49,9 +52,6 @@ doc = [ "sphinx-rtd-theme >= 1.3.0", ] -[project.entry-points] -pytest11 = {typeguard = "typeguard._pytest_plugin"} - [tool.setuptools.package-data] typeguard = ["py.typed"] @@ -99,15 +99,16 @@ 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 +requires = ["tox >= 4.22"] [tool.tox.env_run_base] commands = [["coverage", "run", "-m", "pytest", { replace = "posargs", extend = true }]] package = "editable" -extras = ["test"] +dependency_groups = ["test"] [tool.tox.env.docs] depends = [] -extras = ["doc"] +dependency_groups = ["doc"] commands = [["sphinx-build", "-W", "-n", "docs", "build/sphinx"]] diff --git a/src/typeguard/_checkers.py b/src/typeguard/_checkers.py index 5e34036..989409b 100644 --- a/src/typeguard/_checkers.py +++ b/src/typeguard/_checkers.py @@ -172,7 +172,7 @@ def check_callable( if unfulfilled_kwonlyargs: raise TypeCheckError( f"has mandatory keyword-only arguments in its declaration: " - f'{", ".join(unfulfilled_kwonlyargs)}' + f"{', '.join(unfulfilled_kwonlyargs)}" ) num_positional_args = num_mandatory_pos_args = 0 @@ -500,8 +500,17 @@ def check_class( ) finally: del errors # avoid creating ref cycle - elif not issubclass(value, expected_class): # type: ignore[arg-type] - raise TypeCheckError(f"is not a subclass of {qualified_name(expected_class)}") + else: + if isinstance(expected_class, generic_alias_types): + expected_class = get_origin(expected_class) + + if isinstance(value, generic_alias_types): + value = get_origin(value) + + if not issubclass(value, expected_class): + raise TypeCheckError( + f"is not a subclass of {qualified_name(expected_class)}" + ) def check_newtype( @@ -533,7 +542,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__: @@ -550,7 +559,7 @@ def check_typevar( get_type_name(constraint) for constraint in origin_type.__constraints__ ) raise TypeCheckError( - f"does not match any of the constraints " f"({formatted_constraints})" + f"does not match any of the constraints ({formatted_constraints})" ) @@ -648,7 +657,12 @@ def check_io( def check_signature_compatible(subject: type, protocol: type, attrname: str) -> None: - subject_sig = inspect.signature(getattr(subject, attrname)) + subject_attr = getattr(subject, attrname) + try: + subject_sig = inspect.signature(subject_attr) + except ValueError: + return # this can happen with builtins where the signature cannot be retrieved + protocol_sig = inspect.signature(getattr(protocol, attrname)) protocol_type: typing.Literal["instance", "class", "static"] = "instance" subject_type: typing.Literal["instance", "class", "static"] = "instance" @@ -1036,7 +1050,7 @@ def builtin_checker_lookup( and getattr(origin_type, "__qualname__", "").startswith("NewType.") and hasattr(origin_type, "__supertype__") ): - # typing.NewType on Python 3.9 and below + # typing.NewType on Python 3.9 return check_newtype return None @@ -1061,7 +1075,7 @@ def load_plugins() -> None: plugin = ep.load() except Exception as exc: warnings.warn( - f"Failed to load plugin {ep.name!r}: " f"{qualified_name(exc)}: {exc}", + f"Failed to load plugin {ep.name!r}: {qualified_name(exc)}: {exc}", stacklevel=2, ) continue diff --git a/src/typeguard/_config.py b/src/typeguard/_config.py index 36efad5..c309764 100644 --- a/src/typeguard/_config.py +++ b/src/typeguard/_config.py @@ -92,8 +92,6 @@ class TypeCheckConfiguration: If set to ``True``, the code of modules or functions instrumented by typeguard is printed to ``sys.stderr`` after the instrumentation is done - Requires Python 3.9 or newer. - Default: ``False`` """ diff --git a/src/typeguard/_decorators.py b/src/typeguard/_decorators.py index a6c20cb..9b7521b 100644 --- a/src/typeguard/_decorators.py +++ b/src/typeguard/_decorators.py @@ -36,18 +36,19 @@ def make_cell(value: object) -> _Cell: def find_target_function( new_code: CodeType, target_path: Sequence[str], firstlineno: int ) -> CodeType | None: - target_name = target_path[0] for const in new_code.co_consts: if isinstance(const, CodeType): - if const.co_name == target_name: + new_path = ( + target_path[1:] if const.co_name == target_path[0] else target_path + ) + if not new_path: if const.co_firstlineno == firstlineno: return const - elif len(target_path) > 1: - target_code = find_target_function( - const, target_path[1:], firstlineno - ) - if target_code: - return target_code + + continue + + if target_code := find_target_function(const, new_path, firstlineno): + return target_code return None @@ -117,10 +118,18 @@ 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__ new_function.__doc__ = f.__doc__ new_function.__defaults__ = f.__defaults__ new_function.__kwdefaults__ = f.__kwdefaults__ + + if sys.version_info >= (3, 12): + new_function.__type_params__ = f.__type_params__ + + if sys.version_info >= (3, 14): + new_function.__annotate__ = f.__annotate__ + else: + new_function.__annotations__ = f.__annotations__ + return new_function diff --git a/src/typeguard/_transformer.py b/src/typeguard/_transformer.py index 25696a5..7b6dda8 100644 --- a/src/typeguard/_transformer.py +++ b/src/typeguard/_transformer.py @@ -373,7 +373,7 @@ def visit_BinOp(self, node: BinOp) -> Any: if isinstance(node.op, BitOr): # If either branch of the BinOp has been transformed to `None`, it means - # that a type in the union was ignored, so the entire annotation should e + # that a type in the union was ignored, so the entire annotation should be # ignored if not hasattr(node, "left") or not hasattr(node, "right"): return None @@ -384,6 +384,7 @@ def visit_BinOp(self, node: BinOp) -> Any: elif self._memo.name_matches(node.right, *anytype_names): return node.right + # Turn union types to typing.Union constructs on Python 3.9 if sys.version_info < (3, 10): union_name = self.transformer._get_import("typing", "Union") return Subscript( @@ -1073,8 +1074,9 @@ def visit_Assign(self, node: Assign) -> Any: path.insert(0, exp.id) name = prefix + ".".join(path) - annotation = self._memo.variable_annotations.get(exp.id) - if annotation: + if len(path) == 1 and ( + annotation := self._memo.variable_annotations.get(exp.id) + ): annotations_.append((Constant(name), annotation)) check_required = True else: @@ -1137,8 +1139,20 @@ def visit_NamedExpr(self, node: NamedExpr) -> Any: func_name, [ node.value, - Constant(node.target.id), - annotation, + List( + [ + List( + [ + Tuple( + [Constant(node.target.id), annotation], + ctx=Load(), + ) + ], + ctx=Load(), + ) + ], + ctx=Load(), + ), self._memo.get_memo_name(), ], [], diff --git a/src/typeguard/_union_transformer.py b/src/typeguard/_union_transformer.py deleted file mode 100644 index 1c296d3..0000000 --- a/src/typeguard/_union_transformer.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Transforms lazily evaluated PEP 604 unions into typing.Unions, for compatibility with -Python versions older than 3.10. -""" - -from __future__ import annotations - -from ast import ( - BinOp, - BitOr, - Load, - Name, - NodeTransformer, - Subscript, - Tuple, - fix_missing_locations, - parse, -) -from types import CodeType -from typing import Any - - -class UnionTransformer(NodeTransformer): - def __init__(self, union_name: Name | None = None): - self.union_name = union_name or Name(id="Union", ctx=Load()) - - def visit_BinOp(self, node: BinOp) -> Any: - self.generic_visit(node) - if isinstance(node.op, BitOr): - return Subscript( - value=self.union_name, - slice=Tuple(elts=[node.left, node.right], ctx=Load()), - ctx=Load(), - ) - - return node - - -def compile_type_hint(hint: str) -> CodeType: - parsed = parse(hint, "", "eval") - UnionTransformer().visit(parsed) - fix_missing_locations(parsed) - return compile(parsed, "", "eval", flags=0) diff --git a/src/typeguard/_utils.py b/src/typeguard/_utils.py index e8f9b03..f61b94d 100644 --- a/src/typeguard/_utils.py +++ b/src/typeguard/_utils.py @@ -4,44 +4,41 @@ import sys from importlib import import_module from inspect import currentframe -from types import CodeType, FrameType, FunctionType -from typing import TYPE_CHECKING, Any, Callable, ForwardRef, Union, cast, final -from weakref import WeakValueDictionary +from types import FrameType +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ForwardRef, + Union, + cast, + final, + get_args, + get_origin, +) if TYPE_CHECKING: from ._memo import TypeCheckMemo -if sys.version_info >= (3, 13): - from typing import get_args, get_origin +if sys.version_info >= (3, 14): def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: - return forwardref._evaluate( - memo.globals, memo.locals, type_params=(), recursive_guard=frozenset() + return forwardref.evaluate( + globals=memo.globals, locals=memo.locals, type_params=() ) - -elif sys.version_info >= (3, 10): - from typing import get_args, get_origin +elif sys.version_info >= (3, 13): def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: return forwardref._evaluate( - memo.globals, memo.locals, recursive_guard=frozenset() + memo.globals, memo.locals, type_params=(), recursive_guard=frozenset() ) - else: - from typing_extensions import get_args, get_origin - - evaluate_extra_args: tuple[frozenset[Any], ...] = ( - (frozenset(),) if sys.version_info >= (3, 9) else () - ) def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: - from ._union_transformer import compile_type_hint - - if not forwardref.__forward_evaluated__: - forwardref.__forward_code__ = compile_type_hint(forwardref.__forward_arg__) - try: - return forwardref._evaluate(memo.globals, memo.locals, *evaluate_extra_args) + return forwardref._evaluate( + memo.globals, memo.locals, recursive_guard=frozenset() + ) except NameError: if sys.version_info < (3, 10): # Try again, with the type substitutions (list -> List etc.) in place @@ -49,15 +46,12 @@ def evaluate_forwardref(forwardref: ForwardRef, memo: TypeCheckMemo) -> Any: new_globals.setdefault("Union", Union) return forwardref._evaluate( - new_globals, memo.locals or new_globals, *evaluate_extra_args + new_globals, memo.locals or new_globals, recursive_guard=frozenset() ) raise -_functions_map: WeakValueDictionary[CodeType, FunctionType] = WeakValueDictionary() - - def get_type_name(type_: Any) -> str: name: str for attrname in "__name__", "_name", "__forward_arg__": @@ -85,7 +79,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/dummymodule.py b/tests/dummymodule.py index d53c972..17ca226 100644 --- a/tests/dummymodule.py +++ b/tests/dummymodule.py @@ -74,6 +74,9 @@ class Metaclass(type): @typechecked class DummyClass(metaclass=Metaclass): + bar: str + baz: int + def type_checked_method(self, x: int, y: int) -> int: return x * y @@ -270,6 +273,11 @@ def unpacking_assign_star_no_annotation(value: Any) -> Tuple[int, List[bytes], s return x, y, z +@typechecked +def attribute_assign_unpacking(obj: DummyClass) -> None: + obj.bar, obj.baz = "foo", 123123 + + @typechecked(forward_ref_policy=ForwardRefPolicy.ERROR) def override_forward_ref_policy(value: "NonexistentType") -> None: # noqa: F821 pass diff --git a/tests/mypy/test_type_annotations.py b/tests/mypy/test_type_annotations.py index aacee7f..5189106 100644 --- a/tests/mypy/test_type_annotations.py +++ b/tests/mypy/test_type_annotations.py @@ -1,14 +1,12 @@ +import json import os import platform -import re import subprocess -from typing import Dict, List import pytest POSITIVE_FILE = "positive.py" NEGATIVE_FILE = "negative.py" -LINE_PATTERN = NEGATIVE_FILE + ":([0-9]+):" pytestmark = [ pytest.mark.skipif( @@ -18,8 +16,8 @@ ] -def get_mypy_cmd(filename: str) -> List[str]: - return ["mypy", "--strict", filename] +def get_mypy_cmd(filename: str) -> list[str]: + return ["mypy", "-O", "json", "--strict", filename] def get_negative_mypy_output() -> str: @@ -34,7 +32,7 @@ def get_negative_mypy_output() -> str: return output -def get_expected_errors() -> Dict[int, str]: +def get_expected_errors() -> dict[int, str]: """ Extract the expected errors from comments in the negative examples file. """ @@ -46,14 +44,14 @@ def get_expected_errors() -> Dict[int, str]: for idx, line in enumerate(lines): line = line.rstrip() if "# error" in line: - expected[idx + 1] = line[line.index("# error") + 2 :] + expected[idx + 1] = line[line.index("# error") + 9 :] # Sanity check. Should update if negative.py changes. assert len(expected) == 9 return expected -def get_mypy_errors() -> Dict[int, str]: +def get_mypy_errors() -> dict[int, str]: """ Extract the errors from running mypy on the negative examples file. """ @@ -61,10 +59,8 @@ def get_mypy_errors() -> Dict[int, str]: got = {} for line in mypy_output.splitlines(): - m = re.match(LINE_PATTERN, line) - if m is None: - continue - got[int(m.group(1))] = line[len(m.group(0)) + 1 :] + error = json.loads(line) + got[error["line"]] = f"{error['message']} [{error['code']}]" return got @@ -109,5 +105,6 @@ def test_negative() -> None: ] for idx, expected, got in mismatches: print(f"Line {idx}", f"Expected: {expected}", f"Got: {got}", sep="\n\t") + if mismatches: raise RuntimeError("Error messages changed") diff --git a/tests/pep695.py b/tests/pep695.py new file mode 100644 index 0000000..b0a2929 --- /dev/null +++ b/tests/pep695.py @@ -0,0 +1,12 @@ +from typeguard import typechecked + + +@typechecked +class ParametrizedClass[T]: + def method(self, x: T, y: str) -> T: + return x + + +@typechecked +def parametrized_func[T](x: T, y: str) -> T: + return x diff --git a/tests/test_checkers.py b/tests/test_checkers.py index 1ba0407..22c0d1c 100644 --- a/tests/test_checkers.py +++ b/tests/test_checkers.py @@ -2,6 +2,7 @@ import sys import types from contextlib import nullcontext +from datetime import timedelta from functools import partial from io import BytesIO, StringIO from pathlib import Path @@ -896,8 +897,13 @@ def test_raw_uniontype_success(self): @pytest.mark.skipif(sys.version_info < (3, 10), reason="UnionType requires 3.10") def test_raw_uniontype_fail(self): + if sys.version_info < (3, 14): + expected_type = r"\w+\.UnionType" + else: + expected_type = "Union" + with pytest.raises( - TypeCheckError, match=r"class str is not an instance of \w+\.UnionType$" + TypeCheckError, match=f"class str is not an instance of {expected_type}$" ): check_type(str, types.UnionType) @@ -968,6 +974,9 @@ def test_parametrized_fail(self): "class int is not a subclass of str" ) + def test_parametrized_value(self): + check_type(list[str], type[list[str]]) + @pytest.mark.parametrize( "value", [pytest.param(str, id="str"), pytest.param(int, id="int")] ) @@ -1383,6 +1392,18 @@ def meth(self) -> None: f"be a class method but it's an instance method" ) + def test_builtin_signature_check(self) -> None: + class MyProtocol(Protocol): + def attr(self) -> None: + pass + + class Foo: + attr = timedelta + + # Foo.attr is incompatible but timedelta has not inspectable signature so the + # check is skipped + check_type(Foo(), MyProtocol) + class TestRecursiveType: def test_valid(self): diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index 74bab3e..4d9929c 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,59 @@ 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) + + +@pytest.fixture(scope="module") +def pep695(method: str): + if sys.version_info < (3, 12): + raise pytest.skip("PEP 695 type parameter syntax requires Python 3.12+") + + return _fixture_module("pep695", method) + + def test_type_checked_func(dummymodule): assert dummymodule.type_checked_func(2, 3) == 6 @@ -250,6 +284,11 @@ def test_unpacking_assign_star_no_annotation_success(dummymodule): ) +def test_attribute_assign_unpacking(dummymodule): + foo = dummymodule.DummyClass() + dummymodule.attribute_assign_unpacking(foo) + + def test_unpacking_assign_star_no_annotation_fail(dummymodule): with pytest.raises( TypeCheckError, match=r"value assigned to z \(bytes\) is not an instance of str" @@ -330,6 +369,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) @@ -342,3 +382,38 @@ 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) + + +class TestParametrized: + def test_success_func(self, pep695): + assert pep695.parametrized_func(1, "2") == 1 + + def test_success_method(self, pep695): + assert pep695.ParametrizedClass[int]().method(1, "2") == 1 + + def test_failure_func(self, pep695): + with pytest.raises( + TypeCheckError, + match=r'argument "y" \(int\) is not an instance of str', + ): + pep695.parametrized_func(1, 2) + + def test_failure_method(self, pep695): + with pytest.raises( + TypeCheckError, + match=r'argument "y" \(int\) is not an instance of str', + ): + pep695.ParametrizedClass[int]().method("str", 2) diff --git a/tests/test_transformer.py b/tests/test_transformer.py index 2e18a5a..3fb0462 100644 --- a/tests/test_transformer.py +++ b/tests/test_transformer.py @@ -1475,7 +1475,7 @@ def foo() -> None: def foo() -> None: memo = TypeCheckMemo(globals(), locals()) x: int - if (x := check_variable_assignment(otherfunc(), 'x', int, \ + if (x := check_variable_assignment(otherfunc(), [[('x', int)]], \ memo)): pass """ @@ -1504,7 +1504,7 @@ def foo(x: int) -> None: def foo(x: int) -> None: memo = TypeCheckMemo(globals(), locals()) check_argument_types('foo', {'x': (x, int)}, memo) - if (x := check_variable_assignment(otherfunc(), 'x', int, memo)): + if (x := check_variable_assignment(otherfunc(), [[('x', int)]], memo)): pass """ ).strip() diff --git a/tests/test_typechecked.py b/tests/test_typechecked.py index d56f3ae..c5fee1a 100644 --- a/tests/test_typechecked.py +++ b/tests/test_typechecked.py @@ -692,3 +692,18 @@ def x(self, value: int) -> str: # noqa: F811 assert Foo().x(1) == "second" with pytest.raises(TypeCheckError): Foo().x("wrong") + + +def test_duplicate_function(): + @typechecked + def foo() -> list[int]: # noqa: F811 + return [x for x in range(5)] + + foo1 = foo + + @typechecked + def foo() -> list[int]: # noqa: F811 + return [x for x in range(5, 10)] + + assert foo1() == [0, 1, 2, 3, 4] + assert foo() == [5, 6, 7, 8, 9] diff --git a/tests/test_union_transformer.py b/tests/test_union_transformer.py deleted file mode 100644 index e6dcd25..0000000 --- a/tests/test_union_transformer.py +++ /dev/null @@ -1,44 +0,0 @@ -import typing -from typing import Callable, Union - -import pytest -from typing_extensions import Literal - -from typeguard._union_transformer import compile_type_hint - -eval_globals = { - "Callable": Callable, - "Literal": Literal, - "typing": typing, - "Union": Union, -} - - -@pytest.mark.parametrize( - "inputval, expected", - [ - ["str | int", "Union[str, int]"], - ["str | int | bytes", "Union[str, int, bytes]"], - ["str | Union[int | bytes, set]", "Union[str, int, bytes, set]"], - ["str | int | Callable[..., bytes]", "Union[str, int, Callable[..., bytes]]"], - ["str | int | Callable[[], bytes]", "Union[str, int, Callable[[], bytes]]"], - [ - "str | int | Callable[[], bytes | set]", - "Union[str, int, Callable[[], Union[bytes, set]]]", - ], - ["str | int | Literal['foo']", "Union[str, int, Literal['foo']]"], - ["str | int | Literal[-1]", "Union[str, int, Literal[-1]]"], - ["str | int | Literal[-1]", "Union[str, int, Literal[-1]]"], - [ - 'str | int | Literal["It\'s a string \'\\""]', - "Union[str, int, Literal['It\\'s a string \\'\"']]", - ], - ], -) -def test_union_transformer(inputval: str, expected: str) -> None: - code = compile_type_hint(inputval) - evaluated = eval(code, eval_globals) - evaluated_repr = repr(evaluated) - evaluated_repr = evaluated_repr.replace("typing.", "") - evaluated_repr = evaluated_repr.replace("typing_extensions.", "") - assert evaluated_repr == expected