Skip to content

Commit 5d7bd2c

Browse files
Merge pull request #632 from RonnyPfannschmidt/varnames-no-resolve-types
[NO-SQUASH-MERGE] fix varnames on py3.14 deferred annotations
2 parents 59e66fd + 0258484 commit 5d7bd2c

7 files changed

Lines changed: 368 additions & 42 deletions

File tree

changelog/629.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix hooks failing to register on Python 3.14+ when type annotations use forward references.

changelog/632.removal.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hookspec methods defined without ``self`` as the first parameter now emit a :class:`DeprecationWarning`. Add ``self`` as the first parameter or use ``@staticmethod``.

src/pluggy/_hooks.py

Lines changed: 73 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from collections.abc import Set
1212
import inspect
1313
import sys
14+
import types
1415
from types import ModuleType
1516
from typing import Any
1617
from typing import Final
@@ -287,70 +288,98 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None:
287288
opts.setdefault("specname", None)
288289

289290

290-
_PYPY = hasattr(sys, "pypy_version_info")
291+
_PYPY = sys.implementation.name == "pypy"
292+
_IMPLICIT_NAMES = ("self", "cls", "obj") if _PYPY else ("self", "cls")
293+
294+
# Qualnames whose missing-self deprecation warning is suppressed because
295+
# their upstream code is already fixed but not yet released.
296+
# Remove entries once a release with the fix is available.
297+
_NOSELF_WARN_SUPPRESS: frozenset[str] = frozenset(
298+
{
299+
# pytest-timeout >=2.3.2 has the fix, but is unreleased as of 2026-05.
300+
"TimeoutHooks.pytest_timeout_set_timer",
301+
"TimeoutHooks.pytest_timeout_cancel_timer",
302+
}
303+
)
291304

292305

293-
def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
294-
"""Return tuple of positional and keywrord argument names for a function,
295-
method, class or callable.
306+
def varnames(
307+
func: object, *, legacy_noself: bool = False
308+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
309+
"""Return tuple of positional and keyword parameter names for a callable.
296310
297311
In case of a class, its ``__init__`` method is considered.
298-
For methods the ``self`` parameter is not included.
312+
For bound methods, the already-bound first parameter is not included.
313+
For unbound methods with a dotted ``__qualname__``, the first parameter is
314+
stripped only if its name is a known implicit name (``self``, ``cls``).
315+
Keyword-only parameters are not included.
316+
317+
:param legacy_noself:
318+
If ``True``, support hookspec classes whose methods omit ``self``.
319+
When the function looks like a class method but has no implicit first
320+
parameter, a :class:`DeprecationWarning` is emitted.
299321
"""
322+
is_bound = False
300323
if inspect.isclass(func):
301324
try:
302325
func = func.__init__
303326
except AttributeError: # pragma: no cover - pypy special case
304327
return (), ()
328+
is_bound = True
305329
elif not inspect.isroutine(func): # callable object?
306330
try:
307331
func = getattr(func, "__call__", func)
308332
except Exception: # pragma: no cover - pypy special case
309333
return (), ()
310334

335+
# Track bound methods before unwrapping, since __func__ loses that info.
336+
if inspect.ismethod(func):
337+
is_bound = True
338+
func = inspect.unwrap(func) # type: ignore[arg-type]
339+
if inspect.ismethod(func):
340+
is_bound = True
341+
func = func.__func__
342+
311343
try:
312-
# func MUST be a function or method here or we won't parse any args.
313-
sig = inspect.signature(
314-
func.__func__ if inspect.ismethod(func) else func # type:ignore[arg-type]
315-
)
316-
except TypeError: # pragma: no cover
344+
code: types.CodeType = func.__code__ # type: ignore[attr-defined]
345+
defaults: tuple[object, ...] | None = func.__defaults__ # type: ignore[attr-defined]
346+
qualname: str = func.__qualname__ # type: ignore[attr-defined]
347+
except AttributeError: # pragma: no cover
317348
return (), ()
318349

319-
_valid_param_kinds = (
320-
inspect.Parameter.POSITIONAL_ONLY,
321-
inspect.Parameter.POSITIONAL_OR_KEYWORD,
322-
)
323-
_valid_params = {
324-
name: param
325-
for name, param in sig.parameters.items()
326-
if param.kind in _valid_param_kinds
327-
}
328-
args = tuple(_valid_params)
329-
defaults = (
330-
tuple(
331-
param.default
332-
for param in _valid_params.values()
333-
if param.default is not param.empty
334-
)
335-
or None
336-
)
350+
# Get positional argument names (positional-only + positional-or-keyword)
351+
args: tuple[str, ...] = code.co_varnames[: code.co_argcount]
337352

353+
# Determine which args have defaults
354+
kwargs: tuple[str, ...]
338355
if defaults:
339356
index = -len(defaults)
340-
args, kwargs = args[:index], tuple(args[index:])
357+
args, kwargs = args[:index], args[index:]
341358
else:
342359
kwargs = ()
343360

344-
# strip any implicit instance arg
345-
# pypy3 uses "obj" instead of "self" for default dunder methods
346-
if not _PYPY:
347-
implicit_names: tuple[str, ...] = ("self",)
348-
else:
349-
implicit_names = ("self", "obj")
361+
# Strip implicit instance/class arg.
362+
# Check if this looks like a method defined in a class by examining the
363+
# qualname after the last "<locals>." segment (if any). A remaining dot
364+
# means it's a class method (e.g. "MyClass.method" or
365+
# "func.<locals>.MyClass.method"), not just a nested function.
366+
_tail = qualname.rsplit("<locals>.", maxsplit=1)[-1]
367+
_is_class_method = "." in _tail
350368
if args:
351-
qualname: str = getattr(func, "__qualname__", "")
352-
if inspect.ismethod(func) or ("." in qualname and args[0] in implicit_names):
369+
if is_bound:
353370
args = args[1:]
371+
elif _is_class_method and args[0] in _IMPLICIT_NAMES:
372+
args = args[1:]
373+
elif _is_class_method and legacy_noself:
374+
if _tail not in _NOSELF_WARN_SUPPRESS:
375+
warnings.warn(
376+
f"{qualname} is a method but its first parameter"
377+
f" {args[0]!r} is not 'self'."
378+
f" Add 'self' as the first parameter or use @staticmethod."
379+
f" This will become an error in a future version of pluggy.",
380+
DeprecationWarning,
381+
stacklevel=2,
382+
)
354383

355384
return args, kwargs
356385

@@ -708,9 +737,14 @@ class HookSpec:
708737

709738
def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None:
710739
self.namespace = namespace
711-
self.function: Callable[..., object] = getattr(namespace, name)
712740
self.name = name
713-
self.argnames, self.kwargnames = varnames(self.function)
741+
self.function: Callable[..., object] = getattr(namespace, name)
742+
legacy_noself = inspect.isclass(namespace) and not isinstance(
743+
inspect.getattr_static(namespace, name), staticmethod
744+
)
745+
self.argnames, self.kwargnames = varnames(
746+
self.function, legacy_noself=legacy_noself
747+
)
714748
self.opts = opts
715749
self.warn_on_impl = opts.get("warn_on_impl")
716750
self.warn_on_impl_args = opts.get("warn_on_impl_args")

testing/benchmark.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Benchmarking and performance tests.
33
"""
44

5+
import inspect
6+
import sys
57
from typing import Any
68

79
import pytest
@@ -11,6 +13,66 @@
1113
from pluggy import PluginManager
1214
from pluggy._callers import _multicall
1315
from pluggy._hooks import HookImpl
16+
from pluggy._hooks import varnames
17+
18+
19+
def _varnames_legacy(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
20+
"""Pre-structural-detection implementation using inspect.signature and
21+
name-based heuristics for comparison."""
22+
if inspect.isclass(func):
23+
try:
24+
func = func.__init__
25+
except AttributeError:
26+
return (), ()
27+
elif not inspect.isroutine(func):
28+
try:
29+
func = getattr(func, "__call__", func)
30+
except Exception:
31+
return (), ()
32+
33+
try:
34+
sig = inspect.signature(
35+
func.__func__ if inspect.ismethod(func) else func # type: ignore[arg-type]
36+
)
37+
except TypeError:
38+
return (), ()
39+
40+
_valid_param_kinds = (
41+
inspect.Parameter.POSITIONAL_ONLY,
42+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
43+
)
44+
_valid_params = {
45+
name: param
46+
for name, param in sig.parameters.items()
47+
if param.kind in _valid_param_kinds
48+
}
49+
args = tuple(_valid_params)
50+
defaults = (
51+
tuple(
52+
param.default
53+
for param in _valid_params.values()
54+
if param.default is not param.empty
55+
)
56+
or None
57+
)
58+
59+
if defaults:
60+
index = -len(defaults)
61+
args, kwargs = args[:index], tuple(args[index:])
62+
else:
63+
kwargs = ()
64+
65+
_pypy = hasattr(sys, "pypy_version_info")
66+
if not _pypy:
67+
implicit_names: tuple[str, ...] = ("self",)
68+
else:
69+
implicit_names = ("self", "obj")
70+
if args:
71+
qualname: str = getattr(func, "__qualname__", "")
72+
if inspect.ismethod(func) or ("." in qualname and args[0] in implicit_names):
73+
args = args[1:]
74+
75+
return args, kwargs
1476

1577

1678
hookspec = HookspecMarker("example")
@@ -106,3 +168,30 @@ def fun(self):
106168
pm.register(PluginWrap(i), name=f"wrap_plug_{i}")
107169

108170
benchmark(pm.hook.fun, hooks=pm.hook, nesting=nesting)
171+
172+
173+
def _plain_func(x: int, y: str, z: float = 1.0) -> None:
174+
pass
175+
176+
177+
class _MethodHolder:
178+
def method(self, x: int, y: str, z: float = 1.0) -> None:
179+
pass
180+
181+
182+
_varnames_funcs = [
183+
pytest.param(_plain_func, id="plain_function"),
184+
pytest.param(_MethodHolder.method, id="unbound_method"),
185+
pytest.param(_MethodHolder().method, id="bound_method"),
186+
pytest.param(_MethodHolder, id="class"),
187+
]
188+
189+
190+
@pytest.mark.parametrize("func", _varnames_funcs)
191+
def test_varnames(benchmark, func: object) -> None:
192+
benchmark(varnames, func)
193+
194+
195+
@pytest.mark.parametrize("func", _varnames_funcs)
196+
def test_varnames_legacy(benchmark, func: object) -> None:
197+
benchmark(_varnames_legacy, func)

0 commit comments

Comments
 (0)