Skip to content

Commit ddb9161

Browse files
committed
Make subset_hook_caller() return a proxy HookCaller instead of a standalone HookCaller
Fix #346. Fix #347.
1 parent 63b7e90 commit ddb9161

File tree

3 files changed

+89
-16
lines changed

3 files changed

+89
-16
lines changed

src/pluggy/_hooks.py

Lines changed: 48 additions & 0 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,
@@ -450,6 +451,53 @@ def _maybe_apply_history(self, method: "HookImpl") -> None:
450451
result_callback(res[0])
451452

452453

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+
453501
class HookImpl:
454502
__slots__ = (
455503
"function",

src/pluggy/_manager.py

Lines changed: 6 additions & 16 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,
@@ -436,24 +437,13 @@ def after(
436437
def subset_hook_caller(
437438
self, name: str, remove_plugins: Iterable[_Plugin]
438439
) -> _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."""
440+
"""Return a proxy :py:class:`._hooks._HookCaller` instance for the named
441+
method which manages calls to all registered plugins except the ones
442+
from remove_plugins."""
442443
orig: _HookCaller = getattr(self.hook, name)
443-
plugins_to_remove = [plug for plug in remove_plugins if hasattr(plug, name)]
444+
plugins_to_remove = {plug for plug in remove_plugins if hasattr(plug, name)}
444445
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
446+
return _SubsetHookCaller(orig, plugins_to_remove)
457447
return orig
458448

459449

testing/test_pluginmanager.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,39 @@ def he_method1(self, arg):
221221
assert out == [1, 10, 120, 12]
222222

223223

224+
def test_historic_with_subset_hook_caller(pm: PluginManager) -> None:
225+
class Hooks:
226+
@hookspec(historic=True)
227+
def he_method1(self, arg):
228+
...
229+
230+
pm.add_hookspecs(Hooks)
231+
232+
out = []
233+
234+
class Plugin:
235+
@hookimpl
236+
def he_method1(self, arg):
237+
out.append(arg)
238+
239+
plugin = Plugin()
240+
pm.register(plugin)
241+
242+
class Plugin2:
243+
@hookimpl
244+
def he_method1(self, arg):
245+
out.append(arg * 10)
246+
247+
shc = pm.subset_hook_caller("he_method1", remove_plugins=[plugin])
248+
shc.call_historic(kwargs=dict(arg=1))
249+
250+
pm.register(Plugin2())
251+
assert out == [10]
252+
253+
pm.register(Plugin())
254+
assert out == [10, 1]
255+
256+
224257
@pytest.mark.parametrize("result_callback", [True, False])
225258
def test_with_result_memorized(pm: PluginManager, result_callback: bool) -> None:
226259
"""Verify that ``_HookCaller._maybe_apply_history()`
@@ -400,6 +433,8 @@ class PluginNo:
400433
pm.hook.he_method1(arg=1)
401434
assert out == [10]
402435

436+
assert repr(hc) == "<_SubsetHookCaller 'he_method1'>"
437+
403438

404439
def test_get_hookimpls(pm: PluginManager) -> None:
405440
class Hooks:

0 commit comments

Comments
 (0)