Skip to content
Closed
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/794.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Restore discovery of class-valued hook specifications, such as those used by
ScanCode's PluginCode, without evaluating descriptors. Read constructor defaults
when a class supplies the specification signature.
5 changes: 4 additions & 1 deletion src/pluggy/_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,10 @@ def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None
self.argnames, self.kwargnames = varnames(
self.function, legacy_noself=legacy_noself
)
defaults = inspect.unwrap(self.function).__defaults__
function = self.function
if inspect.isclass(function):
function = function.__init__
defaults = getattr(inspect.unwrap(function), "__defaults__", None)
self.kwargdefaults = dict(zip(self.kwargnames, defaults or ()))
self.opts = opts
self.warn_on_impl = opts.get("warn_on_impl")
Expand Down
11 changes: 8 additions & 3 deletions src/pluggy/_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,14 +383,19 @@ def parse_hookspec_opts(

Discovery uses :func:`inspect.getattr_static` so properties and other
descriptors are not executed. Only functions, classmethods,
staticmethods, and bound methods are considered.
staticmethods, bound methods, and class-valued specifications are
considered.
"""
found = _static_hook_attr(module_or_class, name)
if found is None:
return None
holder = inspect.getattr_static(module_or_class, name, None)
if not inspect.isclass(holder):
return None
else:
holder = found[0]
return cast(
HookspecOpts | None,
_get_marker_opts(found[0], self.project_name + "_spec"),
_get_marker_opts(holder, self.project_name + "_spec"),
)

def get_plugins(self) -> set[Any]:
Expand Down
103 changes: 103 additions & 0 deletions testing/test_class_hookspec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from functools import wraps
import types
from typing import Any

import pytest

from pluggy import HookimplMarker
from pluggy import HookspecMarker
from pluggy import PluginManager


hookspec = HookspecMarker("example")
hookimpl = HookimplMarker("example")


@pytest.mark.parametrize("namespace_kind", ["module", "class", "instance"])
@pytest.mark.parametrize(
"constructor", ["implicit", "required", "default", "inherited", "wrapped"]
)
def test_class_valued_hookspec(namespace_kind: str, constructor: str) -> None:
class Implicit:
pass

class Required:
def __init__(self, value: object) -> None:
raise AssertionError("constructor executed") # pragma: no cover

class Default:
def __init__(self, value: object = "fallback") -> None:
raise AssertionError("constructor executed") # pragma: no cover

class Inherited(Default):
pass

class Wrapped:
@wraps(Default.__init__) # type: ignore[misc]
def __init__(self, *args: object, **kwargs: object) -> None:
raise AssertionError("constructor executed") # pragma: no cover

specification = hookspec(
{
"implicit": Implicit,
"required": Required,
"default": Default,
"inherited": Inherited,
"wrapped": Wrapped,
}[constructor]
)
namespace: Any
if namespace_kind == "module":
namespace = types.ModuleType("specification")
namespace.hook = specification
else:
namespace_class = type("Specification", (), {"hook": specification})
namespace = namespace_class if namespace_kind == "class" else namespace_class()
pm = PluginManager("example")
pm.add_hookspecs(namespace)
spec = pm.hook.hook.spec
assert spec is not None
assert spec.function is specification
assert spec.argnames == (("value",) if constructor == "required" else ())
assert spec.kwargnames == (
() if constructor in ("implicit", "required") else ("value",)
)
assert spec.kwargdefaults == (
{} if constructor in ("implicit", "required") else {"value": "fallback"}
)

plugin: Any = types.ModuleType("implementation")
if constructor == "implicit":

@hookimpl
def empty_hook() -> str:
return "control"

plugin.hook = empty_hook
pm.register(plugin)
assert pm.hook.hook() == ["control"]
else:

@hookimpl
def value_hook(value: object) -> object:
return value

plugin.hook = value_hook
pm.register(plugin)
assert pm.hook.hook(value="caller") == ["caller"]
if constructor != "required":
assert pm.hook.hook() == ["fallback"]


def test_class_valued_hookimpl_is_still_ignored() -> None:
plugin: Any = types.ModuleType("implementation")

@hookimpl
class Implementation:
pass

plugin.hook = Implementation
pm = PluginManager("example")
assert pm.parse_hookimpl_opts(plugin, "hook") is None
pm.register(plugin)
assert pm.get_hookcallers(plugin) == []
Loading