From ff60fb323df59223db8e62ce047fe623a4e5969e Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 28 Nov 2025 21:57:49 +0100 Subject: [PATCH 01/10] Fix varnames to handle Python 3.14 deferred annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Python 3.14+, annotations are evaluated lazily per PEP 649/749. When inspect.signature() is called, it tries to resolve annotations by default, which fails if the annotation references an undefined type. Add a version-gated _signature helper that uses annotation_format=annotationlib.Format.STRING on Python 3.14+ to prevent annotation resolution errors. Fixes #629 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/pluggy/_hooks.py | 25 ++++++++++++++++++++++--- testing/test_helpers.py | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 3d232870..bd74f150 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -290,6 +290,27 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: _PYPY = hasattr(sys, "pypy_version_info") +if sys.version_info >= (3, 14): + import annotationlib + + def _signature(func: object) -> inspect.Signature: + """Return the signature of a callable, avoiding annotation resolution. + + In Python 3.14+, annotations are evaluated lazily (PEP 649/749). + Using annotation_format=STRING prevents errors when annotations + reference undefined names. + """ + return inspect.signature( + func, # type: ignore[arg-type] + annotation_format=annotationlib.Format.STRING, + ) +else: + + def _signature(func: object) -> inspect.Signature: + """Return the signature of a callable.""" + return inspect.signature(func) # type: ignore[arg-type] + + def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: """Return tuple of positional and keywrord argument names for a function, method, class or callable. @@ -310,9 +331,7 @@ def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: 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] - ) + sig = _signature(func.__func__ if inspect.ismethod(func) else func) except TypeError: # pragma: no cover return (), () 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"), ()) From b70fe6235502ee406078fb7d264a59e1b1bc5d56 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 28 Nov 2025 22:03:49 +0100 Subject: [PATCH 02/10] Simplify varnames to use code object directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use code object attributes (co_varnames, co_argcount) and __defaults__ directly instead of inspect.signature(). This avoids annotation resolution entirely, which is simpler and more efficient. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/pluggy/_hooks.py | 73 ++++++++++++-------------------------------- 1 file changed, 20 insertions(+), 53 deletions(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index bd74f150..aa0d3df1 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,27 +289,8 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: _PYPY = hasattr(sys, "pypy_version_info") - - -if sys.version_info >= (3, 14): - import annotationlib - - def _signature(func: object) -> inspect.Signature: - """Return the signature of a callable, avoiding annotation resolution. - - In Python 3.14+, annotations are evaluated lazily (PEP 649/749). - Using annotation_format=STRING prevents errors when annotations - reference undefined names. - """ - return inspect.signature( - func, # type: ignore[arg-type] - annotation_format=annotationlib.Format.STRING, - ) -else: - - def _signature(func: object) -> inspect.Signature: - """Return the signature of a callable.""" - return inspect.signature(func) # type: ignore[arg-type] +# 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, ...]]: @@ -329,47 +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 = _signature(func.__func__ if inspect.ismethod(func) else func) - 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: - 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 From 46b2e0cddc9dd73979d0f244cbd0e975373f5df0 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 29 Apr 2026 10:27:54 +0200 Subject: [PATCH 03/10] Add varnames benchmark comparing __code__ vs inspect.signature Include both the current implementation and the legacy inspect.signature-based version to clearly demonstrate the ~7-66x speedup from using code objects directly. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- testing/benchmark.py | 90 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/testing/benchmark.py b/testing/benchmark.py index cc3be4eb..d672e5cf 100644 --- a/testing/benchmark.py +++ b/testing/benchmark.py @@ -2,6 +2,8 @@ Benchmarking and performance tests. """ +import inspect +import sys from typing import Any import pytest @@ -11,6 +13,67 @@ from pluggy import PluginManager from pluggy._callers import _multicall from pluggy._hooks import HookImpl +from pluggy._hooks import varnames + + +_PYPY = hasattr(sys, "pypy_version_info") + + +def _varnames_legacy(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Pre-PEP 649 implementation using inspect.signature for comparison.""" + if inspect.isclass(func): + try: + func = func.__init__ + except AttributeError: + return (), () + elif not inspect.isroutine(func): + try: + func = getattr(func, "__call__", func) + except Exception: + return (), () + + try: + sig = inspect.signature( + func.__func__ if inspect.ismethod(func) else func # type: ignore[arg-type] + ) + except TypeError: + 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 + ) + + if defaults: + index = -len(defaults) + args, kwargs = args[:index], tuple(args[index:]) + else: + kwargs = () + + if not _PYPY: + implicit_names: tuple[str, ...] = ("self",) + else: + 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:] + + return args, kwargs hookspec = HookspecMarker("example") @@ -106,3 +169,30 @@ def fun(self): pm.register(PluginWrap(i), name=f"wrap_plug_{i}") benchmark(pm.hook.fun, hooks=pm.hook, nesting=nesting) + + +def _plain_func(x: int, y: str, z: float = 1.0) -> None: + pass + + +class _MethodHolder: + def method(self, x: int, y: str, z: float = 1.0) -> None: + pass + + +_varnames_funcs = [ + pytest.param(_plain_func, id="plain_function"), + pytest.param(_MethodHolder.method, id="unbound_method"), + pytest.param(_MethodHolder().method, id="bound_method"), + pytest.param(_MethodHolder, id="class"), +] + + +@pytest.mark.parametrize("func", _varnames_funcs) +def test_varnames(benchmark, func: object) -> None: + benchmark(varnames, func) + + +@pytest.mark.parametrize("func", _varnames_funcs) +def test_varnames_legacy(benchmark, func: object) -> None: + benchmark(_varnames_legacy, func) From 27debb83aefe7a38b8cd3bf53f961b9de7dc8282 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 29 Apr 2026 21:27:45 +0200 Subject: [PATCH 04/10] Fix typo in varnames docstring: 'keywrord' -> 'keyword' Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/pluggy/_hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index aa0d3df1..86cdcdf6 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -294,7 +294,7 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: - """Return tuple of positional and keywrord argument names for a function, + """Return tuple of positional and keyword argument names for a function, method, class or callable. In case of a class, its ``__init__`` method is considered. From 53ad320fa71af60d551cd7378bd685667f309adc Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 29 Apr 2026 21:28:19 +0200 Subject: [PATCH 05/10] Fix varnames to strip self for bound methods without dotted qualname A module-level function assigned to a class attribute becomes a bound method on instances, but its __qualname__ has no dot. Track whether the original callable was a bound method before unwrapping to __func__, so self is always stripped for bound methods. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/pluggy/_hooks.py | 9 ++++++--- testing/test_helpers.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 86cdcdf6..f3d62fc1 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -311,9 +311,11 @@ 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 + # Track bound methods before unwrapping, since __func__ loses that info. + is_bound = inspect.ismethod(func) func = inspect.unwrap(func) # type: ignore[arg-type] if inspect.ismethod(func): + is_bound = True func = func.__func__ try: @@ -335,8 +337,9 @@ def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: kwargs = () # Strip implicit instance arg (self/obj for methods) - if args and "." in qualname and args[0] in _IMPLICIT_NAMES: - args = args[1:] + if args and args[0] in _IMPLICIT_NAMES: + if is_bound or "." in qualname: + args = args[1:] return args, kwargs diff --git a/testing/test_helpers.py b/testing/test_helpers.py index 2567affd..878175f5 100644 --- a/testing/test_helpers.py +++ b/testing/test_helpers.py @@ -116,6 +116,20 @@ def example_method(self, x, y=1) -> None: assert varnames(ex_inst.example_method) == (("x",), ("y",)) +def test_varnames_bound_method_from_module_function() -> None: + """A module-level function assigned to a class attribute becomes a bound + method when accessed on an instance, but its __qualname__ has no dot. + varnames must still strip ``self``.""" + + def standalone(self, x) -> None: + pass # pragma: no cover + + class MyClass: + method = standalone + + assert varnames(MyClass().method) == (("x",), ()) + + def test_varnames_unresolvable_annotation() -> None: """Test that varnames works with annotations that cannot be resolved. From 76f8b266242d197c683361d23fc296fdbe6c4e16 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 29 Apr 2026 21:29:59 +0200 Subject: [PATCH 06/10] Simplify unresolvable annotation test to use direct function definition Replace exec()-based test with a direct function definition. The string annotation "NonExistentType" triggers the same behavior without the indirection. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- testing/test_helpers.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/testing/test_helpers.py b/testing/test_helpers.py index 878175f5..53f6f1d9 100644 --- a/testing/test_helpers.py +++ b/testing/test_helpers.py @@ -135,18 +135,13 @@ def test_varnames_unresolvable_annotation() -> None: 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. + importable. Using __code__ directly avoids this issue. """ - # 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"), ()) + + def func_with_bad_annotation( + x: "NonExistentType", # type: ignore[name-defined] # noqa: F821 + y, + ) -> None: + pass + + assert varnames(func_with_bad_annotation) == (("x", "y"), ()) From 80db10d3a6070b3250d015904a0631409c45bffe Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 29 Apr 2026 21:53:01 +0200 Subject: [PATCH 07/10] Add changelog fragment for #629 Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- changelog/629.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/629.bugfix.rst diff --git a/changelog/629.bugfix.rst b/changelog/629.bugfix.rst new file mode 100644 index 00000000..dbcc63e7 --- /dev/null +++ b/changelog/629.bugfix.rst @@ -0,0 +1 @@ +Fix ``varnames()`` to work with Python 3.14+ deferred annotations by using ``__code__`` introspection instead of ``inspect.signature()``, which fails when string annotations cannot be resolved at runtime. From 1deb49fd5a7fd439b0af6c57bad8841af6a5c70f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 1 May 2026 08:42:57 +0200 Subject: [PATCH 08/10] Improve varnames docstring: use 'parameter' and note keyword-only exclusion Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/pluggy/_hooks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index f3d62fc1..46318b53 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -294,9 +294,10 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: - """Return tuple of positional and keyword argument names for a function, + """Return tuple of positional and keyword parameter names for a function, method, class or callable. + Keyword-only parameter names are not included. In case of a class, its ``__init__`` method is considered. For methods the ``self`` parameter is not included. """ From a99f1e19cb697f70fe7464e366e107adb9b90dcd Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 1 May 2026 08:43:28 +0200 Subject: [PATCH 09/10] Reword changelog fragment to describe user-facing bug Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 Made-with: Cursor --- changelog/629.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/629.bugfix.rst b/changelog/629.bugfix.rst index dbcc63e7..88c96c1f 100644 --- a/changelog/629.bugfix.rst +++ b/changelog/629.bugfix.rst @@ -1 +1 @@ -Fix ``varnames()`` to work with Python 3.14+ deferred annotations by using ``__code__`` introspection instead of ``inspect.signature()``, which fails when string annotations cannot be resolved at runtime. +Fix hooks failing to register on Python 3.14+ when type annotations use forward references. From 006b245c49363e019112aa0fc89f61f3546471f1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 4 May 2026 12:54:09 +0200 Subject: [PATCH 10/10] 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 Co-authored-by: Anthropic Claude Opus 4 --- src/pluggy/_hooks.py | 60 ++++++++++++++++----- testing/benchmark.py | 9 ++-- testing/test_helpers.py | 107 ++++++++++++++++++++++++++++++++++++- testing/test_hookcaller.py | 1 + testing/test_warnings.py | 43 +++++++++++++++ 5 files changed, 200 insertions(+), 20 deletions(-) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 46318b53..d62dff19 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -288,24 +288,33 @@ def normalize_hookimpl_opts(opts: HookimplOpts) -> None: opts.setdefault("specname", 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",) +_PYPY = sys.implementation.name == "pypy" +_IMPLICIT_NAMES = ("self", "cls", "obj") if _PYPY else ("self", "cls") -def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: - """Return tuple of positional and keyword parameter names for a function, - method, class or callable. +def varnames( + func: object, *, legacy_noself: bool = False +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return tuple of positional and keyword parameter names for a callable. - Keyword-only parameter names are not included. In case of a class, its ``__init__`` method is considered. - For methods the ``self`` parameter is not included. + For bound methods, the already-bound first parameter is not included. + For unbound methods with a dotted ``__qualname__``, the first parameter is + stripped only if its name is a known implicit name (``self``, ``cls``). + Keyword-only parameters are not included. + + :param legacy_noself: + If ``True``, support hookspec classes whose methods omit ``self``. + When the function looks like a class method but has no implicit first + parameter, a :class:`FutureWarning` is emitted. """ + is_bound = False if inspect.isclass(func): try: func = func.__init__ except AttributeError: # pragma: no cover - pypy special case return (), () + is_bound = True elif not inspect.isroutine(func): # callable object? try: func = getattr(func, "__call__", func) @@ -313,7 +322,8 @@ def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: return (), () # Track bound methods before unwrapping, since __func__ loses that info. - is_bound = inspect.ismethod(func) + if inspect.ismethod(func): + is_bound = True func = inspect.unwrap(func) # type: ignore[arg-type] if inspect.ismethod(func): is_bound = True @@ -337,10 +347,27 @@ def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: else: kwargs = () - # Strip implicit instance arg (self/obj for methods) - if args and args[0] in _IMPLICIT_NAMES: - if is_bound or "." in qualname: + # Strip implicit instance/class arg. + # Check if this looks like a method defined in a class by examining the + # qualname after the last "." segment (if any). A remaining dot + # means it's a class method (e.g. "MyClass.method" or + # "func..MyClass.method"), not just a nested function. + _tail = qualname.rsplit(".", maxsplit=1)[-1] + _is_class_method = "." in _tail + if args: + if is_bound: args = args[1:] + elif _is_class_method and args[0] in _IMPLICIT_NAMES: + args = args[1:] + elif _is_class_method and legacy_noself: + warnings.warn( + f"{qualname} is a method but its first parameter" + f" {args[0]!r} is not 'self'." + f" Add 'self' as the first parameter or use @staticmethod." + f" This will become an error in a future version of pluggy.", + FutureWarning, + stacklevel=2, + ) return args, kwargs @@ -698,9 +725,14 @@ class HookSpec: def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None: self.namespace = namespace - self.function: Callable[..., object] = getattr(namespace, name) self.name = name - self.argnames, self.kwargnames = varnames(self.function) + self.function: Callable[..., object] = getattr(namespace, name) + legacy_noself = inspect.isclass(namespace) and not isinstance( + inspect.getattr_static(namespace, name), staticmethod + ) + self.argnames, self.kwargnames = varnames( + self.function, legacy_noself=legacy_noself + ) self.opts = opts self.warn_on_impl = opts.get("warn_on_impl") self.warn_on_impl_args = opts.get("warn_on_impl_args") diff --git a/testing/benchmark.py b/testing/benchmark.py index d672e5cf..81823edd 100644 --- a/testing/benchmark.py +++ b/testing/benchmark.py @@ -16,11 +16,9 @@ from pluggy._hooks import varnames -_PYPY = hasattr(sys, "pypy_version_info") - - def _varnames_legacy(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: - """Pre-PEP 649 implementation using inspect.signature for comparison.""" + """Pre-structural-detection implementation using inspect.signature and + name-based heuristics for comparison.""" if inspect.isclass(func): try: func = func.__init__ @@ -64,7 +62,8 @@ def _varnames_legacy(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: else: kwargs = () - if not _PYPY: + _pypy = hasattr(sys, "pypy_version_info") + if not _pypy: implicit_names: tuple[str, ...] = ("self",) else: implicit_names = ("self", "obj") diff --git a/testing/test_helpers.py b/testing/test_helpers.py index 53f6f1d9..c0d64b3e 100644 --- a/testing/test_helpers.py +++ b/testing/test_helpers.py @@ -112,14 +112,16 @@ def example_method(self, x, y=1) -> None: ex_inst = Example() assert varnames(example) == (("a",), ("b",)) + # Unbound: self is stripped because it's in _IMPLICIT_NAMES and qualname is dotted. assert varnames(Example.example_method) == (("x",), ("y",)) + # Bound: self is already consumed. assert varnames(ex_inst.example_method) == (("x",), ("y",)) def test_varnames_bound_method_from_module_function() -> None: """A module-level function assigned to a class attribute becomes a bound method when accessed on an instance, but its __qualname__ has no dot. - varnames must still strip ``self``.""" + varnames must still strip the first parameter.""" def standalone(self, x) -> None: pass # pragma: no cover @@ -130,6 +132,109 @@ class MyClass: assert varnames(MyClass().method) == (("x",), ()) +def test_varnames_unconventional_first_param_name() -> None: + """Bound methods strip unconditionally, but unbound methods with + non-standard first parameter names preserve all arguments.""" + + class MyClass: + def method(this, x) -> None: + pass # pragma: no cover + + # Bound: stripped regardless of name. + assert varnames(MyClass().method) == (("x",), ()) + # Unbound with dotted qualname but non-implicit name: NOT stripped. + assert varnames(MyClass.method) == (("this", "x"), ()) + + +def test_varnames_classmethod() -> None: + class MyClass: + @classmethod + def cm(cls, x, y=1) -> None: + pass # pragma: no cover + + # Classmethods are always bound (even from the class). + assert varnames(MyClass.cm) == (("x",), ("y",)) + assert varnames(MyClass().cm) == (("x",), ("y",)) + + +def test_varnames_staticmethod() -> None: + class MyClass: + @staticmethod + def sm(x, y=1) -> None: + pass # pragma: no cover + + # Staticmethods have no implicit first arg. + assert varnames(MyClass.sm) == (("x",), ("y",)) + assert varnames(MyClass().sm) == (("x",), ("y",)) + + +def test_varnames_hookspec_without_self() -> None: + """Hookspec-style class methods without self/cls preserve all parameters. + + This is the convention used by projects like pytest-timeout where hookspec + classes define methods without ``self`` since they serve as pure signatures. + By default varnames does not warn; the warning is emitted when + ``legacy_noself=True`` is passed (as HookSpec.__init__ does). + """ + + class MySpecs: + def my_hook(item, extra) -> None: + pass # pragma: no cover + + # Accessed as unbound: first arg is not an implicit name, keep it. + assert varnames(MySpecs.my_hook) == (("item", "extra"), ()) + # Accessed as bound (via instance): first arg is stripped. + assert varnames(MySpecs().my_hook) == (("extra",), ()) + + +def test_varnames_legacy_noself_warns() -> None: + """With ``legacy_noself=True``, varnames warns when it encounters a + class method whose first parameter is not an implicit name.""" + import warnings + + class MySpecs: + def my_hook(item, extra) -> None: + pass # pragma: no cover + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = varnames(MySpecs.my_hook, legacy_noself=True) + assert result == (("item", "extra"), ()) + assert len(w) == 1 + assert issubclass(w[0].category, FutureWarning) + assert "'item' is not 'self'" in str(w[0].message) + + +def test_varnames_legacy_noself_no_warn_with_self() -> None: + """With ``legacy_noself=True``, no warning when the method has ``self``.""" + import warnings + + class MySpecs: + def my_hook(self, item, extra) -> None: + pass # pragma: no cover + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = varnames(MySpecs.my_hook, legacy_noself=True) + assert result == (("item", "extra"), ()) + assert len(w) == 0 + + +def test_varnames_no_legacy_noself_no_warn() -> None: + """Without ``legacy_noself``, no warning even for class methods without self.""" + import warnings + + class MySpecs: + def my_hook(item, extra) -> None: + pass # pragma: no cover + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = varnames(MySpecs.my_hook) + assert result == (("item", "extra"), ()) + assert len(w) == 0 + + def test_varnames_unresolvable_annotation() -> None: """Test that varnames works with annotations that cannot be resolved. diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index f9855814..36dea7b7 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -301,6 +301,7 @@ def m13() -> None: ... ] +@pytest.mark.filterwarnings("ignore::FutureWarning") def test_hookspec(pm: PluginManager) -> None: class HookSpec: @hookspec() diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 4f5454be..e14e829f 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,4 +1,5 @@ from pathlib import Path +import warnings import pytest @@ -47,3 +48,45 @@ def my_hook(self): pm.hook.my_hook() assert len(wc.list) == 1 assert Path(wc.list[0].filename).name == "test_warnings.py" + + +def test_hookspec_missing_self_warns(pm: PluginManager) -> None: + """A hookspec defined as a method without ``self`` emits a FutureWarning.""" + + class Api: + @hookspec + def my_hook(item, extra): + pass + + with pytest.warns( + FutureWarning, + match=r"is a method but its first parameter 'item' is not 'self'", + ): + pm.add_hookspecs(Api) + + +def test_hookspec_with_self_no_warning(pm: PluginManager) -> None: + """A hookspec with ``self`` does not emit a FutureWarning.""" + + class Api: + @hookspec + def my_hook(self, item, extra): + pass + + with warnings.catch_warnings(): + warnings.simplefilter("error") + pm.add_hookspecs(Api) + + +def test_hookspec_staticmethod_no_warning(pm: PluginManager) -> None: + """A hookspec using @staticmethod does not emit a FutureWarning.""" + + class Api: + @staticmethod + @hookspec + def my_hook(item, extra) -> None: + pass + + with warnings.catch_warnings(): + warnings.simplefilter("error") + pm.add_hookspecs(Api)