|
11 | 11 | from collections.abc import Set |
12 | 12 | import inspect |
13 | 13 | import sys |
| 14 | +import types |
14 | 15 | from types import ModuleType |
15 | 16 | from typing import Any |
16 | 17 | from typing import Final |
@@ -287,70 +288,98 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: |
287 | 288 | opts.setdefault("specname", None) |
288 | 289 |
|
289 | 290 |
|
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 | +) |
291 | 304 |
|
292 | 305 |
|
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. |
296 | 310 |
|
297 | 311 | 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. |
299 | 321 | """ |
| 322 | + is_bound = False |
300 | 323 | if inspect.isclass(func): |
301 | 324 | try: |
302 | 325 | func = func.__init__ |
303 | 326 | except AttributeError: # pragma: no cover - pypy special case |
304 | 327 | return (), () |
| 328 | + is_bound = True |
305 | 329 | elif not inspect.isroutine(func): # callable object? |
306 | 330 | try: |
307 | 331 | func = getattr(func, "__call__", func) |
308 | 332 | except Exception: # pragma: no cover - pypy special case |
309 | 333 | return (), () |
310 | 334 |
|
| 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 | + |
311 | 343 | 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 |
317 | 348 | return (), () |
318 | 349 |
|
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] |
337 | 352 |
|
| 353 | + # Determine which args have defaults |
| 354 | + kwargs: tuple[str, ...] |
338 | 355 | if defaults: |
339 | 356 | index = -len(defaults) |
340 | | - args, kwargs = args[:index], tuple(args[index:]) |
| 357 | + args, kwargs = args[:index], args[index:] |
341 | 358 | else: |
342 | 359 | kwargs = () |
343 | 360 |
|
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 |
350 | 368 | 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: |
353 | 370 | 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 | + ) |
354 | 383 |
|
355 | 384 | return args, kwargs |
356 | 385 |
|
@@ -708,9 +737,14 @@ class HookSpec: |
708 | 737 |
|
709 | 738 | def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None: |
710 | 739 | self.namespace = namespace |
711 | | - self.function: Callable[..., object] = getattr(namespace, name) |
712 | 740 | 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 | + ) |
714 | 748 | self.opts = opts |
715 | 749 | self.warn_on_impl = opts.get("warn_on_impl") |
716 | 750 | self.warn_on_impl_args = opts.get("warn_on_impl_args") |
0 commit comments