From 51af9de3eb32d0013df70b876c015b37c01cc1b1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 28 Nov 2025 21:57:49 +0100 Subject: [PATCH 1/2] Fix varnames to handle Python 3.14 deferred annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Python 3.14+, annotations are evaluated lazily per PEP 649/749. When inspect.signature() is called, it tries to resolve annotations by default, which fails if the annotation references an undefined type. Add a version-gated _signature helper that uses annotation_format=annotationlib.Format.STRING on Python 3.14+ to prevent annotation resolution errors. Fixes #629 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/pluggy/_hooks.py | 25 ++++++++++++++++++++++--- testing/test_helpers.py | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 7fde78c9..14025b51 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -290,6 +290,27 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: _PYPY = hasattr(sys, "pypy_version_info") +if sys.version_info >= (3, 14): + import annotationlib + + def _signature(func: object) -> inspect.Signature: + """Return the signature of a callable, avoiding annotation resolution. + + In Python 3.14+, annotations are evaluated lazily (PEP 649/749). + Using annotation_format=STRING prevents errors when annotations + reference undefined names. + """ + return inspect.signature( + func, # type: ignore[arg-type] + annotation_format=annotationlib.Format.STRING, + ) +else: + + def _signature(func: object) -> inspect.Signature: + """Return the signature of a callable.""" + return inspect.signature(func) # type: ignore[arg-type] + + def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: """Return tuple of positional and keywrord argument names for a function, method, class or callable. @@ -310,9 +331,7 @@ def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: try: # func MUST be a function or method here or we won't parse any args. - sig = inspect.signature( - func.__func__ if inspect.ismethod(func) else func # type:ignore[arg-type] - ) + sig = _signature(func.__func__ if inspect.ismethod(func) else func) except TypeError: # pragma: no cover return (), () diff --git a/testing/test_helpers.py b/testing/test_helpers.py index a08e3d7a..2567affd 100644 --- a/testing/test_helpers.py +++ b/testing/test_helpers.py @@ -114,3 +114,25 @@ def example_method(self, x, y=1) -> None: assert varnames(example) == (("a",), ("b",)) assert varnames(Example.example_method) == (("x",), ("y",)) assert varnames(ex_inst.example_method) == (("x",), ("y",)) + + +def test_varnames_unresolvable_annotation() -> None: + """Test that varnames works with annotations that cannot be resolved. + + In Python 3.14+, inspect.signature() tries to resolve string annotations + by default, which can fail if the annotation refers to a type that isn't + importable. This test ensures varnames handles such cases. + """ + # Create a function with an annotation that cannot be resolved + exec_globals: dict[str, object] = {} + exec( + """ +def func_with_unresolvable_annotation(x: "NonExistentType", y) -> None: + pass +""", + exec_globals, + ) + func = exec_globals["func_with_unresolvable_annotation"] + + # Should work without trying to resolve the annotation + assert varnames(func) == (("x", "y"), ()) From 3624358b84c98a749c3a2c3172656d6be6271659 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 28 Nov 2025 22:03:49 +0100 Subject: [PATCH 2/2] Simplify varnames to use code object directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use code object attributes (co_varnames, co_argcount) and __defaults__ directly instead of inspect.signature(). This avoids annotation resolution entirely, which is simpler and more efficient. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/pluggy/_hooks.py | 73 ++++++++++++-------------------------------- 1 file changed, 20 insertions(+), 53 deletions(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 14025b51..7ef13081 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -11,6 +11,7 @@ from collections.abc import Set import inspect import sys +import types from types import ModuleType from typing import Any from typing import Final @@ -288,27 +289,8 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: _PYPY = hasattr(sys, "pypy_version_info") - - -if sys.version_info >= (3, 14): - import annotationlib - - def _signature(func: object) -> inspect.Signature: - """Return the signature of a callable, avoiding annotation resolution. - - In Python 3.14+, annotations are evaluated lazily (PEP 649/749). - Using annotation_format=STRING prevents errors when annotations - reference undefined names. - """ - return inspect.signature( - func, # type: ignore[arg-type] - annotation_format=annotationlib.Format.STRING, - ) -else: - - def _signature(func: object) -> inspect.Signature: - """Return the signature of a callable.""" - return inspect.signature(func) # type: ignore[arg-type] +# pypy3 uses "obj" instead of "self" for default dunder methods +_IMPLICIT_NAMES = ("self", "obj") if _PYPY else ("self",) def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: @@ -329,47 +311,32 @@ def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: except Exception: # pragma: no cover - pypy special case return (), () + # Unwrap decorated functions to get the original signature + func = inspect.unwrap(func) # type: ignore[arg-type] + if inspect.ismethod(func): + func = func.__func__ + try: - # func MUST be a function or method here or we won't parse any args. - sig = _signature(func.__func__ if inspect.ismethod(func) else func) - except TypeError: # pragma: no cover + code: types.CodeType = func.__code__ # type: ignore[attr-defined] + defaults: tuple[object, ...] | None = func.__defaults__ # type: ignore[attr-defined] + qualname: str = func.__qualname__ # type: ignore[attr-defined] + except AttributeError: # pragma: no cover return (), () - _valid_param_kinds = ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - _valid_params = { - name: param - for name, param in sig.parameters.items() - if param.kind in _valid_param_kinds - } - args = tuple(_valid_params) - defaults = ( - tuple( - param.default - for param in _valid_params.values() - if param.default is not param.empty - ) - or None - ) + # Get positional argument names (positional-only + positional-or-keyword) + args: tuple[str, ...] = code.co_varnames[: code.co_argcount] + # Determine which args have defaults + kwargs: tuple[str, ...] if defaults: index = -len(defaults) - args, kwargs = args[:index], tuple(args[index:]) + args, kwargs = args[:index], args[index:] else: kwargs = () - # strip any implicit instance arg - # pypy3 uses "obj" instead of "self" for default dunder methods - if not _PYPY: - implicit_names: tuple[str, ...] = ("self",) - else: # pragma: no cover - implicit_names = ("self", "obj") - if args: - qualname: str = getattr(func, "__qualname__", "") - if inspect.ismethod(func) or ("." in qualname and args[0] in implicit_names): - args = args[1:] + # Strip implicit instance arg (self/obj for methods) + if args and "." in qualname and args[0] in _IMPLICIT_NAMES: + args = args[1:] return args, kwargs