diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 7fde78c9..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,6 +289,8 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: _PYPY = hasattr(sys, "pypy_version_info") +# 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, ...]]: @@ -308,49 +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 = inspect.signature( - func.__func__ if inspect.ismethod(func) else func # type:ignore[arg-type] - ) - 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 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"), ())