Skip to content

Commit 2af96fe

Browse files
Split hook lists and add insertion utility in NormalHookCaller
- Split single _hookimpls list into _normal_hookimpls and _wrapper_hookimpls - Add _insert_hookimpl_into_list() utility to eliminate code duplication - Use unpacking syntax [*first, *second] for cleaner list combinations - Simplifies hook management by removing complex splitpoint calculations - Maintains all existing ordering semantics and test compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent defe380 commit 2af96fe

File tree

2 files changed

+56
-74
lines changed

2 files changed

+56
-74
lines changed

CLAUDE.md

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,24 @@ It provides hook specification and implementation mechanisms through a plugin ma
1212
### Testing
1313
- `uv run pytest` - Run all tests, prefer runnign all tests to quickly get feedback
1414
- `uv run pytest testing/benchmark.py` runs the benchmark tests
15-
- `tox` - Run tests across multiple Python versions (py39, py310, py311, py312, py313, pypy3)
16-
- `tox -e py39` - Run tests on specific Python version
17-
- `tox -e benchmark` - Run benchmarks
18-
- `tox -e py39-pytestmain` - Test against pytest main branch
1915

2016
### Code Quality
2117
- `uv run pre-commit run -a` - Run all pre-commit hooks - gives linting and typing errors + corrects files
2218
- reread files that get fixed by pre-commit
2319

24-
### Documentation
25-
- `tox -e docs` - Build documentation
26-
- `python scripts/towncrier-draft-to-file.py` - Generate changelog draft to verify
2720

28-
### Release
2921
## Core Architecture
3022

3123
### Main Components
3224

3325
- always read all python files in `src/pluggy/ to have full context`
26+
3427
- **PluginManager** (`src/pluggy/_manager.py`): Central registry that manages plugins and coordinates hook calls
3528
- **HookCaller** (`src/pluggy/_hooks.py`): Executes hook implementations with proper argument binding
3629
- **HookImpl/HookSpec** (`src/pluggy/_hooks.py`): Represent hook implementations and specifications
3730
- **Result** (`src/pluggy/_result.py`): Handles hook call results and exception propagation
3831
- **Multicall** (`src/pluggy/_callers.py`): Core execution engine for calling multiple hook implementations
3932

40-
### Key Concepts
41-
- **Hook Specifications**: Define the interface (`@hookspec` decorator)
42-
- **Hook Implementations**: Provide the actual functionality (`@hookimpl` decorator)
43-
- **Plugin Registration**: Plugins are registered with the PluginManager
44-
- **Hook Calling**: The manager coordinates calls to all registered implementations
45-
4633
### Package Structure
4734
- `src/pluggy/` - Main package source
4835
- `testing/` - Test suite using pytest
@@ -53,10 +40,3 @@ It provides hook specification and implementation mechanisms through a plugin ma
5340
- `pyproject.toml` - Project metadata, build system, tool configuration (ruff, mypy, setuptools-scm)
5441
- `tox.ini` - Multi-environment testing configuration
5542
- `.pre-commit-config.yaml` - Code quality automation (ruff, mypy, flake8, etc.)
56-
57-
## Testing Notes
58-
- Tests are located in `testing/` directory
59-
- Uses pytest with coverage reporting
60-
- Benchmark tests in `testing/benchmark.py`
61-
- Minimum pytest version: 8.0
62-
- Test configuration in `[pytest]` section of `tox.ini`

src/pluggy/_hook_callers.py

Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@
2727
from ._hook_markers import varnames
2828

2929

30+
def _insert_hookimpl_into_list(hookimpl: HookImpl, target_list: list[HookImpl]) -> None:
31+
"""Insert a hookimpl into the target list maintaining proper ordering.
32+
33+
The ordering is: [trylast, normal, tryfirst]
34+
"""
35+
if hookimpl.trylast:
36+
target_list.insert(0, hookimpl)
37+
elif hookimpl.tryfirst:
38+
target_list.append(hookimpl)
39+
else:
40+
# find last non-tryfirst method
41+
i = len(target_list) - 1
42+
while i >= 0 and target_list[i].tryfirst:
43+
i -= 1
44+
target_list.insert(i + 1, hookimpl)
45+
46+
3047
@runtime_checkable
3148
class HookCaller(Protocol):
3249
"""Protocol defining the interface for hook callers."""
@@ -183,16 +200,7 @@ def get_hookimpls(self) -> list[HookImpl]:
183200
def _add_hookimpl(self, hookimpl: HookImpl) -> None:
184201
"""Add an implementation to the callback chain."""
185202
# Historic hooks don't support wrappers - simpler ordering
186-
if hookimpl.trylast:
187-
self._hookimpls.insert(0, hookimpl)
188-
elif hookimpl.tryfirst:
189-
self._hookimpls.append(hookimpl)
190-
else:
191-
# find last non-tryfirst method
192-
i = len(self._hookimpls) - 1
193-
while i >= 0 and self._hookimpls[i].tryfirst:
194-
i -= 1
195-
self._hookimpls.insert(i + 1, hookimpl)
203+
_insert_hookimpl_into_list(hookimpl, self._hookimpls)
196204

197205
# Apply history to the newly added hookimpl
198206
self._maybe_apply_history(hookimpl)
@@ -262,12 +270,14 @@ class NormalHookCaller:
262270
"name",
263271
"spec",
264272
"_hookexec",
265-
"_hookimpls",
273+
"_normal_hookimpls",
274+
"_wrapper_hookimpls",
266275
)
267276
name: Final[str]
268277
spec: HookSpec | None
269278
_hookexec: Final[_HookExec]
270-
_hookimpls: Final[list[HookImpl]]
279+
_normal_hookimpls: Final[list[HookImpl]]
280+
_wrapper_hookimpls: Final[list[HookImpl]]
271281

272282
def __init__(
273283
self,
@@ -280,14 +290,12 @@ def __init__(
280290
#: Name of the hook getting called.
281291
self.name = name
282292
self._hookexec = hook_execute
283-
# The hookimpls list. The caller iterates it *in reverse*. Format:
284-
# 1. trylast nonwrappers
285-
# 2. nonwrappers
286-
# 3. tryfirst nonwrappers
287-
# 4. trylast wrappers
288-
# 5. wrappers
289-
# 6. tryfirst wrappers
290-
self._hookimpls = []
293+
# Split hook implementations into two lists for simpler management:
294+
# Normal hooks: [trylast, normal, tryfirst]
295+
# Wrapper hooks: [trylast, normal, tryfirst]
296+
# Combined execution order: normal_hooks + wrapper_hooks (reversed)
297+
self._normal_hookimpls = []
298+
self._wrapper_hookimpls = []
291299
# TODO: Document, or make private.
292300
self.spec: HookSpec | None = None
293301
if specmodule_or_class is not None:
@@ -345,39 +353,34 @@ def is_historic(self) -> bool:
345353
return False # HookCaller is never historic
346354

347355
def _remove_plugin(self, plugin: _Plugin) -> None:
348-
for i, method in enumerate(self._hookimpls):
356+
# Try to remove from normal hookimpls first
357+
for i, method in enumerate(self._normal_hookimpls):
349358
if method.plugin == plugin:
350-
del self._hookimpls[i]
359+
del self._normal_hookimpls[i]
360+
return
361+
# Then try wrapper hookimpls
362+
for i, method in enumerate(self._wrapper_hookimpls):
363+
if method.plugin == plugin:
364+
del self._wrapper_hookimpls[i]
351365
return
352366
raise ValueError(f"plugin {plugin!r} not found")
353367

354368
def get_hookimpls(self) -> list[HookImpl]:
355369
"""Get all registered hook implementations for this hook."""
356-
return self._hookimpls.copy()
370+
# Combine normal hooks and wrapper hooks in the correct order
371+
# Normal hooks come first, then wrapper hooks (execution order is reversed)
372+
return [*self._normal_hookimpls, *self._wrapper_hookimpls]
357373

358374
def _add_hookimpl(self, hookimpl: HookImpl) -> None:
359375
"""Add an implementation to the callback chain."""
360-
for i, method in enumerate(self._hookimpls):
361-
if method.hookwrapper or method.wrapper:
362-
splitpoint = i
363-
break
364-
else:
365-
splitpoint = len(self._hookimpls)
376+
# Choose the appropriate list based on wrapper type
366377
if hookimpl.hookwrapper or hookimpl.wrapper:
367-
start, end = splitpoint, len(self._hookimpls)
378+
target_list = self._wrapper_hookimpls
368379
else:
369-
start, end = 0, splitpoint
380+
target_list = self._normal_hookimpls
370381

371-
if hookimpl.trylast:
372-
self._hookimpls.insert(start, hookimpl)
373-
elif hookimpl.tryfirst:
374-
self._hookimpls.insert(end, hookimpl)
375-
else:
376-
# find last non-tryfirst method
377-
i = end - 1
378-
while i >= start and self._hookimpls[i].tryfirst:
379-
i -= 1
380-
self._hookimpls.insert(i + 1, hookimpl)
382+
# Insert in the correct position within the chosen list
383+
_insert_hookimpl_into_list(hookimpl, target_list)
381384

382385
def __repr__(self) -> str:
383386
return f"<NormalHookCaller {self.name!r}>"
@@ -395,7 +398,9 @@ def __call__(self, **kwargs: object) -> Any:
395398
self.spec.verify_all_args_are_provided(kwargs)
396399
firstresult = self.spec.config.firstresult if self.spec else False
397400
# Copy because plugins may register other plugins during iteration (#438).
398-
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
401+
# Combine normal and wrapper hookimpls in correct order
402+
all_hookimpls = [*self._normal_hookimpls, *self._wrapper_hookimpls]
403+
return self._hookexec(self.name, all_hookimpls, kwargs, firstresult)
399404

400405
def call_historic(
401406
self,
@@ -421,19 +426,16 @@ def call_extra(
421426
if self.spec:
422427
self.spec.verify_all_args_are_provided(kwargs)
423428
config = HookimplConfiguration()
424-
hookimpls = self._hookimpls.copy()
429+
# Start with copies of our separate lists
430+
normal_hookimpls = self._normal_hookimpls.copy()
431+
425432
for method in methods:
426433
hookimpl = HookImpl(None, "<temp>", method, config)
427-
# Find last non-tryfirst nonwrapper method.
428-
i = len(hookimpls) - 1
429-
while i >= 0 and (
430-
# Skip wrappers.
431-
(hookimpls[i].hookwrapper or hookimpls[i].wrapper)
432-
# Skip tryfirst nonwrappers.
433-
or hookimpls[i].tryfirst
434-
):
435-
i -= 1
436-
hookimpls.insert(i + 1, hookimpl)
434+
# Add temporary methods to the normal hookimpls list
435+
_insert_hookimpl_into_list(hookimpl, normal_hookimpls)
436+
437+
# Combine the lists
438+
hookimpls = [*normal_hookimpls, *self._wrapper_hookimpls]
437439
firstresult = self.spec.config.firstresult if self.spec else False
438440
return self._hookexec(self.name, hookimpls, kwargs, firstresult)
439441

0 commit comments

Comments
 (0)