Skip to content

Commit 6cb6eaa

Browse files
Split historic hook functionality into separate HistoricHookCaller class
- Create dedicated HistoricHookCaller class for historic hooks - Remove historic functionality from regular HookCaller class - Implement handover mechanism in PluginManager to convert HookCaller to HistoricHookCaller when historic specs are detected - Update _SubsetHookCaller to properly delegate historic operations - Add HistoricHookCaller to public API exports - Historic hooks now have simplified implementation ordering (no wrapper support) - Improves separation of concerns and performance for non-historic hooks 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 66d8063 commit 6cb6eaa

File tree

4 files changed

+253
-60
lines changed

4 files changed

+253
-60
lines changed

src/pluggy/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"PluginManager",
44
"PluginValidationError",
55
"HookCaller",
6+
"HistoricHookCaller",
67
"HookCallError",
78
"HookspecOpts",
89
"HookimplOpts",
@@ -17,6 +18,7 @@
1718
"PluggyWarning",
1819
"PluggyTeardownRaisedWarning",
1920
]
21+
from ._hooks import HistoricHookCaller
2022
from ._hooks import HookCaller
2123
from ._hooks import HookImpl
2224
from ._hooks import HookimplConfiguration
@@ -35,6 +37,9 @@
3537
from ._warnings import PluggyWarning
3638

3739

40+
__version__: str
41+
42+
3843
def __getattr__(name: str) -> str:
3944
if name == "__version__":
4045
from importlib.metadata import version

src/pluggy/_hooks.py

Lines changed: 201 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,165 @@ def __getattr__(self, name: str) -> HookCaller: ...
513513
_CallHistory = list[tuple[Mapping[str, object], Optional[Callable[[Any], None]]]]
514514

515515

516+
class HistoricHookCaller:
517+
"""A caller for historic hook specifications that memorizes and replays calls.
518+
519+
Historic hooks memorize every call and replay them on plugins registered
520+
after the call was made. Historic hooks do not support wrappers.
521+
"""
522+
523+
__slots__ = (
524+
"name",
525+
"spec",
526+
"_hookexec",
527+
"_hookimpls",
528+
"_call_history",
529+
)
530+
531+
def __init__(
532+
self,
533+
name: str,
534+
hook_execute: _HookExec,
535+
specmodule_or_class: _Namespace,
536+
spec_config: HookspecConfiguration,
537+
) -> None:
538+
""":meta private:"""
539+
assert spec_config.historic, "HistoricHookCaller requires historic=True"
540+
#: Name of the hook getting called.
541+
self.name: Final = name
542+
self._hookexec: Final = hook_execute
543+
# The hookimpls list for historic hooks (no wrappers supported)
544+
self._hookimpls: Final[list[HookImpl]] = []
545+
self._call_history: Final[_CallHistory] = []
546+
# TODO: Document, or make private.
547+
self.spec: Final = HookSpec(specmodule_or_class, name, spec_config)
548+
549+
def has_spec(self) -> bool:
550+
return True # HistoricHookCaller always has a spec
551+
552+
def set_specification(
553+
self,
554+
specmodule_or_class: _Namespace,
555+
_spec_opts_or_config: HookspecOpts | HookspecConfiguration | None = None,
556+
*,
557+
spec_opts: HookspecOpts | None = None,
558+
spec_config: HookspecConfiguration | None = None,
559+
) -> None:
560+
"""Historic hooks cannot have their specification changed after creation."""
561+
raise ValueError(
562+
f"HistoricHookCaller {self.name!r} already has a specification. "
563+
"Historic hooks cannot have their specification changed."
564+
)
565+
566+
def is_historic(self) -> bool:
567+
"""Whether this caller is :ref:`historic <historic>`."""
568+
return True # HistoricHookCaller is always historic
569+
570+
def _remove_plugin(self, plugin: _Plugin) -> None:
571+
for i, method in enumerate(self._hookimpls):
572+
if method.plugin == plugin:
573+
del self._hookimpls[i]
574+
return
575+
raise ValueError(f"plugin {plugin!r} not found")
576+
577+
def get_hookimpls(self) -> list[HookImpl]:
578+
"""Get all registered hook implementations for this hook."""
579+
return self._hookimpls.copy()
580+
581+
def _add_hookimpl(self, hookimpl: HookImpl) -> None:
582+
"""Add an implementation to the callback chain."""
583+
# Historic hooks don't support wrappers - simpler ordering
584+
if hookimpl.trylast:
585+
self._hookimpls.insert(0, hookimpl)
586+
elif hookimpl.tryfirst:
587+
self._hookimpls.append(hookimpl)
588+
else:
589+
# find last non-tryfirst method
590+
i = len(self._hookimpls) - 1
591+
while i >= 0 and self._hookimpls[i].tryfirst:
592+
i -= 1
593+
self._hookimpls.insert(i + 1, hookimpl)
594+
595+
# Apply history to the newly added hookimpl
596+
self._maybe_apply_history(hookimpl)
597+
598+
def __repr__(self) -> str:
599+
return f"<HistoricHookCaller {self.name!r}>"
600+
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+
620+
def __call__(self, **kwargs: object) -> Any:
621+
"""Call the hook.
622+
623+
Historic hooks cannot be called directly. Use call_historic instead.
624+
"""
625+
raise RuntimeError(
626+
"Cannot directly call a historic hook - use call_historic instead."
627+
)
628+
629+
def call_historic(
630+
self,
631+
result_callback: Callable[[Any], None] | None = None,
632+
kwargs: Mapping[str, object] | None = None,
633+
) -> None:
634+
"""Call the hook with given ``kwargs`` for all registered plugins and
635+
for all plugins which will be registered afterwards, see
636+
:ref:`historic`.
637+
638+
:param result_callback:
639+
If provided, will be called for each non-``None`` result obtained
640+
from a hook implementation.
641+
"""
642+
kwargs = kwargs or {}
643+
self._verify_all_args_are_provided(kwargs)
644+
self._call_history.append((kwargs, result_callback))
645+
# Historizing hooks don't return results.
646+
# Remember firstresult isn't compatible with historic.
647+
# Copy because plugins may register other plugins during iteration (#438).
648+
res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False)
649+
if result_callback is None:
650+
return
651+
if isinstance(res, list):
652+
for x in res:
653+
result_callback(x)
654+
655+
def call_extra(
656+
self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object]
657+
) -> Any:
658+
"""Call the hook with some additional temporarily participating
659+
methods using the specified ``kwargs`` as call parameters, see
660+
:ref:`call_extra`."""
661+
raise RuntimeError(
662+
"Cannot call call_extra on a historic hook - use call_historic instead."
663+
)
664+
665+
def _maybe_apply_history(self, method: HookImpl) -> None:
666+
"""Apply call history to a new hookimpl if it is marked as historic."""
667+
for kwargs, result_callback in self._call_history:
668+
res = self._hookexec(self.name, [method], kwargs, False)
669+
if res and result_callback is not None:
670+
# XXX: remember firstresult isn't compat with historic
671+
assert isinstance(res, list)
672+
result_callback(res[0])
673+
674+
516675
class HookCaller:
517676
"""A caller of all registered implementations of a hook specification."""
518677

@@ -521,7 +680,6 @@ class HookCaller:
521680
"spec",
522681
"_hookexec",
523682
"_hookimpls",
524-
"_call_history",
525683
)
526684

527685
def __init__(
@@ -543,7 +701,6 @@ def __init__(
543701
# 5. wrappers
544702
# 6. tryfirst wrappers
545703
self._hookimpls: Final[list[HookImpl]] = []
546-
self._call_history: _CallHistory | None = None
547704
# TODO: Document, or make private.
548705
self.spec: HookSpec | None = None
549706
if specmodule_or_class is not None:
@@ -589,13 +746,16 @@ def set_specification(
589746
else:
590747
raise TypeError("Must provide either spec_opts or spec_config")
591748

592-
self.spec = HookSpec(specmodule_or_class, self.name, final_config)
593749
if final_config.historic:
594-
self._call_history = []
750+
raise ValueError(
751+
f"HookCaller cannot handle historic hooks. "
752+
f"Use HistoricHookCaller for {self.name!r}"
753+
)
754+
self.spec = HookSpec(specmodule_or_class, self.name, final_config)
595755

596756
def is_historic(self) -> bool:
597757
"""Whether this caller is :ref:`historic <historic>`."""
598-
return self._call_history is not None
758+
return False # HookCaller is never historic
599759

600760
def _remove_plugin(self, plugin: _Plugin) -> None:
601761
for i, method in enumerate(self._hookimpls):
@@ -663,9 +823,6 @@ def __call__(self, **kwargs: object) -> Any:
663823
Returns the result(s) of calling all registered plugins, see
664824
:ref:`calling`.
665825
"""
666-
assert not self.is_historic(), (
667-
"Cannot directly call a historic hook - use call_historic instead."
668-
)
669826
self._verify_all_args_are_provided(kwargs)
670827
firstresult = self.spec.config.firstresult if self.spec else False
671828
# Copy because plugins may register other plugins during iteration (#438).
@@ -680,33 +837,18 @@ def call_historic(
680837
for all plugins which will be registered afterwards, see
681838
:ref:`historic`.
682839
683-
:param result_callback:
684-
If provided, will be called for each non-``None`` result obtained
685-
from a hook implementation.
840+
This method should not be called on non-historic hooks.
686841
"""
687-
assert self._call_history is not None
688-
kwargs = kwargs or {}
689-
self._verify_all_args_are_provided(kwargs)
690-
self._call_history.append((kwargs, result_callback))
691-
# Historizing hooks don't return results.
692-
# Remember firstresult isn't compatible with historic.
693-
# Copy because plugins may register other plugins during iteration (#438).
694-
res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False)
695-
if result_callback is None:
696-
return
697-
if isinstance(res, list):
698-
for x in res:
699-
result_callback(x)
842+
raise AssertionError(
843+
f"Hook {self.name!r} is not historic - cannot call call_historic"
844+
)
700845

701846
def call_extra(
702847
self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object]
703848
) -> Any:
704849
"""Call the hook with some additional temporarily participating
705850
methods using the specified ``kwargs`` as call parameters, see
706851
:ref:`call_extra`."""
707-
assert not self.is_historic(), (
708-
"Cannot directly call a historic hook - use call_historic instead."
709-
)
710852
self._verify_all_args_are_provided(kwargs)
711853
config = HookimplConfiguration()
712854
hookimpls = self._hookimpls.copy()
@@ -725,17 +867,6 @@ def call_extra(
725867
firstresult = self.spec.config.firstresult if self.spec else False
726868
return self._hookexec(self.name, hookimpls, kwargs, firstresult)
727869

728-
def _maybe_apply_history(self, method: HookImpl) -> None:
729-
"""Apply call history to a new hookimpl if it is marked as historic."""
730-
if self.is_historic():
731-
assert self._call_history is not None
732-
for kwargs, result_callback in self._call_history:
733-
res = self._hookexec(self.name, [method], kwargs, False)
734-
if res and result_callback is not None:
735-
# XXX: remember firstresult isn't compat with historic
736-
assert isinstance(res, list)
737-
result_callback(res[0])
738-
739870

740871
# Historical name (pluggy<=1.2), kept for backward compatibility.
741872
_HookCaller = HookCaller
@@ -778,9 +909,38 @@ def _hookimpls(self) -> list[HookImpl]:
778909
def spec(self) -> HookSpec | None: # type: ignore[override]
779910
return self._orig.spec
780911

781-
@property
782-
def _call_history(self) -> _CallHistory | None: # type: ignore[override]
783-
return self._orig._call_history
912+
def is_historic(self) -> bool:
913+
return self._orig.is_historic()
914+
915+
def call_historic(
916+
self,
917+
result_callback: Callable[[Any], None] | None = None,
918+
kwargs: Mapping[str, object] | None = None,
919+
) -> None:
920+
"""Call the hook with given ``kwargs`` for all registered plugins and
921+
for all plugins which will be registered afterwards, see
922+
:ref:`historic`.
923+
"""
924+
if not self.is_historic():
925+
raise AssertionError(
926+
f"Hook {self.name!r} is not historic - cannot call call_historic"
927+
)
928+
929+
# For subset hook callers, we need to manually handle the history and execution
930+
kwargs = kwargs or {}
931+
self._verify_all_args_are_provided(kwargs)
932+
933+
# If the original is a HistoricHookCaller, add to its history
934+
if hasattr(self._orig, "_call_history"):
935+
self._orig._call_history.append((kwargs, result_callback))
936+
937+
# Execute with filtered hookimpls
938+
res = self._hookexec(self.name, self._hookimpls, kwargs, False)
939+
if result_callback is None:
940+
return
941+
if isinstance(res, list):
942+
for x in res:
943+
result_callback(x)
784944

785945
def __repr__(self) -> str:
786946
return f"<_SubsetHookCaller {self.name!r}>"

0 commit comments

Comments
 (0)