Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 20 additions & 34 deletions src/pluggy/_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, ...]]:
Expand All @@ -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

Expand Down
22 changes: 22 additions & 0 deletions testing/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"), ())