Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/774.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/pluggy/_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,23 @@ def _multicall(
try:
args = [caller_kwargs[argname] for argname in hook_impl.argnames]
if hook_impl.kwargnames:
if hook_impl._posonlydefaults:
last_supplied = 0
for index, argname in enumerate(hook_impl._posonlydefaults, 1):
if argname in caller_kwargs:
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
if argname in caller_kwargs
and argname not in hook_impl._posonlydefaults
} or None
else:
kwargs = None
Expand Down
20 changes: 20 additions & 0 deletions src/pluggy/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +28,7 @@ class HookImpl:
"""A hook implementation in a :class:`HookCaller`."""

__slots__ = (
"_posonlydefaults",
"argnames",
"function",
"hookwrapper",
Expand Down Expand Up @@ -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.
Expand Down
131 changes: 131 additions & 0 deletions testing/test_posonly_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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", "class"])
@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

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)
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)]
Loading