From b40da5452f3bbf702ebde2489af1e8db90f5edb7 Mon Sep 17 00:00:00 2001 From: Xucheng Zhou Date: Sun, 11 Oct 2026 08:00:35 +0800 Subject: [PATCH 1/2] Deliver optional positional-only hook arguments positionally Fixes #774 Assisted-by: OpenAI Codex Signed-off-by: Xucheng Zhou --- changelog/774.bugfix.rst | 3 + src/pluggy/_execution.py | 12 +++ src/pluggy/_impl.py | 20 +++++ testing/test_posonly_defaults.py | 122 +++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 changelog/774.bugfix.rst create mode 100644 testing/test_posonly_defaults.py diff --git a/changelog/774.bugfix.rst b/changelog/774.bugfix.rst new file mode 100644 index 00000000..1d5a2219 --- /dev/null +++ b/changelog/774.bugfix.rst @@ -0,0 +1,3 @@ +Deliver defaulted positional-only hook implementation arguments positionally, +including in hook wrappers and ``call_extra``. Explicit hook call values and +hookspec defaults keep their precedence over implementation defaults. diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index 0e62b4d3..c7756212 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -106,10 +106,22 @@ def _multicall( try: args = [caller_kwargs[argname] for argname in hook_impl.argnames] if hook_impl.kwargnames: + if hook_impl._posonlydefaults: + last_supplied = None + for argname in hook_impl._posonlydefaults: + if argname in caller_kwargs: + last_supplied = argname + if last_supplied is not None: + # Fill gaps, but leave trailing defaults implicit. + for argname, default in hook_impl._posonlydefaults.items(): + args.append(caller_kwargs.get(argname, default)) + if argname == last_supplied: + break kwargs = { argname: caller_kwargs[argname] for argname in hook_impl.kwargnames if argname in caller_kwargs + and argname not in hook_impl._posonlydefaults } or None else: kwargs = None diff --git a/src/pluggy/_impl.py b/src/pluggy/_impl.py index 1b7182f1..f96b5d89 100644 --- a/src/pluggy/_impl.py +++ b/src/pluggy/_impl.py @@ -6,6 +6,7 @@ from collections.abc import Callable from collections.abc import Generator +import inspect from typing import Final from typing import final from typing import TypeAlias @@ -27,6 +28,7 @@ class HookImpl: """A hook implementation in a :class:`HookCaller`.""" __slots__ = ( + "_posonlydefaults", "argnames", "function", "hookwrapper", @@ -56,6 +58,24 @@ def __init__( #: The keyword parameter names of ``function`` which declare defaults. #: Keyword-only parameters are *not* included. self.kwargnames: Final = kwargnames + # Optional positional-only arguments still need positional delivery. + # Inspect the same underlying callable as varnames, without evaluating + # annotations or requiring an inspect.Signature on the hot call path. + func = self.function + if inspect.isclass(func): + func = func.__init__ + elif not inspect.isroutine(func): + func = getattr(func, "__call__", func) # noqa: B004 + func = inspect.unwrap(func) + code = getattr(func, "__code__", None) + posonly = () if code is None else code.co_varnames[: code.co_posonlyargcount] + self._posonlydefaults: Final = { + name: default + for name, default in zip( + kwargnames, getattr(func, "__defaults__", ()) or () + ) + if name in posonly + } #: The plugin which defined this hook implementation. self.plugin: Final = plugin #: The :class:`HookimplOpts` used to configure this hook implementation. diff --git a/testing/test_posonly_defaults.py b/testing/test_posonly_defaults.py new file mode 100644 index 00000000..1eaf64a2 --- /dev/null +++ b/testing/test_posonly_defaults.py @@ -0,0 +1,122 @@ +from collections.abc import Callable +from functools import wraps + +import pytest + +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import PluginManager + + +hookspec = HookspecMarker("posonly") +hookimpl = HookimplMarker("posonly") + + +@pytest.mark.parametrize("style", ["plain", "wrapper", "hookwrapper"]) +@pytest.mark.parametrize("supplied", [False, True]) +def test_defaulted_positional_only_delivery(style, supplied): + class Spec: + @hookspec + def hello(self, value): + pass + + class Plain: + @hookimpl + def hello(self, value="fallback", /): + return value + + class Wrapper: + @hookimpl(wrapper=True) + def hello(self, value="fallback", /): + yield + return value + + class Hookwrapper: + @hookimpl(hookwrapper=True) + def hello(self, value="fallback", /): + outcome = yield + outcome.force_result(value) + + manager = PluginManager("posonly") + manager.add_hookspecs(Spec) + manager.register( + {"plain": Plain, "wrapper": Wrapper, "hookwrapper": Hookwrapper}[style]() + ) + if supplied: + result = manager.hook.hello(value="caller") + assert result == (["caller"] if style == "plain" else "caller") + else: + with pytest.warns(UserWarning): + result = manager.hook.hello() + assert result == (["fallback"] if style == "plain" else "fallback") + + +@pytest.mark.parametrize("kind", ["method", "callable", "decorated"]) +@pytest.mark.parametrize( + "kwargs, expected", + [ + ({}, (1, 2, 3)), + ({"first": 10}, (10, 2, 3)), + ({"second": 20}, (1, 20, 3)), + ({"tail": 30}, (1, 2, 30)), + ({"first": None, "second": 0, "tail": False}, (None, 0, False)), + ], +) +def test_positional_only_default_gaps(kind, kwargs, expected): + positional_calls = [] + + class Spec: + @hookspec + def hello(self): + pass + + def decorate(function: Callable[..., object]) -> Callable[..., object]: + @wraps(function) + def wrapped(*args, **kwargs): + positional_calls.append(args) + return function(*args, **kwargs) + + return wrapped + + class Plugin: + @hookimpl + def hello(self, first=1, second=2, /, tail=3): + return first, second, tail + + class CallablePlugin: + def __call__(self, first=1, second=2, /, tail=3): + return first, second, tail + + manager = PluginManager("posonly") + manager.add_hookspecs(Spec) + if kind == "method": + manager.register(Plugin()) + result = manager.hook.hello(**kwargs) + else: + function = CallablePlugin() if kind == "callable" else decorate(Plugin().hello) + result = manager.hook.hello.call_extra([function], kwargs) + assert result == [expected] + if kind == "decorated": + if "second" in kwargs: + assert positional_calls == [expected[:2]] + elif "first" in kwargs: + assert positional_calls == [expected[:1]] + else: + assert positional_calls == [()] + + +def test_required_positional_only_control(): + class Spec: + @hookspec + def hello(self, first, second=20): + pass + + class Plugin: + @hookimpl + def hello(self, first, second=2, /, tail=3): + return first, second, tail + + manager = PluginManager("posonly") + manager.add_hookspecs(Spec) + manager.register(Plugin()) + assert manager.hook.hello(first=10) == [(10, 20, 3)] From dcfb4601a1a11d6ce03ce946fb5174146afab1fa Mon Sep 17 00:00:00 2001 From: Xucheng Zhou Date: Sun, 11 Oct 2026 08:07:55 +0800 Subject: [PATCH 2/2] Cover class call_extra and avoid unreachable binding branch Assisted-by: OpenAI Codex Signed-off-by: Xucheng Zhou --- src/pluggy/_execution.py | 19 ++++++++++--------- testing/test_posonly_defaults.py | 11 ++++++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index c7756212..b11d0282 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -107,16 +107,17 @@ def _multicall( args = [caller_kwargs[argname] for argname in hook_impl.argnames] if hook_impl.kwargnames: if hook_impl._posonlydefaults: - last_supplied = None - for argname in hook_impl._posonlydefaults: + last_supplied = 0 + for index, argname in enumerate(hook_impl._posonlydefaults, 1): if argname in caller_kwargs: - last_supplied = argname - if last_supplied is not None: - # Fill gaps, but leave trailing defaults implicit. - for argname, default in hook_impl._posonlydefaults.items(): - args.append(caller_kwargs.get(argname, default)) - if argname == last_supplied: - break + last_supplied = index + # Fill gaps, but leave trailing defaults implicit. + args.extend( + caller_kwargs.get(argname, default) + for argname, default in list( + hook_impl._posonlydefaults.items() + )[:last_supplied] + ) kwargs = { argname: caller_kwargs[argname] for argname in hook_impl.kwargnames diff --git a/testing/test_posonly_defaults.py b/testing/test_posonly_defaults.py index 1eaf64a2..3b2bca7d 100644 --- a/testing/test_posonly_defaults.py +++ b/testing/test_posonly_defaults.py @@ -51,7 +51,7 @@ def hello(self, value="fallback", /): assert result == (["fallback"] if style == "plain" else "fallback") -@pytest.mark.parametrize("kind", ["method", "callable", "decorated"]) +@pytest.mark.parametrize("kind", ["method", "callable", "decorated", "class"]) @pytest.mark.parametrize( "kwargs, expected", [ @@ -87,11 +87,20 @@ class CallablePlugin: def __call__(self, first=1, second=2, /, tail=3): return first, second, tail + class ClassPlugin: + def __init__(self, first=1, second=2, /, tail=3): + self.values = first, second, tail + manager = PluginManager("posonly") manager.add_hookspecs(Spec) if kind == "method": manager.register(Plugin()) result = manager.hook.hello(**kwargs) + elif kind == "class": + result = manager.hook.hello.call_extra([ClassPlugin], kwargs) + assert isinstance(result, list) + assert isinstance(result[0], ClassPlugin) + result = [result[0].values] else: function = CallablePlugin() if kind == "callable" else decorate(Plugin().hello) result = manager.hook.hello.call_extra([function], kwargs)