Skip to content

Commit e674e8f

Browse files
Replace internal hook configuration with HookimplConfiguration
- Update HookimplMarker to store HookimplConfiguration objects directly - introduce PluginManager._parse_hookimplv to do most of the hook parsing - Convert parse_hookimpl_opts to compatibility shim that returns None (allow pytest to work) - Update test to use direct attribute access instead of dict .get() method - Eliminate redundant dict-to-config conversions in plugin registration - Remove HookImpl.opts deprecated attribute - Update benchmark tests to use hookimpl.get_hookconfig() directly - Eliminates internal HookimplOpts conversions while preserving public API - Add HookimplMarker.get_hookconfig() method that returns HookimplConfiguration directly - Update test files to use new method instead of accessing dynamic attributes - Fix mypy type errors in test_multicall.py and test_hookcaller.py 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 45a2620 commit e674e8f

File tree

6 files changed

+184
-55
lines changed

6 files changed

+184
-55
lines changed

src/pluggy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"HookCallError",
77
"HookspecOpts",
88
"HookimplOpts",
9+
"HookimplConfiguration",
910
"HookImpl",
1011
"HookRelay",
1112
"HookspecMarker",
@@ -16,6 +17,7 @@
1617
]
1718
from ._hooks import HookCaller
1819
from ._hooks import HookImpl
20+
from ._hooks import HookimplConfiguration
1921
from ._hooks import HookimplMarker
2022
from ._hooks import HookimplOpts
2123
from ._hooks import HookRelay

src/pluggy/_hooks.py

Lines changed: 109 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,83 @@ class HookimplOpts(TypedDict):
7474
specname: str | None
7575

7676

77+
@final
78+
class HookimplConfiguration:
79+
"""Configuration class for hook implementations.
80+
81+
This class is intended to replace HookimplOpts in future versions.
82+
It provides a more structured and extensible way to configure hook implementations.
83+
"""
84+
85+
__slots__ = (
86+
"wrapper",
87+
"hookwrapper",
88+
"optionalhook",
89+
"tryfirst",
90+
"trylast",
91+
"specname",
92+
)
93+
94+
def __init__(
95+
self,
96+
wrapper: bool = False,
97+
hookwrapper: bool = False,
98+
optionalhook: bool = False,
99+
tryfirst: bool = False,
100+
trylast: bool = False,
101+
specname: str | None = None,
102+
) -> None:
103+
"""Initialize hook implementation configuration.
104+
105+
:param wrapper:
106+
Whether the hook implementation is a :ref:`wrapper <hookwrapper>`.
107+
:param hookwrapper:
108+
Whether the hook implementation is an :ref:`old-style wrapper
109+
<old_style_hookwrappers>`.
110+
:param optionalhook:
111+
Whether validation against a hook specification is :ref:`optional
112+
<optionalhook>`.
113+
:param tryfirst:
114+
Whether to try to order this hook implementation :ref:`first
115+
<callorder>`.
116+
:param trylast:
117+
Whether to try to order this hook implementation :ref:`last
118+
<callorder>`.
119+
:param specname:
120+
The name of the hook specification to match, see :ref:`specname`.
121+
"""
122+
#: Whether the hook implementation is a :ref:`wrapper <hookwrapper>`.
123+
self.wrapper: Final = wrapper
124+
#: Whether the hook implementation is an :ref:`old-style wrapper
125+
#: <old_style_hookwrappers>`.
126+
self.hookwrapper: Final = hookwrapper
127+
#: Whether validation against a hook specification is :ref:`optional
128+
#: <optionalhook>`.
129+
self.optionalhook: Final = optionalhook
130+
#: Whether to try to order this hook implementation :ref:`first
131+
#: <callorder>`.
132+
self.tryfirst: Final = tryfirst
133+
#: Whether to try to order this hook implementation :ref:`last
134+
#: <callorder>`.
135+
self.trylast: Final = trylast
136+
#: The name of the hook specification to match, see :ref:`specname`.
137+
self.specname: Final = specname
138+
139+
@classmethod
140+
def from_opts(cls, opts: HookimplOpts) -> HookimplConfiguration:
141+
"""Create from HookimplOpts for backward compatibility."""
142+
return cls(**opts)
143+
144+
def __repr__(self) -> str:
145+
attrs = []
146+
for slot in self.__slots__:
147+
value = getattr(self, slot)
148+
if value:
149+
attrs.append(f"{slot}={value!r}")
150+
attrs_str = ", ".join(attrs)
151+
return f"HookimplConfiguration({attrs_str})"
152+
153+
77154
@final
78155
class HookspecMarker:
79156
"""Decorator for marking functions as hook specifications.
@@ -261,22 +338,33 @@ def __call__( # noqa: F811
261338
"""
262339

263340
def setattr_hookimpl_opts(func: _F) -> _F:
264-
opts: HookimplOpts = {
265-
"wrapper": wrapper,
266-
"hookwrapper": hookwrapper,
267-
"optionalhook": optionalhook,
268-
"tryfirst": tryfirst,
269-
"trylast": trylast,
270-
"specname": specname,
271-
}
272-
setattr(func, self.project_name + "_impl", opts)
341+
config = HookimplConfiguration(
342+
wrapper=wrapper,
343+
hookwrapper=hookwrapper,
344+
optionalhook=optionalhook,
345+
tryfirst=tryfirst,
346+
trylast=trylast,
347+
specname=specname,
348+
)
349+
setattr(func, self.project_name + "_impl", config)
273350
return func
274351

275352
if function is None:
276353
return setattr_hookimpl_opts
277354
else:
278355
return setattr_hookimpl_opts(function)
279356

357+
def get_hookconfig(self, func: Callable[..., object]) -> HookimplConfiguration:
358+
"""Extract hook implementation configuration from a decorated function.
359+
360+
:param func: A function decorated with this HookimplMarker
361+
:return: HookimplConfiguration object containing the hook implementation options
362+
:raises AttributeError: If the function is not decorated with this marker
363+
"""
364+
attr_name = self.project_name + "_impl"
365+
config: HookimplConfiguration = getattr(func, attr_name)
366+
return config
367+
280368

281369
def normalize_hookimpl_opts(opts: HookimplOpts) -> None:
282370
opts.setdefault("tryfirst", False)
@@ -548,17 +636,10 @@ def call_extra(
548636
"Cannot directly call a historic hook - use call_historic instead."
549637
)
550638
self._verify_all_args_are_provided(kwargs)
551-
opts: HookimplOpts = {
552-
"wrapper": False,
553-
"hookwrapper": False,
554-
"optionalhook": False,
555-
"trylast": False,
556-
"tryfirst": False,
557-
"specname": None,
558-
}
639+
config = HookimplConfiguration()
559640
hookimpls = self._hookimpls.copy()
560641
for method in methods:
561-
hookimpl = HookImpl(None, "<temp>", method, opts)
642+
hookimpl = HookImpl(None, "<temp>", method, config)
562643
# Find last non-tryfirst nonwrapper method.
563644
i = len(hookimpls) - 1
564645
while i >= 0 and (
@@ -642,21 +723,21 @@ class HookImpl:
642723
"argnames",
643724
"kwargnames",
644725
"plugin",
645-
"opts",
646726
"plugin_name",
647727
"wrapper",
648728
"hookwrapper",
649729
"optionalhook",
650730
"tryfirst",
651731
"trylast",
732+
"hookimpl_config",
652733
)
653734

654735
def __init__(
655736
self,
656737
plugin: _Plugin,
657738
plugin_name: str,
658739
function: _HookImplFunction[object],
659-
hook_impl_opts: HookimplOpts,
740+
hook_impl_config: HookimplConfiguration,
660741
) -> None:
661742
""":meta private:"""
662743
#: The hook implementation function.
@@ -668,24 +749,25 @@ def __init__(
668749
self.kwargnames: Final = kwargnames
669750
#: The plugin which defined this hook implementation.
670751
self.plugin: Final = plugin
671-
#: The :class:`HookimplOpts` used to configure this hook implementation.
672-
self.opts: Final = hook_impl_opts
752+
#: The :class:`HookimplConfiguration` used to configure this hook
753+
#: implementation.
754+
self.hookimpl_config: Final = hook_impl_config
673755
#: The name of the plugin which defined this hook implementation.
674756
self.plugin_name: Final = plugin_name
675757
#: Whether the hook implementation is a :ref:`wrapper <hookwrapper>`.
676-
self.wrapper: Final = hook_impl_opts["wrapper"]
758+
self.wrapper: Final = hook_impl_config.wrapper
677759
#: Whether the hook implementation is an :ref:`old-style wrapper
678760
#: <old_style_hookwrappers>`.
679-
self.hookwrapper: Final = hook_impl_opts["hookwrapper"]
761+
self.hookwrapper: Final = hook_impl_config.hookwrapper
680762
#: Whether validation against a hook specification is :ref:`optional
681763
#: <optionalhook>`.
682-
self.optionalhook: Final = hook_impl_opts["optionalhook"]
764+
self.optionalhook: Final = hook_impl_config.optionalhook
683765
#: Whether to try to order this hook implementation :ref:`first
684766
#: <callorder>`.
685-
self.tryfirst: Final = hook_impl_opts["tryfirst"]
767+
self.tryfirst: Final = hook_impl_config.tryfirst
686768
#: Whether to try to order this hook implementation :ref:`last
687769
#: <callorder>`.
688-
self.trylast: Final = hook_impl_opts["trylast"]
770+
self.trylast: Final = hook_impl_config.trylast
689771

690772
def __repr__(self) -> str:
691773
return f"<HookImpl plugin_name={self.plugin_name!r}, plugin={self.plugin!r}>"

src/pluggy/_manager.py

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ._hooks import _SubsetHookCaller
2121
from ._hooks import HookCaller
2222
from ._hooks import HookImpl
23+
from ._hooks import HookimplConfiguration
2324
from ._hooks import HookimplOpts
2425
from ._hooks import HookRelay
2526
from ._hooks import HookspecOpts
@@ -154,46 +155,80 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None:
154155

155156
# register matching hook implementations of the plugin
156157
for name in dir(plugin):
157-
hookimpl_opts = self.parse_hookimpl_opts(plugin, name)
158-
if hookimpl_opts is not None:
159-
normalize_hookimpl_opts(hookimpl_opts)
158+
hookimpl_config = self._parse_hookimpl(plugin, name)
159+
if hookimpl_config is not None:
160160
method: _HookImplFunction[object] = getattr(plugin, name)
161-
hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts)
162-
name = hookimpl_opts.get("specname") or name
163-
hook: HookCaller | None = getattr(self.hook, name, None)
161+
hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_config)
162+
hook_name = hookimpl_config.specname or name
163+
hook: HookCaller | None = getattr(self.hook, hook_name, None)
164164
if hook is None:
165-
hook = HookCaller(name, self._hookexec)
166-
setattr(self.hook, name, hook)
165+
hook = HookCaller(hook_name, self._hookexec)
166+
setattr(self.hook, hook_name, hook)
167167
elif hook.has_spec():
168168
self._verify_hook(hook, hookimpl)
169169
hook._maybe_apply_history(hookimpl)
170170
hook._add_hookimpl(hookimpl)
171171
return plugin_name
172172

173+
def _parse_hookimpl(
174+
self, plugin: _Plugin, name: str
175+
) -> HookimplConfiguration | None:
176+
"""Internal method to parse hook implementation configuration.
177+
178+
This method uses the new HookimplConfiguration type internally.
179+
Falls back to the legacy parse_hookimpl_opts method for compatibility.
180+
181+
:param plugin: The plugin object to inspect
182+
:param name: The attribute name to check for hook implementation
183+
:returns: HookimplConfiguration if found, None otherwise
184+
"""
185+
try:
186+
method: object = getattr(plugin, name)
187+
except Exception: # pragma: no cover
188+
return None
189+
190+
if not inspect.isroutine(method):
191+
return None
192+
193+
try:
194+
# Try to get hook implementation configuration directly
195+
impl_attr = getattr(method, self.project_name + "_impl", None)
196+
except Exception: # pragma: no cover
197+
impl_attr = None
198+
199+
if impl_attr is not None:
200+
# Check if it's already a HookimplConfiguration (new style)
201+
if isinstance(impl_attr, HookimplConfiguration):
202+
return impl_attr
203+
# Handle legacy dict-based configuration
204+
elif isinstance(impl_attr, dict):
205+
return HookimplConfiguration.from_opts(impl_attr) # type: ignore
206+
207+
# Fall back to legacy parse_hookimpl_opts for compatibility
208+
# (e.g. pytest override)
209+
legacy_opts = self.parse_hookimpl_opts(plugin, name)
210+
if legacy_opts is not None:
211+
normalize_hookimpl_opts(legacy_opts)
212+
return HookimplConfiguration.from_opts(legacy_opts)
213+
214+
return None
215+
173216
def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None:
174217
"""Try to obtain a hook implementation from an item with the given name
175218
in the given plugin which is being searched for hook impls.
176219
177220
:returns:
178221
The parsed hookimpl options, or None to skip the given item.
179222
180-
This method can be overridden by ``PluginManager`` subclasses to
181-
customize how hook implementation are picked up. By default, returns the
182-
options for items decorated with :class:`HookimplMarker`.
223+
.. deprecated::
224+
Customizing hook implementation parsing by overriding this method is
225+
deprecated. This method is only kept as a compatibility shim for
226+
legacy projects like pytest. New code should use the standard
227+
:class:`HookimplMarker` decorators.
183228
"""
184-
method: object = getattr(plugin, name)
185-
if not inspect.isroutine(method):
186-
return None
187-
try:
188-
res: HookimplOpts | None = getattr(
189-
method, self.project_name + "_impl", None
190-
)
191-
except Exception: # pragma: no cover
192-
res = {} # type: ignore[assignment] #pragma: no cover
193-
if res is not None and not isinstance(res, dict):
194-
# false positive
195-
res = None # type:ignore[unreachable] #pragma: no cover
196-
return res
229+
# Compatibility shim - only overridden by legacy projects like pytest
230+
# Modern hook implementations are handled by _parse_hookimpl
231+
return None
197232

198233
def unregister(
199234
self, plugin: _Plugin | None = None, name: str | None = None

testing/benchmark.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ def setup():
4242
hook_name = "foo"
4343
hook_impls = []
4444
for method in hooks + wrappers:
45-
f = HookImpl(None, "<temp>", method, method.example_impl)
45+
f = HookImpl(
46+
None,
47+
"<temp>",
48+
method,
49+
hookimpl.get_hookconfig(method),
50+
)
4651
hook_impls.append(f)
4752
caller_kwargs = {"arg1": 1, "arg2": 2, "arg3": 3}
4853
firstresult = False

testing/test_hookcaller.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ def wrap(func: FuncT) -> FuncT:
5050
wrapper=wrapper,
5151
)(func)
5252
self.hc._add_hookimpl(
53-
HookImpl(None, "<temp>", func, func.example_impl), # type: ignore[attr-defined]
53+
HookImpl(
54+
None,
55+
"<temp>",
56+
func,
57+
hookimpl.get_hookconfig(func),
58+
),
5459
)
5560
return func
5661

@@ -332,7 +337,7 @@ def he_myhook1(arg1) -> None:
332337
pass
333338

334339
if val:
335-
assert he_myhook1.example_impl.get(name)
340+
assert getattr(he_myhook1.example_impl, name)
336341
else:
337342
assert not hasattr(he_myhook1, name)
338343

testing/test_multicall.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def MC(
2424
caller = _multicall
2525
hookfuncs = []
2626
for method in methods:
27-
f = HookImpl(None, "<temp>", method, method.example_impl) # type: ignore[attr-defined]
27+
f = HookImpl(None, "<temp>", method, hookimpl.get_hookconfig(method))
2828
hookfuncs.append(f)
2929
return caller("foo", hookfuncs, kwargs, firstresult)
3030

0 commit comments

Comments
 (0)