Skip to content

Commit 2304a0f

Browse files
authored
Merge pull request #348 from bluetech/subset-caller-6
Improve subset hook caller & related changes
2 parents 2b9998a + 12ea858 commit 2304a0f

File tree

5 files changed

+330
-76
lines changed

5 files changed

+330
-76
lines changed

src/pluggy/_hooks.py

Lines changed: 73 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import warnings
77
from types import ModuleType
88
from typing import (
9+
AbstractSet,
910
Any,
1011
Callable,
1112
Generator,
@@ -286,8 +287,7 @@ class _HookCaller:
286287
"name",
287288
"spec",
288289
"_hookexec",
289-
"_wrappers",
290-
"_nonwrappers",
290+
"_hookimpls",
291291
"_call_history",
292292
)
293293

@@ -300,8 +300,7 @@ def __init__(
300300
) -> None:
301301
self.name: "Final" = name
302302
self._hookexec: "Final" = hook_execute
303-
self._wrappers: "Final[List[HookImpl]]" = []
304-
self._nonwrappers: "Final[List[HookImpl]]" = []
303+
self._hookimpls: "Final[List[HookImpl]]" = []
305304
self._call_history: Optional[_CallHistory] = None
306305
self.spec: Optional[HookSpec] = None
307306
if specmodule_or_class is not None:
@@ -325,38 +324,38 @@ def is_historic(self) -> bool:
325324
return self._call_history is not None
326325

327326
def _remove_plugin(self, plugin: _Plugin) -> None:
328-
def remove(wrappers: List[HookImpl]) -> Optional[bool]:
329-
for i, method in enumerate(wrappers):
330-
if method.plugin == plugin:
331-
del wrappers[i]
332-
return True
333-
return None
334-
335-
if remove(self._wrappers) is None:
336-
if remove(self._nonwrappers) is None:
337-
raise ValueError(f"plugin {plugin!r} not found")
327+
for i, method in enumerate(self._hookimpls):
328+
if method.plugin == plugin:
329+
del self._hookimpls[i]
330+
return
331+
raise ValueError(f"plugin {plugin!r} not found")
338332

339333
def get_hookimpls(self) -> List["HookImpl"]:
340-
# Order is important for _hookexec
341-
return self._nonwrappers + self._wrappers
334+
return self._hookimpls.copy()
342335

343336
def _add_hookimpl(self, hookimpl: "HookImpl") -> None:
344337
"""Add an implementation to the callback chain."""
338+
for i, method in enumerate(self._hookimpls):
339+
if method.hookwrapper:
340+
splitpoint = i
341+
break
342+
else:
343+
splitpoint = len(self._hookimpls)
345344
if hookimpl.hookwrapper:
346-
methods = self._wrappers
345+
start, end = splitpoint, len(self._hookimpls)
347346
else:
348-
methods = self._nonwrappers
347+
start, end = 0, splitpoint
349348

350349
if hookimpl.trylast:
351-
methods.insert(0, hookimpl)
350+
self._hookimpls.insert(start, hookimpl)
352351
elif hookimpl.tryfirst:
353-
methods.append(hookimpl)
352+
self._hookimpls.insert(end, hookimpl)
354353
else:
355354
# find last non-tryfirst method
356-
i = len(methods) - 1
357-
while i >= 0 and methods[i].tryfirst:
355+
i = end - 1
356+
while i >= start and self._hookimpls[i].tryfirst:
358357
i -= 1
359-
methods.insert(i + 1, hookimpl)
358+
self._hookimpls.insert(i + 1, hookimpl)
360359

361360
def __repr__(self) -> str:
362361
return f"<_HookCaller {self.name!r}>"
@@ -387,7 +386,7 @@ def __call__(self, *args: object, **kwargs: object) -> Any:
387386
), "Cannot directly call a historic hook - use call_historic instead."
388387
self._verify_all_args_are_provided(kwargs)
389388
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
390-
return self._hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
389+
return self._hookexec(self.name, self._hookimpls, kwargs, firstresult)
391390

392391
def call_historic(
393392
self,
@@ -406,7 +405,7 @@ def call_historic(
406405
self._call_history.append((kwargs, result_callback))
407406
# Historizing hooks don't return results.
408407
# Remember firstresult isn't compatible with historic.
409-
res = self._hookexec(self.name, self.get_hookimpls(), kwargs, False)
408+
res = self._hookexec(self.name, self._hookimpls, kwargs, False)
410409
if result_callback is None:
411410
return
412411
if isinstance(res, list):
@@ -429,13 +428,12 @@ def call_extra(
429428
"tryfirst": False,
430429
"specname": None,
431430
}
432-
hookimpls = self.get_hookimpls()
431+
hookimpls = self._hookimpls.copy()
433432
for method in methods:
434433
hookimpl = HookImpl(None, "<temp>", method, opts)
435434
# Find last non-tryfirst nonwrapper method.
436435
i = len(hookimpls) - 1
437-
until = len(self._nonwrappers)
438-
while i >= until and hookimpls[i].tryfirst:
436+
while i >= 0 and hookimpls[i].tryfirst and not hookimpls[i].hookwrapper:
439437
i -= 1
440438
hookimpls.insert(i + 1, hookimpl)
441439
firstresult = self.spec.opts.get("firstresult", False) if self.spec else False
@@ -453,6 +451,53 @@ def _maybe_apply_history(self, method: "HookImpl") -> None:
453451
result_callback(res[0])
454452

455453

454+
class _SubsetHookCaller(_HookCaller):
455+
"""A proxy to another HookCaller which manages calls to all registered
456+
plugins except the ones from remove_plugins."""
457+
458+
# This class is unusual: in inhertits from `_HookCaller` so all of
459+
# the *code* runs in the class, but it delegates all underlying *data*
460+
# to the original HookCaller.
461+
# `subset_hook_caller` used to be implemented by creating a full-fledged
462+
# HookCaller, copying all hookimpls from the original. This had problems
463+
# with memory leaks (#346) and historic calls (#347), which make a proxy
464+
# approach better.
465+
# An alternative implementation is to use a `_getattr__`/`__getattribute__`
466+
# proxy, however that adds more overhead and is more tricky to implement.
467+
468+
__slots__ = (
469+
"_orig",
470+
"_remove_plugins",
471+
"name",
472+
"_hookexec",
473+
)
474+
475+
def __init__(self, orig: _HookCaller, remove_plugins: AbstractSet[_Plugin]) -> None:
476+
self._orig = orig
477+
self._remove_plugins = remove_plugins
478+
self.name = orig.name # type: ignore[misc]
479+
self._hookexec = orig._hookexec # type: ignore[misc]
480+
481+
@property # type: ignore[misc]
482+
def _hookimpls(self) -> List["HookImpl"]: # type: ignore[override]
483+
return [
484+
impl
485+
for impl in self._orig._hookimpls
486+
if impl.plugin not in self._remove_plugins
487+
]
488+
489+
@property
490+
def spec(self) -> Optional["HookSpec"]: # type: ignore[override]
491+
return self._orig.spec
492+
493+
@property
494+
def _call_history(self) -> Optional[_CallHistory]: # type: ignore[override]
495+
return self._orig._call_history
496+
497+
def __repr__(self) -> str:
498+
return f"<_SubsetHookCaller {self.name!r}>"
499+
500+
456501
class HookImpl:
457502
__slots__ = (
458503
"function",

src/pluggy/_manager.py

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
HookImpl,
2626
HookSpec,
2727
_HookCaller,
28+
_SubsetHookCaller,
2829
_HookImplFunction,
2930
_HookRelay,
3031
_Namespace,
@@ -114,7 +115,6 @@ class PluginManager:
114115
def __init__(self, project_name: str) -> None:
115116
self.project_name: "Final" = project_name
116117
self._name2plugin: "Final[Dict[str, _Plugin]]" = {}
117-
self._plugin2hookcallers: "Final[Dict[_Plugin, List[_HookCaller]]]" = {}
118118
self._plugin_distinfo: "Final[List[Tuple[_Plugin, DistFacade]]]" = []
119119
self.trace: "Final" = _tracing.TagTracer().get("pluginmanage")
120120
self.hook: "Final" = _HookRelay()
@@ -137,11 +137,17 @@ def register(self, plugin: _Plugin, name: Optional[str] = None) -> Optional[str]
137137
is already registered."""
138138
plugin_name = name or self.get_canonical_name(plugin)
139139

140-
if plugin_name in self._name2plugin or plugin in self._plugin2hookcallers:
140+
if plugin_name in self._name2plugin:
141141
if self._name2plugin.get(plugin_name, -1) is None:
142142
return None # blocked plugin, return None to indicate no registration
143143
raise ValueError(
144-
"Plugin already registered: %s=%s\n%s"
144+
"Plugin name already registered: %s=%s\n%s"
145+
% (plugin_name, plugin, self._name2plugin)
146+
)
147+
148+
if plugin in self._name2plugin.values():
149+
raise ValueError(
150+
"Plugin already registered under a different name: %s=%s\n%s"
145151
% (plugin_name, plugin, self._name2plugin)
146152
)
147153

@@ -150,8 +156,6 @@ def register(self, plugin: _Plugin, name: Optional[str] = None) -> Optional[str]
150156
self._name2plugin[plugin_name] = plugin
151157

152158
# register matching hook implementations of the plugin
153-
hookcallers: List[_HookCaller] = []
154-
self._plugin2hookcallers[plugin] = hookcallers
155159
for name in dir(plugin):
156160
hookimpl_opts = self.parse_hookimpl_opts(plugin, name)
157161
if hookimpl_opts is not None:
@@ -167,7 +171,6 @@ def register(self, plugin: _Plugin, name: Optional[str] = None) -> Optional[str]
167171
self._verify_hook(hook, hookimpl)
168172
hook._maybe_apply_history(hookimpl)
169173
hook._add_hookimpl(hookimpl)
170-
hookcallers.append(hook)
171174
return plugin_name
172175

173176
def parse_hookimpl_opts(
@@ -200,14 +203,16 @@ def unregister(
200203
if plugin is None:
201204
plugin = self.get_plugin(name)
202205

206+
hookcallers = self.get_hookcallers(plugin)
207+
if hookcallers:
208+
for hookcaller in hookcallers:
209+
hookcaller._remove_plugin(plugin)
210+
203211
# if self._name2plugin[name] == None registration was blocked: ignore
204212
if self._name2plugin.get(name):
205213
assert name is not None
206214
del self._name2plugin[name]
207215

208-
for hookcaller in self._plugin2hookcallers.pop(plugin, []):
209-
hookcaller._remove_plugin(plugin)
210-
211216
return plugin
212217

213218
def set_blocked(self, name: str) -> None:
@@ -253,11 +258,11 @@ def parse_hookspec_opts(
253258

254259
def get_plugins(self) -> Set[Any]:
255260
"""return the set of registered plugins."""
256-
return set(self._plugin2hookcallers)
261+
return set(self._name2plugin.values())
257262

258263
def is_registered(self, plugin: _Plugin) -> bool:
259264
"""Return ``True`` if the plugin is already registered."""
260-
return plugin in self._plugin2hookcallers
265+
return any(plugin == val for val in self._name2plugin.values())
261266

262267
def get_canonical_name(self, plugin: _Plugin) -> str:
263268
"""Return canonical name for a plugin object. Note that a plugin
@@ -372,7 +377,14 @@ def list_name_plugin(self) -> List[Tuple[str, _Plugin]]:
372377

373378
def get_hookcallers(self, plugin: _Plugin) -> Optional[List[_HookCaller]]:
374379
"""get all hook callers for the specified plugin."""
375-
return self._plugin2hookcallers.get(plugin)
380+
if self.get_name(plugin) is None:
381+
return None
382+
hookcallers = []
383+
for hookcaller in self.hook.__dict__.values():
384+
for hookimpl in hookcaller.get_hookimpls():
385+
if hookimpl.plugin is plugin:
386+
hookcallers.append(hookcaller)
387+
return hookcallers
376388

377389
def add_hookcall_monitoring(
378390
self, before: _BeforeTrace, after: _AfterTrace
@@ -436,24 +448,13 @@ def after(
436448
def subset_hook_caller(
437449
self, name: str, remove_plugins: Iterable[_Plugin]
438450
) -> _HookCaller:
439-
"""Return a new :py:class:`._hooks._HookCaller` instance for the named method
440-
which manages calls to all registered plugins except the
441-
ones from remove_plugins."""
451+
"""Return a proxy :py:class:`._hooks._HookCaller` instance for the named
452+
method which manages calls to all registered plugins except the ones
453+
from remove_plugins."""
442454
orig: _HookCaller = getattr(self.hook, name)
443-
plugins_to_remove = [plug for plug in remove_plugins if hasattr(plug, name)]
455+
plugins_to_remove = {plug for plug in remove_plugins if hasattr(plug, name)}
444456
if plugins_to_remove:
445-
assert orig.spec is not None
446-
hc = _HookCaller(
447-
orig.name, orig._hookexec, orig.spec.namespace, orig.spec.opts
448-
)
449-
for hookimpl in orig.get_hookimpls():
450-
plugin = hookimpl.plugin
451-
if plugin not in plugins_to_remove:
452-
hc._add_hookimpl(hookimpl)
453-
# we also keep track of this hook caller so it
454-
# gets properly removed on plugin unregistration
455-
self._plugin2hookcallers.setdefault(plugin, []).append(hc)
456-
return hc
457+
return _SubsetHookCaller(orig, plugins_to_remove)
457458
return orig
458459

459460

testing/test_details.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,18 @@ def x1meth2(self):
3434
pm = MyPluginManager(hookspec.project_name)
3535
pm.register(Plugin())
3636
pm.add_hookspecs(Spec)
37-
assert not pm.hook.x1meth._nonwrappers[0].hookwrapper
38-
assert not pm.hook.x1meth._nonwrappers[0].tryfirst
39-
assert not pm.hook.x1meth._nonwrappers[0].trylast
40-
assert not pm.hook.x1meth._nonwrappers[0].optionalhook
4137

42-
assert pm.hook.x1meth2._wrappers[0].tryfirst
43-
assert pm.hook.x1meth2._wrappers[0].hookwrapper
38+
hookimpls = pm.hook.x1meth.get_hookimpls()
39+
assert len(hookimpls) == 1
40+
assert not hookimpls[0].hookwrapper
41+
assert not hookimpls[0].tryfirst
42+
assert not hookimpls[0].trylast
43+
assert not hookimpls[0].optionalhook
44+
45+
hookimpls = pm.hook.x1meth2.get_hookimpls()
46+
assert len(hookimpls) == 1
47+
assert hookimpls[0].hookwrapper
48+
assert hookimpls[0].tryfirst
4449

4550

4651
def test_warn_when_deprecated_specified(recwarn) -> None:
@@ -127,6 +132,6 @@ def myhook(self):
127132

128133
plugin = Plugin()
129134
pname = pm.register(plugin)
130-
assert repr(pm.hook.myhook._nonwrappers[0]) == (
135+
assert repr(pm.hook.myhook.get_hookimpls()[0]) == (
131136
f"<HookImpl plugin_name={pname!r}, plugin={plugin!r}>"
132137
)

0 commit comments

Comments
 (0)