diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02f80cf..9bf08e4 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.8.6 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.14.1 hooks: - id: mypy additional_dependencies: [ "typing_extensions" ] diff --git a/debian/changelog b/debian/changelog index b313174..03bfde9 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +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/docs/versionhistory.rst b/docs/versionhistory.rst index 45880a2..83f53d2 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -4,6 +4,19 @@ Version history This library adheres to `Semantic Versioning 2.0 `_. +**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 +35,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 +42,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..1dd35d2 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__: @@ -648,7 +648,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" 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/_transformer.py b/src/typeguard/_transformer.py index 25696a5..a9f0aed 100644 --- a/src/typeguard/_transformer.py +++ b/src/typeguard/_transformer.py @@ -1073,8 +1073,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 +1138,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/_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/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/test_checkers.py b/tests/test_checkers.py index 1ba0407..a326abd 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 @@ -1383,6 +1384,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..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 @@ -250,6 +275,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 +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) @@ -342,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) 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()