Skip to content

Commit dd20a85

Browse files
RonnyPfannschmidtCursor AIclaude
committed
Warn from varnames for hookspec methods missing self
Move the missing-self warning into varnames where the ambiguity actually lives, instead of bolting it onto HookSpec.__init__. Add a legacy_noself parameter to varnames: when True and the function looks like a class method but lacks self/cls as its first parameter, emit a FutureWarning. HookSpec.__init__ passes legacy_noself=True for class-based non-static hookspecs to support the legacy pattern while warning about it. Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Anthropic Claude Opus 4 <claude@anthropic.com>
1 parent 1990b19 commit dd20a85

5 files changed

Lines changed: 200 additions & 20 deletions

File tree

src/pluggy/_hooks.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -288,32 +288,42 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None:
288288
opts.setdefault("specname", None)
289289

290290

291-
_PYPY = hasattr(sys, "pypy_version_info")
292-
# pypy3 uses "obj" instead of "self" for default dunder methods
293-
_IMPLICIT_NAMES = ("self", "obj") if _PYPY else ("self",)
291+
_PYPY = sys.implementation.name == "pypy"
292+
_IMPLICIT_NAMES = ("self", "cls", "obj") if _PYPY else ("self", "cls")
294293

295294

296-
def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
297-
"""Return tuple of positional and keyword parameter names for a function,
298-
method, class or callable.
295+
def varnames(
296+
func: object, *, legacy_noself: bool = False
297+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
298+
"""Return tuple of positional and keyword parameter names for a callable.
299299
300-
Keyword-only parameter names are not included.
301300
In case of a class, its ``__init__`` method is considered.
302-
For methods the ``self`` parameter is not included.
301+
For bound methods, the already-bound first parameter is not included.
302+
For unbound methods with a dotted ``__qualname__``, the first parameter is
303+
stripped only if its name is a known implicit name (``self``, ``cls``).
304+
Keyword-only parameters are not included.
305+
306+
:param legacy_noself:
307+
If ``True``, support hookspec classes whose methods omit ``self``.
308+
When the function looks like a class method but has no implicit first
309+
parameter, a :class:`FutureWarning` is emitted.
303310
"""
311+
is_bound = False
304312
if inspect.isclass(func):
305313
try:
306314
func = func.__init__
307315
except AttributeError: # pragma: no cover - pypy special case
308316
return (), ()
317+
is_bound = True
309318
elif not inspect.isroutine(func): # callable object?
310319
try:
311320
func = getattr(func, "__call__", func)
312321
except Exception: # pragma: no cover - pypy special case
313322
return (), ()
314323

315324
# Track bound methods before unwrapping, since __func__ loses that info.
316-
is_bound = inspect.ismethod(func)
325+
if inspect.ismethod(func):
326+
is_bound = True
317327
func = inspect.unwrap(func) # type: ignore[arg-type]
318328
if inspect.ismethod(func):
319329
is_bound = True
@@ -337,10 +347,27 @@ def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
337347
else:
338348
kwargs = ()
339349

340-
# Strip implicit instance arg (self/obj for methods)
341-
if args and args[0] in _IMPLICIT_NAMES:
342-
if is_bound or "." in qualname:
350+
# Strip implicit instance/class arg.
351+
# Check if this looks like a method defined in a class by examining the
352+
# qualname after the last "<locals>." segment (if any). A remaining dot
353+
# means it's a class method (e.g. "MyClass.method" or
354+
# "func.<locals>.MyClass.method"), not just a nested function.
355+
_tail = qualname.rsplit("<locals>.", maxsplit=1)[-1]
356+
_is_class_method = "." in _tail
357+
if args:
358+
if is_bound:
343359
args = args[1:]
360+
elif _is_class_method and args[0] in _IMPLICIT_NAMES:
361+
args = args[1:]
362+
elif _is_class_method and legacy_noself:
363+
warnings.warn(
364+
f"{qualname} is a method but its first parameter"
365+
f" {args[0]!r} is not 'self'."
366+
f" Add 'self' as the first parameter or use @staticmethod."
367+
f" This will become an error in a future version of pluggy.",
368+
FutureWarning,
369+
stacklevel=2,
370+
)
344371

345372
return args, kwargs
346373

@@ -698,9 +725,14 @@ class HookSpec:
698725

699726
def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None:
700727
self.namespace = namespace
701-
self.function: Callable[..., object] = getattr(namespace, name)
702728
self.name = name
703-
self.argnames, self.kwargnames = varnames(self.function)
729+
self.function: Callable[..., object] = getattr(namespace, name)
730+
legacy_noself = inspect.isclass(namespace) and not isinstance(
731+
inspect.getattr_static(namespace, name), staticmethod
732+
)
733+
self.argnames, self.kwargnames = varnames(
734+
self.function, legacy_noself=legacy_noself
735+
)
704736
self.opts = opts
705737
self.warn_on_impl = opts.get("warn_on_impl")
706738
self.warn_on_impl_args = opts.get("warn_on_impl_args")

testing/benchmark.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@
1616
from pluggy._hooks import varnames
1717

1818

19-
_PYPY = hasattr(sys, "pypy_version_info")
20-
21-
2219
def _varnames_legacy(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
23-
"""Pre-PEP 649 implementation using inspect.signature for comparison."""
20+
"""Pre-structural-detection implementation using inspect.signature and
21+
name-based heuristics for comparison."""
2422
if inspect.isclass(func):
2523
try:
2624
func = func.__init__
@@ -64,7 +62,8 @@ def _varnames_legacy(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
6462
else:
6563
kwargs = ()
6664

67-
if not _PYPY:
65+
_pypy = hasattr(sys, "pypy_version_info")
66+
if not _pypy:
6867
implicit_names: tuple[str, ...] = ("self",)
6968
else:
7069
implicit_names = ("self", "obj")

testing/test_helpers.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,16 @@ def example_method(self, x, y=1) -> None:
112112
ex_inst = Example()
113113

114114
assert varnames(example) == (("a",), ("b",))
115+
# Unbound: self is stripped because it's in _IMPLICIT_NAMES and qualname is dotted.
115116
assert varnames(Example.example_method) == (("x",), ("y",))
117+
# Bound: self is already consumed.
116118
assert varnames(ex_inst.example_method) == (("x",), ("y",))
117119

118120

119121
def test_varnames_bound_method_from_module_function() -> None:
120122
"""A module-level function assigned to a class attribute becomes a bound
121123
method when accessed on an instance, but its __qualname__ has no dot.
122-
varnames must still strip ``self``."""
124+
varnames must still strip the first parameter."""
123125

124126
def standalone(self, x) -> None:
125127
pass # pragma: no cover
@@ -130,6 +132,109 @@ class MyClass:
130132
assert varnames(MyClass().method) == (("x",), ())
131133

132134

135+
def test_varnames_unconventional_first_param_name() -> None:
136+
"""Bound methods strip unconditionally, but unbound methods with
137+
non-standard first parameter names preserve all arguments."""
138+
139+
class MyClass:
140+
def method(this, x) -> None:
141+
pass # pragma: no cover
142+
143+
# Bound: stripped regardless of name.
144+
assert varnames(MyClass().method) == (("x",), ())
145+
# Unbound with dotted qualname but non-implicit name: NOT stripped.
146+
assert varnames(MyClass.method) == (("this", "x"), ())
147+
148+
149+
def test_varnames_classmethod() -> None:
150+
class MyClass:
151+
@classmethod
152+
def cm(cls, x, y=1) -> None:
153+
pass # pragma: no cover
154+
155+
# Classmethods are always bound (even from the class).
156+
assert varnames(MyClass.cm) == (("x",), ("y",))
157+
assert varnames(MyClass().cm) == (("x",), ("y",))
158+
159+
160+
def test_varnames_staticmethod() -> None:
161+
class MyClass:
162+
@staticmethod
163+
def sm(x, y=1) -> None:
164+
pass # pragma: no cover
165+
166+
# Staticmethods have no implicit first arg.
167+
assert varnames(MyClass.sm) == (("x",), ("y",))
168+
assert varnames(MyClass().sm) == (("x",), ("y",))
169+
170+
171+
def test_varnames_hookspec_without_self() -> None:
172+
"""Hookspec-style class methods without self/cls preserve all parameters.
173+
174+
This is the convention used by projects like pytest-timeout where hookspec
175+
classes define methods without ``self`` since they serve as pure signatures.
176+
By default varnames does not warn; the warning is emitted when
177+
``legacy_noself=True`` is passed (as HookSpec.__init__ does).
178+
"""
179+
180+
class MySpecs:
181+
def my_hook(item, extra) -> None:
182+
pass # pragma: no cover
183+
184+
# Accessed as unbound: first arg is not an implicit name, keep it.
185+
assert varnames(MySpecs.my_hook) == (("item", "extra"), ())
186+
# Accessed as bound (via instance): first arg is stripped.
187+
assert varnames(MySpecs().my_hook) == (("extra",), ())
188+
189+
190+
def test_varnames_legacy_noself_warns() -> None:
191+
"""With ``legacy_noself=True``, varnames warns when it encounters a
192+
class method whose first parameter is not an implicit name."""
193+
import warnings
194+
195+
class MySpecs:
196+
def my_hook(item, extra) -> None:
197+
pass # pragma: no cover
198+
199+
with warnings.catch_warnings(record=True) as w:
200+
warnings.simplefilter("always")
201+
result = varnames(MySpecs.my_hook, legacy_noself=True)
202+
assert result == (("item", "extra"), ())
203+
assert len(w) == 1
204+
assert issubclass(w[0].category, FutureWarning)
205+
assert "'item' is not 'self'" in str(w[0].message)
206+
207+
208+
def test_varnames_legacy_noself_no_warn_with_self() -> None:
209+
"""With ``legacy_noself=True``, no warning when the method has ``self``."""
210+
import warnings
211+
212+
class MySpecs:
213+
def my_hook(self, item, extra) -> None:
214+
pass # pragma: no cover
215+
216+
with warnings.catch_warnings(record=True) as w:
217+
warnings.simplefilter("always")
218+
result = varnames(MySpecs.my_hook, legacy_noself=True)
219+
assert result == (("item", "extra"), ())
220+
assert len(w) == 0
221+
222+
223+
def test_varnames_no_legacy_noself_no_warn() -> None:
224+
"""Without ``legacy_noself``, no warning even for class methods without self."""
225+
import warnings
226+
227+
class MySpecs:
228+
def my_hook(item, extra) -> None:
229+
pass # pragma: no cover
230+
231+
with warnings.catch_warnings(record=True) as w:
232+
warnings.simplefilter("always")
233+
result = varnames(MySpecs.my_hook)
234+
assert result == (("item", "extra"), ())
235+
assert len(w) == 0
236+
237+
133238
def test_varnames_unresolvable_annotation() -> None:
134239
"""Test that varnames works with annotations that cannot be resolved.
135240

testing/test_hookcaller.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ def m13() -> None: ...
301301
]
302302

303303

304+
@pytest.mark.filterwarnings("ignore::FutureWarning")
304305
def test_hookspec(pm: PluginManager) -> None:
305306
class HookSpec:
306307
@hookspec()

testing/test_warnings.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import Path
2+
import warnings
23

34
import pytest
45

@@ -47,3 +48,45 @@ def my_hook(self):
4748
pm.hook.my_hook()
4849
assert len(wc.list) == 1
4950
assert Path(wc.list[0].filename).name == "test_warnings.py"
51+
52+
53+
def test_hookspec_missing_self_warns(pm: PluginManager) -> None:
54+
"""A hookspec defined as a method without ``self`` emits a FutureWarning."""
55+
56+
class Api:
57+
@hookspec
58+
def my_hook(item, extra):
59+
pass
60+
61+
with pytest.warns(
62+
FutureWarning,
63+
match=r"is a method but its first parameter 'item' is not 'self'",
64+
):
65+
pm.add_hookspecs(Api)
66+
67+
68+
def test_hookspec_with_self_no_warning(pm: PluginManager) -> None:
69+
"""A hookspec with ``self`` does not emit a FutureWarning."""
70+
71+
class Api:
72+
@hookspec
73+
def my_hook(self, item, extra):
74+
pass
75+
76+
with warnings.catch_warnings():
77+
warnings.simplefilter("error")
78+
pm.add_hookspecs(Api)
79+
80+
81+
def test_hookspec_staticmethod_no_warning(pm: PluginManager) -> None:
82+
"""A hookspec using @staticmethod does not emit a FutureWarning."""
83+
84+
class Api:
85+
@staticmethod
86+
@hookspec
87+
def my_hook(item, extra) -> None:
88+
pass
89+
90+
with warnings.catch_warnings():
91+
warnings.simplefilter("error")
92+
pm.add_hookspecs(Api)

0 commit comments

Comments
 (0)