Skip to content

Commit 73dfa45

Browse files
Refactor HookCaller class tree with protocol-based architecture
- Create HookCaller protocol defining public interface for all hook callers - Rename HookCaller to NormalHookCaller for non-historic hook handling - Make HistoricHookCaller standalone (no inheritance) - Rename _SubsetHookCaller to SubsetHookCaller and make standalone - Delegate argument verification to HookSpec.verify_all_args_are_provided() - SubsetHookCaller now maintains same invariants as proxied hook caller: - Historic: only allows call_historic(), blocks __call__() and call_extra() - Normal: allows __call__() and call_extra(), blocks call_historic() - Both: block set_specification() as read-only proxies - Update type hints throughout codebase to use protocol interface - Maintain backward compatibility with _HookCaller and _SubsetHookCaller aliases All tests pass (142/142) and code quality checks pass. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent c0f8b86 commit 73dfa45

File tree

4 files changed

+301
-89
lines changed

4 files changed

+301
-89
lines changed

src/pluggy/_hooks.py

Lines changed: 169 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from typing import final
1818
from typing import Optional
1919
from typing import overload
20+
from typing import Protocol
21+
from typing import runtime_checkable
2022
from typing import TYPE_CHECKING
2123
from typing import TypedDict
2224
from typing import TypeVar
@@ -38,6 +40,66 @@
3840
_HookImplFunction = Callable[..., Union[_T, Generator[None, Result[_T], None]]]
3941

4042

43+
@runtime_checkable
44+
class HookCaller(Protocol):
45+
"""Protocol defining the interface for hook callers."""
46+
47+
@property
48+
def name(self) -> str:
49+
"""Name of the hook getting called."""
50+
...
51+
52+
@property
53+
def spec(self) -> HookSpec | None:
54+
"""The hook specification, if any."""
55+
...
56+
57+
def has_spec(self) -> bool:
58+
"""Whether this caller has a hook specification."""
59+
...
60+
61+
def is_historic(self) -> bool:
62+
"""Whether this caller is historic."""
63+
...
64+
65+
def get_hookimpls(self) -> list[HookImpl]:
66+
"""Get all registered hook implementations for this hook."""
67+
...
68+
69+
def set_specification(
70+
self,
71+
specmodule_or_class: _Namespace,
72+
_spec_opts_or_config: HookspecOpts | HookspecConfiguration | None = None,
73+
*,
74+
spec_opts: HookspecOpts | None = None,
75+
spec_config: HookspecConfiguration | None = None,
76+
) -> None:
77+
"""Set the hook specification."""
78+
...
79+
80+
def __call__(self, **kwargs: object) -> Any:
81+
"""Call the hook with given kwargs."""
82+
...
83+
84+
def call_historic(
85+
self,
86+
result_callback: Callable[[Any], None] | None = None,
87+
kwargs: Mapping[str, object] | None = None,
88+
) -> None:
89+
"""Call the hook historically."""
90+
...
91+
92+
def call_extra(
93+
self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object]
94+
) -> Any:
95+
"""Call the hook with additional methods."""
96+
...
97+
98+
def __repr__(self) -> str:
99+
"""String representation of the hook caller."""
100+
...
101+
102+
41103
class HookspecOpts(TypedDict):
42104
"""Options for a hook specification."""
43105

@@ -598,25 +660,6 @@ def _add_hookimpl(self, hookimpl: HookImpl) -> None:
598660
def __repr__(self) -> str:
599661
return f"<HistoricHookCaller {self.name!r}>"
600662

601-
def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None:
602-
# This is written to avoid expensive operations when not needed.
603-
if self.spec:
604-
for argname in self.spec.argnames:
605-
if argname not in kwargs:
606-
notincall = ", ".join(
607-
repr(argname)
608-
for argname in self.spec.argnames
609-
# Avoid self.spec.argnames - kwargs.keys()
610-
# it doesn't preserve order.
611-
if argname not in kwargs.keys()
612-
)
613-
warnings.warn(
614-
f"Argument(s) {notincall} which are declared in the hookspec "
615-
"cannot be found in this hook call",
616-
stacklevel=2,
617-
)
618-
break
619-
620663
def __call__(self, **kwargs: object) -> Any:
621664
"""Call the hook.
622665
@@ -640,7 +683,7 @@ def call_historic(
640683
from a hook implementation.
641684
"""
642685
kwargs = kwargs or {}
643-
self._verify_all_args_are_provided(kwargs)
686+
self.spec.verify_all_args_are_provided(kwargs)
644687
self._call_history.append((kwargs, result_callback))
645688
# Historizing hooks don't return results.
646689
# Remember firstresult isn't compatible with historic.
@@ -672,7 +715,7 @@ def _maybe_apply_history(self, method: HookImpl) -> None:
672715
result_callback(res[0])
673716

674717

675-
class HookCaller:
718+
class NormalHookCaller:
676719
"""A caller of all registered implementations of a hook specification."""
677720

678721
__slots__ = (
@@ -793,26 +836,7 @@ def _add_hookimpl(self, hookimpl: HookImpl) -> None:
793836
self._hookimpls.insert(i + 1, hookimpl)
794837

795838
def __repr__(self) -> str:
796-
return f"<HookCaller {self.name!r}>"
797-
798-
def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None:
799-
# This is written to avoid expensive operations when not needed.
800-
if self.spec:
801-
for argname in self.spec.argnames:
802-
if argname not in kwargs:
803-
notincall = ", ".join(
804-
repr(argname)
805-
for argname in self.spec.argnames
806-
# Avoid self.spec.argnames - kwargs.keys()
807-
# it doesn't preserve order.
808-
if argname not in kwargs.keys()
809-
)
810-
warnings.warn(
811-
f"Argument(s) {notincall} which are declared in the hookspec "
812-
"cannot be found in this hook call",
813-
stacklevel=2,
814-
)
815-
break
839+
return f"<NormalHookCaller {self.name!r}>"
816840

817841
def __call__(self, **kwargs: object) -> Any:
818842
"""Call the hook.
@@ -823,7 +847,8 @@ def __call__(self, **kwargs: object) -> Any:
823847
Returns the result(s) of calling all registered plugins, see
824848
:ref:`calling`.
825849
"""
826-
self._verify_all_args_are_provided(kwargs)
850+
if self.spec:
851+
self.spec.verify_all_args_are_provided(kwargs)
827852
firstresult = self.spec.config.firstresult if self.spec else False
828853
# Copy because plugins may register other plugins during iteration (#438).
829854
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
@@ -849,7 +874,8 @@ def call_extra(
849874
"""Call the hook with some additional temporarily participating
850875
methods using the specified ``kwargs`` as call parameters, see
851876
:ref:`call_extra`."""
852-
self._verify_all_args_are_provided(kwargs)
877+
if self.spec:
878+
self.spec.verify_all_args_are_provided(kwargs)
853879
config = HookimplConfiguration()
854880
hookimpls = self._hookimpls.copy()
855881
for method in methods:
@@ -869,23 +895,13 @@ def call_extra(
869895

870896

871897
# Historical name (pluggy<=1.2), kept for backward compatibility.
872-
_HookCaller = HookCaller
898+
_HookCaller = NormalHookCaller
873899

874900

875-
class _SubsetHookCaller(HookCaller):
901+
class SubsetHookCaller:
876902
"""A proxy to another HookCaller which manages calls to all registered
877903
plugins except the ones from remove_plugins."""
878904

879-
# This class is unusual: in inhertits from `HookCaller` so all of
880-
# the *code* runs in the class, but it delegates all underlying *data*
881-
# to the original HookCaller.
882-
# `subset_hook_caller` used to be implemented by creating a full-fledged
883-
# HookCaller, copying all hookimpls from the original. This had problems
884-
# with memory leaks (#346) and historic calls (#347), which make a proxy
885-
# approach better.
886-
# An alternative implementation is to use a `_getattr__`/`__getattribute__`
887-
# proxy, however that adds more overhead and is more tricky to implement.
888-
889905
__slots__ = (
890906
"_orig",
891907
"_remove_plugins",
@@ -894,24 +910,56 @@ class _SubsetHookCaller(HookCaller):
894910
def __init__(self, orig: HookCaller, remove_plugins: Set[_Plugin]) -> None:
895911
self._orig = orig
896912
self._remove_plugins = remove_plugins
897-
self.name = orig.name # type: ignore[misc]
898-
self._hookexec = orig._hookexec # type: ignore[misc]
899913

900-
@property # type: ignore[misc]
901-
def _hookimpls(self) -> list[HookImpl]:
902-
return [
903-
impl
904-
for impl in self._orig._hookimpls
905-
if impl.plugin not in self._remove_plugins
906-
]
914+
@property
915+
def name(self) -> str:
916+
return self._orig.name
907917

908918
@property
909-
def spec(self) -> HookSpec | None: # type: ignore[override]
919+
def spec(self) -> HookSpec | None:
910920
return self._orig.spec
911921

922+
def has_spec(self) -> bool:
923+
return self._orig.has_spec()
924+
912925
def is_historic(self) -> bool:
913926
return self._orig.is_historic()
914927

928+
def get_hookimpls(self) -> list[HookImpl]:
929+
"""Get filtered hook implementations for this hook."""
930+
return [
931+
impl
932+
for impl in self._orig.get_hookimpls()
933+
if impl.plugin not in self._remove_plugins
934+
]
935+
936+
def set_specification(
937+
self,
938+
specmodule_or_class: _Namespace,
939+
_spec_opts_or_config: HookspecOpts | HookspecConfiguration | None = None,
940+
*,
941+
spec_opts: HookspecOpts | None = None,
942+
spec_config: HookspecConfiguration | None = None,
943+
) -> None:
944+
"""SubsetHookCaller cannot set specifications - they are read-only proxies."""
945+
raise RuntimeError(
946+
f"Cannot set specification on SubsetHookCaller {self.name!r} - "
947+
"it is a read-only proxy to another hook caller"
948+
)
949+
950+
def __call__(self, **kwargs: object) -> Any:
951+
"""Call the hook with filtered implementations."""
952+
if self.is_historic():
953+
raise RuntimeError(
954+
"Cannot directly call a historic hook - use call_historic instead."
955+
)
956+
if self.spec:
957+
self.spec.verify_all_args_are_provided(kwargs)
958+
firstresult = self.spec.config.firstresult if self.spec else False
959+
# Get the hookexec from the original
960+
hookexec = getattr(self._orig, "_hookexec")
961+
return hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
962+
915963
def call_historic(
916964
self,
917965
result_callback: Callable[[Any], None] | None = None,
@@ -928,22 +976,56 @@ def call_historic(
928976

929977
# For subset hook callers, we need to manually handle the history and execution
930978
kwargs = kwargs or {}
931-
self._verify_all_args_are_provided(kwargs)
979+
if self.spec:
980+
self.spec.verify_all_args_are_provided(kwargs)
932981

933982
# If the original is a HistoricHookCaller, add to its history
934983
if hasattr(self._orig, "_call_history"):
935984
self._orig._call_history.append((kwargs, result_callback))
936985

937986
# Execute with filtered hookimpls
938-
res = self._hookexec(self.name, self._hookimpls, kwargs, False)
987+
hookexec = getattr(self._orig, "_hookexec")
988+
res = hookexec(self.name, self.get_hookimpls(), kwargs, False)
939989
if result_callback is None:
940990
return
941991
if isinstance(res, list):
942992
for x in res:
943993
result_callback(x)
944994

995+
def call_extra(
996+
self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object]
997+
) -> Any:
998+
"""Call the hook with some additional temporarily participating methods."""
999+
if self.is_historic():
1000+
raise RuntimeError(
1001+
"Cannot call call_extra on a historic hook - use call_historic instead."
1002+
)
1003+
if self.spec:
1004+
self.spec.verify_all_args_are_provided(kwargs)
1005+
config = HookimplConfiguration()
1006+
hookimpls = self.get_hookimpls()
1007+
for method in methods:
1008+
hookimpl = HookImpl(None, "<temp>", method, config)
1009+
# Find last non-tryfirst nonwrapper method.
1010+
i = len(hookimpls) - 1
1011+
while i >= 0 and (
1012+
# Skip wrappers.
1013+
(hookimpls[i].hookwrapper or hookimpls[i].wrapper)
1014+
# Skip tryfirst nonwrappers.
1015+
or hookimpls[i].tryfirst
1016+
):
1017+
i -= 1
1018+
hookimpls.insert(i + 1, hookimpl)
1019+
firstresult = self.spec.config.firstresult if self.spec else False
1020+
hookexec = getattr(self._orig, "_hookexec")
1021+
return hookexec(self.name, hookimpls, kwargs, firstresult)
1022+
9451023
def __repr__(self) -> str:
946-
return f"<_SubsetHookCaller {self.name!r}>"
1024+
return f"<SubsetHookCaller {self.name!r}>"
1025+
1026+
1027+
# Historical name (pluggy<=1.2), kept for backward compatibility.
1028+
_SubsetHookCaller = SubsetHookCaller
9471029

9481030

9491031
@final
@@ -1028,3 +1110,22 @@ def __init__(
10281110
self.config = config
10291111
self.warn_on_impl = config.warn_on_impl
10301112
self.warn_on_impl_args = config.warn_on_impl_args
1113+
1114+
def verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None:
1115+
"""Verify that all required arguments are provided in kwargs."""
1116+
# This is written to avoid expensive operations when not needed.
1117+
for argname in self.argnames:
1118+
if argname not in kwargs:
1119+
notincall = ", ".join(
1120+
repr(argname)
1121+
for argname in self.argnames
1122+
# Avoid self.argnames - kwargs.keys()
1123+
# it doesn't preserve order.
1124+
if argname not in kwargs.keys()
1125+
)
1126+
warnings.warn(
1127+
f"Argument(s) {notincall} which are declared in the hookspec "
1128+
"cannot be found in this hook call",
1129+
stacklevel=3, # Adjusted for delegation
1130+
)
1131+
break

0 commit comments

Comments
 (0)