diff --git a/README.md b/README.md index 6cf60ef..2c1bf2b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This package provides a subclass of which - allows async functions, context managers, and async context managers to be hooks +- supports `firstresult=True` for async hooks - and accepts plugin factories in addition to plugin instances for registration. --- @@ -30,6 +31,7 @@ which - [Create a plugin manager and register plugins](#create-a-plugin-manager-and-register-plugins) - [Call hooks](#call-hooks) - [Async function](#async-function) + - [firstresult=True](#firstresulttrue) - [Context manager](#context-manager) - [Async context manager](#async-context-manager) - [Links](#links) @@ -209,6 +211,39 @@ inside Plugin_1.afunc() ``` +##### firstresult=True + +When the hookspec is defined with `firstresult=True`, `ahook` returns the +first non-`None` result instead of a list. Implementations are awaited +sequentially in pluggy's standard execution order (reverse registration order, +with `tryfirst` and `trylast` modifiers respected) and the chain stops at the +first non-`None` return value. This is unlike the default behavior, where all +implementations are gathered concurrently. + +```python +>>> class SpecFR: +... @hookspec(firstresult=True) +... async def afunc_first(self, arg1, arg2): +... pass + +>>> class Plugin_FR: +... @hookimpl +... async def afunc_first(self, arg1, arg2): +... return arg1 + arg2 + +>>> pm_fr = pluggy.PluginManager('project') +>>> pm_fr.add_hookspecs(SpecFR) +>>> _ = pm_fr.register(Plugin_FR()) + +>>> async def call_afunc_first(): +... result = await pm_fr.ahook.afunc_first(arg1=1, arg2=2) +... print(result) + +>>> asyncio.run(call_afunc_first()) +3 + +``` + #### Context manager ```python diff --git a/src/apluggy/wrap/ext.py b/src/apluggy/wrap/ext.py index 8e23b58..dbf447f 100644 --- a/src/apluggy/wrap/ext.py +++ b/src/apluggy/wrap/ext.py @@ -1,22 +1,237 @@ import asyncio from collections.abc import Callable -from typing import Any, Coroutine +from typing import TYPE_CHECKING, Any, Coroutine from pluggy import HookCaller from pluggy import PluginManager as PluginManager_ from apluggy.stack import AGenCtxMngr, GenCtxMngr, async_stack_gen_ctxs, stack_gen_ctxs +if TYPE_CHECKING: + from pluggy._hooks import HookImpl + + +async def _call_firstresult( + hook: HookCaller, + caller_kwargs: dict[str, Any], +) -> Any: + """Await hookimpls in pluggy order, returning the first non-None result. + + Pluggy's _multicall returns a single coroutine for firstresult=True hooks, + which is not iterable and cannot be passed to asyncio.gather. This function + bypasses _multicall entirely and drives dispatch manually. + """ + # Retrieve implementations in pluggy's storage order, then iterate in + # reverse to match pluggy's _multicall execution order: + # tryfirst first, then plain impls in registration order, trylast last. + hookimpls: list[HookImpl] = hook.get_hookimpls() + + for hook_impl in reversed(hookimpls): + # Build kwargs from argnames only, matching pluggy's _multicall behavior. + # kwargnames is intentionally excluded — pluggy does not pass keyword-only + # parameters through hook dispatch. + impl_kwargs = {k: caller_kwargs[k] for k in hook_impl.argnames} + + # Invoke the hookimpl function to get a coroutine, then await it. + result = await hook_impl.function(**impl_kwargs) + + # First non-None result stops the chain (firstresult semantics). + if result is not None: + return result + + # All implementations returned None; return None to the caller. + return None + + +async def _async_multicall( + hook_name: str, + hookimpls: list["HookImpl"], + caller_kwargs: dict[str, Any], + firstresult: bool, +) -> Any: + """Drive async hook dispatch for hooks that include wrapper=True hookimpls. + + Mirrors pluggy's _multicall (_callers.py:76-169) but handles async + non-wrappers and sync generator wrappers. Wrappers are set up first + (pre-yield code runs during setup), then non-wrappers are awaited, then + wrappers are torn down in reverse order (post-yield code runs, result or + exception is passed back via send/throw). + + Wrappers must be sync generators — they cannot perform async I/O. The + async I/O happens in non-wrapper hookimpls, which this function awaits. + + Args: + hook_name: Name of the hook, used in error messages. + hookimpls: All hookimpls for this hook, in pluggy storage order. + This function iterates them in reverse (execution order). + caller_kwargs: Keyword arguments passed by the caller. + firstresult: If True, stop after the first non-None result from + non-wrappers; aggregate is a single value. If False, + collect all results into a list. + """ + # teardowns holds sync generators for each wrapper hookimpl, in the order + # they were set up (outermost first). Teardown iterates this in reverse so + # innermost teardown runs first. + teardowns: list[Any] = [] + + # results accumulates return values from non-wrapper hookimpls. + results: list[Any] = [] + + # exception captures any exception raised during setup or invocation, so + # teardown wrappers can receive it via throw(). + exception: BaseException | None = None + + try: + # ----------------------------------------------------------------------- + # Setup phase: iterate hookimpls in pluggy execution order (reversed). + # Wrappers run their pre-yield code; non-wrappers are awaited. + # ----------------------------------------------------------------------- + for hook_impl in reversed(hookimpls): + # Build kwargs by selecting only the args this hookimpl declares. + # pluggy strips 'self' from argnames, so we never need to pass it. + impl_kwargs = {k: caller_kwargs[k] for k in hook_impl.argnames} + + if hook_impl.hookwrapper: + # Old-style hookwrapper=True is not supported in this dispatch path. + raise NotImplementedError( + f"{hook_name}: hookwrapper=True hookimpls are not supported " + "by _async_multicall; use wrapper=True instead" + ) + + if hook_impl.wrapper: + # Wrapper hookimpl: call function to get a sync generator, + # then advance to its first yield (running pre-yield code). + gen = hook_impl.function(**impl_kwargs) + + try: + # Advance the generator to its yield point. + # If it raises StopIteration, it never yielded — programming error. + next(gen) + except StopIteration: + raise RuntimeError( + f"{hook_name}: wrapper {hook_impl.function!r} did not yield" + ) + + # Push the generator onto the teardown stack for post-yield processing. + teardowns.append(gen) + + else: + # Regular (non-wrapper) hookimpl: await its coroutine and collect the result. + result = await hook_impl.function(**impl_kwargs) + results.append(result) + + # firstresult semantics: stop after the first non-None result. + if firstresult and result is not None: + break + + except BaseException as e: + # Any exception during setup or invocation (from a wrapper's pre-yield + # code or from a non-wrapper) is captured here. Teardown will throw it + # into any already-pushed wrappers. + exception = e + + # ----------------------------------------------------------------------- + # Compute aggregate result from non-wrapper invocations. + # ----------------------------------------------------------------------- + if firstresult: + # Return the single first non-None result, or None if all returned None. + aggregate: Any = next((r for r in results if r is not None), None) + else: + # Return all collected results as a list. + aggregate = results + + # ----------------------------------------------------------------------- + # Teardown phase: drive wrappers in reverse setup order (innermost first). + # Send the aggregate result (or throw any exception) into each wrapper's + # generator to run its post-yield code. + # ----------------------------------------------------------------------- + for gen in reversed(teardowns): + try: + if exception is not None: + # An exception is in flight — throw it into the wrapper generator. + # The wrapper can catch it, recover, and return a new result, or + # re-raise (or raise a new exception), which replaces 'exception'. + try: + gen.throw(exception) + except RuntimeError as re: + # Python 3.7+: if the thrown exception was a StopIteration, + # Python wraps it in RuntimeError inside the generator body. + # Match pluggy's handling at _callers.py:140-148. + if isinstance(exception, StopIteration) and re.__cause__ is exception: + # Close the generator cleanly and continue to the next wrapper. + gen.close() + continue + else: + # A genuine RuntimeError from the wrapper — let it replace + # the current exception in the outer except BaseException block. + raise + else: + # No exception: send the current aggregate result into the wrapper. + gen.send(aggregate) + + # If we reach here without an exception, the generator yielded again, + # which is illegal (wrappers must yield exactly once). + gen.close() + raise RuntimeError(f"{hook_name}: wrapper yielded twice") + + except StopIteration as si: + # Normal success path: the wrapper returned a value via 'return'. + # Capture the returned value as the new aggregate result. + aggregate = si.value + exception = None # wrapper handled any in-flight exception + + except BaseException as e: + # Wrapper raised during teardown (re-raised or raised a new exception). + # This replaces the current exception; continue teardown to the next wrapper. + exception = e + continue + + # ----------------------------------------------------------------------- + # Final result: raise any surviving exception, or return the aggregate. + # ----------------------------------------------------------------------- + if exception is not None: + raise exception + + return aggregate + class AHook: def __init__(self, pm: PluginManager_) -> None: self.pm = pm - def __getattr__(self, name: str) -> Callable[..., Coroutine[Any, Any, list]]: - async def call(*args: Any, **kwargs: Any) -> list: + def __getattr__(self, name: str) -> Callable[..., Coroutine[Any, Any, Any]]: + async def call(*args: Any, **kwargs: Any) -> Any: + # Resolve the named HookCaller from pluggy's hook namespace. hook: HookCaller = getattr(self.pm.hook, name) - coros: list[asyncio.Future] = hook(*args, **kwargs) - return await asyncio.gather(*coros) + + # Check whether this hookspec uses firstresult semantics. + # A missing spec means no hookspec was registered; treat as False. + firstresult: bool = bool( + hook.spec and hook.spec.opts.get("firstresult", False) + ) + + # Check whether any hookimpl is a wrapper (wrapper=True or hookwrapper=True). + # When wrappers are present, we must use _async_multicall to drive the + # sync generator teardown protocol. + hookimpls = hook.get_hookimpls() + has_wrappers = any(hi.wrapper or hi.hookwrapper for hi in hookimpls) + + if has_wrappers: + # Wrapper path: drive setup, invocation, and teardown manually. + # Handles both firstresult=True and firstresult=False. + return await _async_multicall(name, hookimpls, kwargs, firstresult) + elif firstresult: + # firstresult=True, no wrappers: bypass _multicall; iterate impls + # sequentially. _multicall returns a single coroutine for these + # hooks, which is not iterable and would crash asyncio.gather. + # Positional args are intentionally not forwarded here — pluggy + # hookspecs use keyword arguments exclusively. + return await _call_firstresult(hook, kwargs) + else: + # firstresult=False, no wrappers: collect unawaited coroutines from + # pluggy's _multicall and run them concurrently. + coros: list[asyncio.Future] = hook(*args, **kwargs) + return await asyncio.gather(*coros) return call diff --git a/tests/wrap/__init__.py b/tests/wrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/wrap/test_ahook_firstresult.py b/tests/wrap/test_ahook_firstresult.py new file mode 100644 index 0000000..bb3c8b7 --- /dev/null +++ b/tests/wrap/test_ahook_firstresult.py @@ -0,0 +1,321 @@ +"""Tests for AHook with firstresult=True async hooks. + +Each test sets up a minimal PluginManager with a firstresult=True hookspec +and a small set of hookimpls, then verifies firstresult semantics via +``pm.ahook``. +""" + +import pytest + +import apluggy as pluggy +from apluggy import PluginManager + +# --------------------------------------------------------------------------- +# Shared markers +# --------------------------------------------------------------------------- + +# Project name used throughout; arbitrary but consistent. +_PROJECT = "test_firstresult" + +hookspec = pluggy.HookspecMarker(_PROJECT) +hookimpl = pluggy.HookimplMarker(_PROJECT) + + +# --------------------------------------------------------------------------- +# Hookspec namespace +# --------------------------------------------------------------------------- + + +class Spec: + """Hookspec namespace containing firstresult and non-firstresult hooks.""" + + @hookspec(firstresult=True) + async def afunc_first(self, value: int) -> int | None: + """firstresult=True hook — returns first non-None result.""" + ... # pragma: no cover + + @hookspec + async def afunc_all(self, value: int) -> int: + """firstresult=False (default) hook — returns all results as list.""" + ... # pragma: no cover + + +# --------------------------------------------------------------------------- +# Test 1: Basic firstresult — two impls, first non-None wins +# --------------------------------------------------------------------------- + + +async def test_firstresult_basic_first_wins() -> None: + """With two impls, the first (by execution order) to return non-None wins. + + Pluggy executes impls in reverse registration order, so Plugin_B (registered + second) runs first. It returns a value, so Plugin_A (registered first) must + never be called. + """ + # Track call order to confirm the chain stops after the first non-None result. + call_log: list[str] = [] + + class Plugin_A: + @hookimpl + async def afunc_first(self, value: int) -> int: + call_log.append("A") + return value * 10 # would return 100 for value=10 + + class Plugin_B: + @hookimpl + async def afunc_first(self, value: int) -> int: + call_log.append("B") + return value * 2 # returns 20 for value=10 + + # Build the plugin manager with both plugins registered. + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_A()) + pm.register(Plugin_B()) # runs first (reverse registration order) + + # Execute the firstresult hook. + result = await pm.ahook.afunc_first(value=10) + + # Only Plugin_B should have run; it returned 20. + assert result == 20 + assert call_log == ["B"], f"Expected only ['B'], got {call_log}" + + +# --------------------------------------------------------------------------- +# Test 2: All impls return None — hook returns None +# --------------------------------------------------------------------------- + + +async def test_firstresult_all_none() -> None: + """When every impl returns None, the hook itself returns None.""" + + class Plugin_None_1: + @hookimpl + async def afunc_first(self, value: int) -> None: + return None + + class Plugin_None_2: + @hookimpl + async def afunc_first(self, value: int) -> None: + return None + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_None_1()) + pm.register(Plugin_None_2()) + + # All None → hook returns None, not a list. + result = await pm.ahook.afunc_first(value=5) + assert result is None + + +# --------------------------------------------------------------------------- +# Test 3: No impls registered — returns None +# --------------------------------------------------------------------------- + + +async def test_firstresult_no_impls() -> None: + """With no hookimpls registered, the hook returns None (not a list, not an error).""" + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + # Intentionally no plugins registered. + + result = await pm.ahook.afunc_first(value=99) + assert result is None + + +# --------------------------------------------------------------------------- +# Test 4: Single impl — returns that impl's result +# --------------------------------------------------------------------------- + + +async def test_firstresult_single_impl() -> None: + """A single impl that returns a value: hook returns that value directly.""" + + class Plugin_One: + @hookimpl + async def afunc_first(self, value: int) -> int: + return value + 1 + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_One()) + + result = await pm.ahook.afunc_first(value=7) + assert result == 8 + + +# --------------------------------------------------------------------------- +# Test 5: tryfirst runs before plain impl; tryfirst returns non-None — stops chain +# --------------------------------------------------------------------------- + + +async def test_firstresult_tryfirst_wins() -> None: + """tryfirst impl runs before the plain impl. + + When tryfirst returns a non-None value, the plain impl is never called. + """ + call_log: list[str] = [] + + class Plugin_Plain: + @hookimpl + async def afunc_first(self, value: int) -> int: + call_log.append("plain") + return value * 3 # should never run + + class Plugin_TryFirst: + @hookimpl(tryfirst=True) + async def afunc_first(self, value: int) -> int: + call_log.append("tryfirst") + return value + 100 # returns 106 for value=6 + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_Plain()) + pm.register(Plugin_TryFirst()) + + result = await pm.ahook.afunc_first(value=6) + + # tryfirst runs first and wins; plain is never called. + assert result == 106 + assert call_log == ["tryfirst"], f"Expected only ['tryfirst'], got {call_log}" + + +# --------------------------------------------------------------------------- +# Test 6: tryfirst returns None — chain continues to next handler (trylast wins) +# --------------------------------------------------------------------------- + + +async def test_firstresult_tryfirst_none_trylast_wins() -> None: + """tryfirst returns None so the chain continues; trylast eventually returns a value. + + This verifies that: + - tryfirst runs before plain impls (and trylast). + - A None from tryfirst does NOT stop the chain. + - Execution continues to plain impls, then trylast. + - The trylast impl's non-None result is returned. + """ + call_log: list[str] = [] + + class Plugin_TryFirst: + @hookimpl(tryfirst=True) + async def afunc_first(self, value: int) -> None: + call_log.append("tryfirst") + return None # yields control to the next impl + + class Plugin_Plain: + @hookimpl + async def afunc_first(self, value: int) -> None: + call_log.append("plain") + return None # also None; keep going + + class Plugin_TryLast: + @hookimpl(trylast=True) + async def afunc_first(self, value: int) -> int: + call_log.append("trylast") + return value * 7 # returns 35 for value=5 + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_Plain()) + pm.register(Plugin_TryFirst()) + pm.register(Plugin_TryLast()) + + result = await pm.ahook.afunc_first(value=5) + + # All three must have run; trylast returned the only non-None value. + assert result == 35 + assert call_log == ["tryfirst", "plain", "trylast"], ( + f"Expected ['tryfirst', 'plain', 'trylast'], got {call_log}" + ) + + +# --------------------------------------------------------------------------- +# Test 7: trylast is skipped when plain impl wins +# --------------------------------------------------------------------------- + + +async def test_firstresult_trylast_skipped_when_plain_wins() -> None: + """Plain impl returns non-None; trylast impl is never called.""" + call_log: list[str] = [] + + # Plain impl returns a non-None value, stopping the chain. + class Plugin_Plain: + @hookimpl + async def afunc_first(self, value: int) -> int: + call_log.append("plain") + return value * 2 + + # trylast impl should never run because the chain stops before it. + class Plugin_TryLast: + @hookimpl(trylast=True) + async def afunc_first(self, value: int) -> int: + call_log.append("trylast") + return value * 99 + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_Plain()) + pm.register(Plugin_TryLast()) + + result = await pm.ahook.afunc_first(value=5) + + assert result == 10 + assert call_log == ["plain"], f"Expected only ['plain'], got {call_log}" + + +# --------------------------------------------------------------------------- +# Test 8: Exception propagation — exception from impl reaches caller +# --------------------------------------------------------------------------- + + +async def test_firstresult_exception_propagates() -> None: + """An exception raised inside a hookimpl propagates to the caller immediately.""" + + class Plugin_Raises: + @hookimpl + async def afunc_first(self, value: int) -> int: + raise ValueError(f"bad value: {value}") + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_Raises()) + + # The ValueError from the impl must reach the caller, not be swallowed. + with pytest.raises(ValueError, match="bad value: 42"): + await pm.ahook.afunc_first(value=42) + + +# --------------------------------------------------------------------------- +# Test 9: firstresult=False behavior unchanged — returns list of all results +# --------------------------------------------------------------------------- + + +async def test_firstresult_false_unaffected() -> None: + """firstresult=False hooks still return a list of all results. + + Regression guard: the fix must not change the existing gather-based behavior + for non-firstresult hooks. + """ + + class Plugin_A: + @hookimpl + async def afunc_all(self, value: int) -> int: + return value + 1 + + class Plugin_B: + @hookimpl + async def afunc_all(self, value: int) -> int: + return value + 2 + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_A()) + pm.register(Plugin_B()) # runs first in reverse order + + result = await pm.ahook.afunc_all(value=10) + + # Both results collected; pluggy returns in reverse registration order. + assert isinstance(result, list) + assert set(result) == {11, 12} # order may vary; check contents + assert len(result) == 2 diff --git a/tests/wrap/test_ahook_wrapper.py b/tests/wrap/test_ahook_wrapper.py new file mode 100644 index 0000000..8708a73 --- /dev/null +++ b/tests/wrap/test_ahook_wrapper.py @@ -0,0 +1,646 @@ +"""Tests for AHook with wrapper=True hookimpls (async middleware chains). + +Wrappers are sync generators decorated with ``@hookimpl(wrapper=True)``. +Non-wrappers are async def functions. The dispatch layer handles awaiting +non-wrappers and driving the sync generator teardown loop. + +These tests are expected to FAIL until ``_async_multicall`` is implemented +and ``AHook.__getattr__`` is updated to route through it when wrappers are +present. +""" + +import pytest + +import apluggy as pluggy +from apluggy import PluginManager + +# --------------------------------------------------------------------------- +# Shared markers +# --------------------------------------------------------------------------- + +_PROJECT = "test_ahook_wrapper" + +hookspec = pluggy.HookspecMarker(_PROJECT) +hookimpl = pluggy.HookimplMarker(_PROJECT) + + +# --------------------------------------------------------------------------- +# Hookspec namespace +# --------------------------------------------------------------------------- + + +class Spec: + """Hookspec namespace for wrapper tests.""" + + @hookspec + async def ahook(self, value: int) -> int: + """firstresult=False hook — wrapper tests use this by default.""" + ... # pragma: no cover + + @hookspec(firstresult=True) + async def ahook_first(self, value: int) -> int | None: + """firstresult=True hook — for firstresult interaction tests.""" + ... # pragma: no cover + + +# =========================================================================== +# Basic wrapper semantics (4 tests) +# =========================================================================== + + +async def test_wrapper_basic_pre_post_order() -> None: + """Single sync wrapper around single async non-wrapper; verify pre/post order. + + The call_log should show: wrapper_pre → non_wrapper → wrapper_post. + """ + call_log: list[str] = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + call_log.append("non_wrapper") + return value * 2 + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + call_log.append("wrapper_pre") + result = yield + call_log.append("wrapper_post") + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + result = await pm.ahook.ahook(value=5) + + assert call_log == ["wrapper_pre", "non_wrapper", "wrapper_post"], ( + f"Expected pre→non_wrapper→post order, got {call_log}" + ) + # firstresult=False with one non-wrapper: result is a list containing [10] + assert result == [10] + + +async def test_wrapper_modifies_result() -> None: + """Wrapper modifies the result returned to the caller.""" + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + return value + 1 # returns 6 for value=5 + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + result = yield + # result is [6] (firstresult=False, one non-wrapper) + # return a modified list + return [x * 10 for x in result] + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + result = await pm.ahook.ahook(value=5) + + assert result == [60], f"Expected [60], got {result}" + + +async def test_wrapper_passes_through() -> None: + """Wrapper that returns result unchanged passes through the inner value.""" + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + return value * 3 # returns 15 for value=5 + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + result = yield + return result # pass through unchanged + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + result = await pm.ahook.ahook(value=5) + + # firstresult=False with one non-wrapper: [15] passed through unchanged + assert result == [15], f"Expected [15], got {result}" + + +async def test_wrapper_no_non_wrappers() -> None: + """Wrapper with no non-wrappers receives an empty list (firstresult=False).""" + received: list = [] + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + result = yield + received.append(result) + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_Wrapper()) + + result = await pm.ahook.ahook(value=1) + + # No non-wrappers: aggregate is empty list for firstresult=False + assert received == [[]], f"Wrapper should receive [], got {received}" + assert result == [], f"Expected [], got {result}" + + +# =========================================================================== +# Multiple wrappers (2 tests) +# =========================================================================== + + +async def test_two_wrappers_nesting_order() -> None: + """Two wrappers; verify nesting: outer_pre → inner_pre → non_wrapper → inner_post → outer_post. + + Pluggy execution order (reversed hookimpls): tryfirst_wrappers first, then + plain wrappers. We register outer wrapper with tryfirst=True so it runs + outermost. + """ + call_log: list[str] = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + call_log.append("non_wrapper") + return value + + class Plugin_InnerWrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + call_log.append("inner_pre") + result = yield + call_log.append("inner_post") + return result + + class Plugin_OuterWrapper: + @hookimpl(wrapper=True, tryfirst=True) + def ahook(self, value: int): + call_log.append("outer_pre") + result = yield + call_log.append("outer_post") + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_InnerWrapper()) + pm.register(Plugin_OuterWrapper()) + + await pm.ahook.ahook(value=1) + + assert call_log == [ + "outer_pre", + "inner_pre", + "non_wrapper", + "inner_post", + "outer_post", + ], f"Wrong nesting order: {call_log}" + + +async def test_inner_wrapper_modifies_outer_sees_it() -> None: + """Inner wrapper modifies result; outer wrapper receives the modified value.""" + received_by_outer: list = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + return value # returns [5] as list + + class Plugin_InnerWrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + result = yield + # result is [5]; multiply each element by 10 + return [x * 10 for x in result] + + class Plugin_OuterWrapper: + @hookimpl(wrapper=True, tryfirst=True) + def ahook(self, value: int): + result = yield + received_by_outer.append(result) + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_InnerWrapper()) + pm.register(Plugin_OuterWrapper()) + + result = await pm.ahook.ahook(value=5) + + # Inner modified [5] → [50]; outer sees [50] and passes through + assert received_by_outer == [[50]], ( + f"Outer should receive [50] from inner, got {received_by_outer}" + ) + assert result == [50], f"Expected [50], got {result}" + + +# =========================================================================== +# Ordering (3 tests) +# =========================================================================== + + +async def test_tryfirst_wrapper_runs_before_default_wrapper() -> None: + """tryfirst wrapper's pre-yield runs before the default wrapper's pre-yield. + + pluggy ordering: tryfirst_wrappers run outermost (their pre-yield executes + first; their post-yield executes last). + """ + call_log: list[str] = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + call_log.append("non_wrapper") + return value + + class Plugin_DefaultWrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + call_log.append("default_pre") + result = yield + call_log.append("default_post") + return result + + class Plugin_TryFirstWrapper: + @hookimpl(wrapper=True, tryfirst=True) + def ahook(self, value: int): + call_log.append("tryfirst_pre") + result = yield + call_log.append("tryfirst_post") + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_DefaultWrapper()) + pm.register(Plugin_TryFirstWrapper()) + + await pm.ahook.ahook(value=1) + + # tryfirst is outermost: tryfirst_pre first, tryfirst_post last + assert call_log == [ + "tryfirst_pre", + "default_pre", + "non_wrapper", + "default_post", + "tryfirst_post", + ], f"Wrong ordering with tryfirst wrapper: {call_log}" + + +async def test_trylast_wrapper_runs_after_default_wrapper() -> None: + """trylast wrapper's pre-yield runs after the default wrapper's pre-yield. + + pluggy ordering: trylast_wrappers run innermost (their pre-yield executes + last among wrappers; their post-yield executes first among wrappers). + """ + call_log: list[str] = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + call_log.append("non_wrapper") + return value + + class Plugin_DefaultWrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + call_log.append("default_pre") + result = yield + call_log.append("default_post") + return result + + class Plugin_TryLastWrapper: + @hookimpl(wrapper=True, trylast=True) + def ahook(self, value: int): + call_log.append("trylast_pre") + result = yield + call_log.append("trylast_post") + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_DefaultWrapper()) + pm.register(Plugin_TryLastWrapper()) + + await pm.ahook.ahook(value=1) + + # trylast wrapper is innermost among wrappers + assert call_log == [ + "default_pre", + "trylast_pre", + "non_wrapper", + "trylast_post", + "default_post", + ], f"Wrong ordering with trylast wrapper: {call_log}" + + +async def test_tryfirst_trylast_on_non_wrappers_inside_wrapper_chain() -> None: + """tryfirst/trylast on non-wrappers control their execution order within the chain. + + Inside the wrapper layer, non-wrappers execute as: + tryfirst_nonwrappers → plain_nonwrappers → trylast_nonwrappers + (pluggy reversed order applied to non-wrapper segment). + """ + call_log: list[str] = [] + + class Plugin_PlainNonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + call_log.append("plain") + return value + + class Plugin_TryFirstNonWrapper: + @hookimpl(tryfirst=True) + async def ahook(self, value: int) -> int: + call_log.append("tryfirst_nonwrapper") + return value + 1 + + class Plugin_TryLastNonWrapper: + @hookimpl(trylast=True) + async def ahook(self, value: int) -> int: + call_log.append("trylast_nonwrapper") + return value + 2 + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + result = yield + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_PlainNonWrapper()) + pm.register(Plugin_TryFirstNonWrapper()) + pm.register(Plugin_TryLastNonWrapper()) + pm.register(Plugin_Wrapper()) + + await pm.ahook.ahook(value=1) + + # Non-wrapper execution order: tryfirst → plain → trylast + assert call_log == ["tryfirst_nonwrapper", "plain", "trylast_nonwrapper"], ( + f"Wrong non-wrapper execution order: {call_log}" + ) + + +# =========================================================================== +# firstresult interaction (2 tests) +# =========================================================================== + + +async def test_wrapper_around_firstresult_receives_single_value() -> None: + """Wrapper around firstresult=True hook receives a single value, not a list.""" + received: list = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook_first(self, value: int) -> int: + return value * 2 # returns 10 for value=5 + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook_first(self, value: int): + result = yield + received.append(result) + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + result = await pm.ahook.ahook_first(value=5) + + # firstresult=True: wrapper sees single value (10), not [10] + assert received == [10], ( + f"Wrapper should receive single int 10, not a list; got {received}" + ) + assert result == 10, f"Expected 10, got {result}" + + +async def test_wrapper_modifies_firstresult_value() -> None: + """Wrapper can modify the firstresult value before returning it to caller.""" + + class Plugin_NonWrapper: + @hookimpl + async def ahook_first(self, value: int) -> int: + return value + 1 # returns 6 for value=5 + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook_first(self, value: int): + result = yield + # result is 6 (single int for firstresult=True) + return result * 10 # return 60 + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + result = await pm.ahook.ahook_first(value=5) + + assert result == 60, f"Expected 60, got {result}" + + +# =========================================================================== +# Error handling (4 tests) +# =========================================================================== + + +async def test_error_non_wrapper_raises_exception_thrown_into_wrapper() -> None: + """Non-wrapper raises an exception; it is thrown into the wrapper at yield point.""" + saw_exception: list[Exception] = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + raise ValueError(f"bad value: {value}") + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + try: + result = yield + except ValueError as exc: + saw_exception.append(exc) + return [] # recovery: return empty list + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + # Wrapper catches the exception; caller sees recovery value + result = await pm.ahook.ahook(value=42) + + assert len(saw_exception) == 1 + assert "bad value: 42" in str(saw_exception[0]) + assert result == [], f"Expected recovery value [], got {result}" + + +async def test_error_wrapper_catches_exception_returns_recovery() -> None: + """Wrapper catches the exception thrown into it and returns a recovery value.""" + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + raise RuntimeError("inner failure") + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + try: + result = yield + except RuntimeError: + return [-1] # recovery value + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + result = await pm.ahook.ahook(value=1) + + assert result == [-1], f"Expected recovery value [-1], got {result}" + + +async def test_error_wrapper_reraises_exception_propagates_to_caller() -> None: + """Wrapper re-raises the exception; it propagates to the caller.""" + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + raise ValueError("propagate me") + + class Plugin_Wrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + try: + result = yield + except ValueError: + raise # re-raise; caller must see it + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_Wrapper()) + + with pytest.raises(ValueError, match="propagate me"): + await pm.ahook.ahook(value=1) + + +async def test_error_exception_in_wrapper_pre_yield_triggers_outer_teardown() -> None: + """Exception during wrapper pre-yield code causes already-pushed wrappers to be torn down. + + Setup: outer wrapper is registered (tryfirst) → inner wrapper raises before yield. + Outer wrapper's teardown (post-yield) must still run so it can clean up. + """ + teardown_log: list[str] = [] + + class Plugin_NonWrapper: + @hookimpl + async def ahook(self, value: int) -> int: + return value + + class Plugin_InnerWrapper: + @hookimpl(wrapper=True) + def ahook(self, value: int): + raise RuntimeError("pre-yield failure") + yield # never reached — makes this a generator + + class Plugin_OuterWrapper: + @hookimpl(wrapper=True, tryfirst=True) + def ahook(self, value: int): + try: + result = yield + except RuntimeError: + teardown_log.append("outer_caught") + raise # re-raise so it propagates + return result + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_NonWrapper()) + pm.register(Plugin_InnerWrapper()) + pm.register(Plugin_OuterWrapper()) + + # The RuntimeError from InnerWrapper's pre-yield should reach outer wrapper, + # then propagate to the caller. + with pytest.raises(RuntimeError, match="pre-yield failure"): + await pm.ahook.ahook(value=1) + + # Outer wrapper teardown must have run + assert teardown_log == ["outer_caught"], ( + f"Outer wrapper teardown should have run, got {teardown_log}" + ) + + +# =========================================================================== +# Regression guards (2 tests) +# =========================================================================== + + +async def test_regression_no_wrappers_firstresult_false_returns_list() -> None: + """No wrappers + firstresult=False → returns list via gather path (unchanged behavior).""" + + class Plugin_A: + @hookimpl + async def ahook(self, value: int) -> int: + return value + 1 + + class Plugin_B: + @hookimpl + async def ahook(self, value: int) -> int: + return value + 2 + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_A()) + pm.register(Plugin_B()) + + result = await pm.ahook.ahook(value=10) + + assert isinstance(result, list), f"Expected list, got {type(result)}" + assert set(result) == {11, 12}, f"Expected {{11, 12}}, got {set(result)}" + assert len(result) == 2 + + +async def test_regression_no_wrappers_firstresult_true_returns_single_value() -> None: + """No wrappers + firstresult=True → returns single value via firstresult path (unchanged).""" + + class Plugin_A: + @hookimpl + async def ahook_first(self, value: int) -> int: + return value * 10 # would return 50 + + class Plugin_B: + @hookimpl + async def ahook_first(self, value: int) -> int: + return value * 2 # returns 10 — runs first (registered last) + + pm = PluginManager(_PROJECT) + pm.add_hookspecs(Spec) + pm.register(Plugin_A()) + pm.register(Plugin_B()) # runs first in reverse registration order + + result = await pm.ahook.ahook_first(value=5) + + # Plugin_B runs first and returns 10; chain stops. + assert result == 10, f"Expected 10 (firstresult from Plugin_B), got {result}"