Skip to content

feat(implementation): add NormalImpl, WrapperImpl, and CompletionHook setup API - #8

Open
RonnyPfannschmidt wants to merge 1 commit into
refactor/markers-attach-configfrom
refactor/hookimpl-wrapper-types
Open

feat(implementation): add NormalImpl, WrapperImpl, and CompletionHook setup API#8
RonnyPfannschmidt wants to merge 1 commit into
refactor/markers-attach-configfrom
refactor/hookimpl-wrapper-types

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Review PR — step 4 of 7.

This PR targets the previous step's branch, so its diff is only this step's change. Review happens here. The corresponding upstream PR, which is the one that actually merges, is pytest-dev#707.

Merges happen upstream one step at a time, bottom-up. When step 4 lands upstream, this PR is closed and the rest of the stack is rebased onto the new main.

Step Branch Review (downstream) Merge (upstream)
1 refactor/split-hook-modules #6 pytest-dev#703
2 refactor/configuration-objects #5 pytest-dev#704
3 refactor/markers-attach-config #7 pytest-dev#706
4 refactor/hookimpl-wrapper-types #8 pytest-dev#707
5 refactor/hookcaller-and-execution #9 pytest-dev#708
6 refactor/project-spec #10 pytest-dev#709
7 refactor/async-submitter #11 pytest-dev#710

Chain step 04 of the internal-refactoring series (design/04-hookimpl-wrapper-types.md).

HookImpl becomes a base class holding hookimpl_config; NormalImpl / WrapperImpl validate their configuration; HookimplConfiguration.create_hookimpl() picks the subclass; WrapperImpl.setup_and_get_completion_hook() exposes wrapper setup/teardown as a runtime-checkable CompletionHook.

Stacked on the step-03 chain PR.

🤖 Generated with Claude Code

Summary by Sourcery

Introduce a typed HookImpl hierarchy with NormalImpl and WrapperImpl, add a CompletionHook-based wrapper setup/teardown API, and update configuration, registration, and public exports to construct and expose the appropriate implementation types.

New Features:

  • Introduce NormalImpl and WrapperImpl subclasses of HookImpl to distinguish normal and wrapper hook implementations.
  • Add a runtime-checkable CompletionHook protocol and WrapperImpl.setup_and_get_completion_hook API to expose wrapper setup/teardown as a completion callback.

Enhancements:

  • Refactor HookImpl to store HookimplConfiguration as hookimpl_config, add a deprecated opts alias, and move argument binding logic into _get_call_args with shared error handling.
  • Add HookimplConfiguration.create_hookimpl factory to construct the appropriate HookImpl subclass based on configuration and use it in plugin registration and extra hook calls.
  • Export NormalImpl, WrapperImpl, and CompletionHook from the public pluggy API and adjust HookImpl repr to show the concrete subclass name.

Documentation:

  • Update API reference to include the new HookImpl subclasses and CompletionHook protocol.

Tests:

  • Add test_implementation.py covering HookImpl subclass creation, argument binding, completion hook behavior for new and old-style wrappers, and plugin registration producing the correct impl types.

@sourcery-ai

sourcery-ai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors hook implementation handling into a small hierarchy (HookImpl base with NormalImpl and WrapperImpl), centralizes hookimpl creation in HookimplConfiguration, and introduces a runtime-checkable CompletionHook API for wrapper setup/teardown, updating callers, exports, and tests accordingly.

Sequence diagram for WrapperImpl.setup_and_get_completion_hook and completion hook teardown

sequenceDiagram
    participant Caller as HookCaller
    participant W as WrapperImpl
    participant Exec as _execution
    participant Gen as wrapper_gen

    Caller->>W: setup_and_get_completion_hook(hook_name, caller_kwargs)
    W->>W: _get_call_args(caller_kwargs)
    alt W.hookwrapper
        W->>Exec: run_old_style_hookwrapper(W, hook_name, args)
        Exec-->>W: wrapper_gen
    else normal wrapper
        W->>W: function(*args)
        W-->>W: wrapper_gen
    end
    W->>Gen: next(wrapper_gen)
    Gen-->>W: setup phase
    W-->>Caller: completion_hook

    Caller->>completion_hook: __call__(result, exception)
    alt exception is not None
        completion_hook->>Gen: throw(exception)
    else no exception
        completion_hook->>Gen: send(result)
    end
    completion_hook->>Gen: close()
    alt wrapper completes
        Gen-->>completion_hook: StopIteration(value)
        completion_hook-->>Caller: (value, None)
    else wrapper errors
        Gen-->>completion_hook: BaseException(e)
        completion_hook-->>Caller: (result, e)
    end
Loading

File-Level Changes

Change Details Files
Introduce HookImpl hierarchy (base HookImpl with NormalImpl and WrapperImpl) and shared argument-binding logic.
  • Make HookImpl a non-final base class that stores HookimplConfiguration as hookimpl_config instead of opts, with a deprecated opts alias property.
  • Add _get_call_args helper on HookImpl to bind caller_kwargs to positional args and raise HookCallError on missing arguments.
  • Update repr to show the concrete subclass name and adjust tests expecting this representation.
  • Add NormalImpl and WrapperImpl subclasses that validate configuration consistency (wrapper vs non-wrapper).
src/pluggy/_implementation.py
testing/test_details.py
testing/test_implementation.py
Add CompletionHook protocol and WrapperImpl.setup_and_get_completion_hook to expose wrapper setup/teardown as a runtime-checkable API.
  • Define runtime_checkable CompletionHook Protocol with a call signature that accepts (result, exception) and returns a possibly replaced (result, exception) pair.
  • Implement WrapperImpl.setup_and_get_completion_hook to run wrapper setup, adapt old-style hookwrappers via run_old_style_hookwrapper, and return a completion hook closure implementing teardown semantics.
  • Use HookImpl._get_call_args and local imports (_raise_wrapfail, run_old_style_hookwrapper) to avoid duplication and circular imports.
  • Add focused tests covering CompletionHook behavior for normal wrappers and old-style hookwrappers, including error cases (no yield, second yield) and exception/result transformation.
src/pluggy/_implementation.py
testing/test_implementation.py
Centralize creation of HookImpl subclasses in HookimplConfiguration and update manager/caller usage.
  • Add HookimplConfiguration.create_hookimpl to select NormalImpl vs WrapperImpl based on wrapper/hookwrapper flags, with local imports to avoid cycles.
  • Update PluginManager.register and _caller.call_extra to use HookimplConfiguration.create_hookimpl instead of constructing HookImpl directly.
  • Add TYPE_CHECKING-only imports in _config to reference implementation types without runtime cycles.
  • Add tests verifying create_hookimpl returns correct subclass types, enforces configuration validation, and that PluginManager registration produces NormalImpl/WrapperImpl instances as expected.
src/pluggy/_config.py
src/pluggy/_manager.py
src/pluggy/_caller.py
testing/test_implementation.py
Expose new implementation types and CompletionHook in public API and hook module.
  • Export NormalImpl, WrapperImpl, and CompletionHook from _hooks.all and re-export NormalImpl and WrapperImpl from pluggy.init for public use.
  • Adjust imports in _hooks and init to bring in the new classes and protocol.
  • Ensure tests import these public symbols and use them when validating behavior.
src/pluggy/_hooks.py
src/pluggy/__init__.py
testing/test_implementation.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The deprecated HookImpl.opts property currently only has a docstring note; consider emitting a DeprecationWarning on access so downstream code gets a runtime signal during migration.
  • Exposing CompletionHook, NormalImpl, and WrapperImpl via __all__ and pluggy.__init__ effectively promotes them to public API; double-check that this is intentional and aligns with the desired stability and refactoring strategy.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The deprecated `HookImpl.opts` property currently only has a docstring note; consider emitting a `DeprecationWarning` on access so downstream code gets a runtime signal during migration.
- Exposing `CompletionHook`, `NormalImpl`, and `WrapperImpl` via `__all__` and `pluggy.__init__` effectively promotes them to public API; double-check that this is intentional and aligns with the desired stability and refactoring strategy.

## Individual Comments

### Comment 1
<location path="testing/test_implementation.py" line_range="180-193" />
<code_context>
+        assert isinstance(exception, RuntimeError)
+        assert "has second yield" in str(exception)
+
+    def test_old_style_hookwrapper_receives_result_object(self) -> None:
+        seen: list[object] = []
+
+        def old_style(arg: object) -> Generator[None, object, None]:
+            outcome = yield
+            seen.append(outcome)
+            outcome.force_result(f"forced: {arg}")  # type: ignore[attr-defined]
+
+        impl = make_wrapper_impl(old_style, hookwrapper=True)
+        completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
+        result, exception = completion("orig", None)
+        assert result == "forced: a"
+        assert exception is None
+        assert seen and seen[0].__class__.__name__ == "Result"
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding tests for exception handling with old-style hookwrappers

The existing tests for old-style hookwrappers only cover the happy-path `Result` and `force_result`. `WrapperImpl.setup_and_get_completion_hook` has more complex behaviour when `exception` is set and when the wrapper raises or swallows exceptions. For consistency with the new-style wrapper tests, please add at least one test where an old-style hookwrapper (a) inspects an exception and forces a result to swallow it, and (b) replaces it with a different exception. This will verify that `run_old_style_hookwrapper` interacts correctly with `CompletionHook` teardown in exceptional paths.

```suggestion
    def test_old_style_hookwrapper_receives_result_object(self) -> None:
        seen: list[object] = []

        def old_style(arg: object) -> Generator[None, object, None]:
            outcome = yield
            seen.append(outcome)
            outcome.force_result(f"forced: {arg}")  # type: ignore[attr-defined]

        impl = make_wrapper_impl(old_style, hookwrapper=True)
        completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
        result, exception = completion("orig", None)
        assert result == "forced: a"
        assert exception is None
        assert seen and seen[0].__class__.__name__ == "Result"

    def test_old_style_hookwrapper_swallow_exception_with_forced_result(self) -> None:
        seen: list[object] = []

        def old_style(arg: object) -> Generator[None, object, None]:
            outcome = yield
            seen.append(outcome)
            # The old-style hookwrapper sees the original exception and decides to
            # recover by forcing a successful result instead.
            assert outcome.excinfo is not None  # type: ignore[attr-defined]
            assert isinstance(outcome.excinfo[1], ValueError)  # type: ignore[attr-defined]
            outcome.force_result(f"recovered: {arg}")  # type: ignore[attr-defined]

        impl = make_wrapper_impl(old_style, hookwrapper=True)
        completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
        result, exception = completion("orig", ValueError("boom"))
        assert result == "recovered: a"
        assert exception is None
        assert seen and seen[0].__class__.__name__ == "Result"

    def test_old_style_hookwrapper_replaces_exception(self) -> None:
        seen: list[object] = []

        def old_style(arg: object) -> Generator[None, object, None]:
            outcome = yield
            seen.append(outcome)
            # The old-style hookwrapper inspects the original exception and then
            # raises a different one to replace it.
            assert outcome.excinfo is not None  # type: ignore[attr-defined]
            assert isinstance(outcome.excinfo[1], ValueError)  # type: ignore[attr-defined]
            raise RuntimeError(f"wrapper failed for: {arg}")

        impl = make_wrapper_impl(old_style, hookwrapper=True)
        completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
        result, exception = completion("orig", ValueError("boom"))
        assert result is None
        assert isinstance(exception, RuntimeError)
        assert "wrapper failed for: a" in str(exception)
        assert seen and seen[0].__class__.__name__ == "Result"
```
</issue_to_address>

### Comment 2
<location path="testing/test_details.py" line_range="195-196" />
<code_context>
     plugin = Plugin()
     pname = pm.register(plugin)
     assert repr(pm.hook.myhook.get_hookimpls()[0]) == (
-        f"<HookImpl plugin_name={pname!r}, plugin={plugin!r}>"
+        f"<NormalImpl plugin_name={pname!r}, plugin={plugin!r}>"
     )

</code_context>
<issue_to_address>
**suggestion (testing):** Add a complementary repr test for WrapperImpl to cover the new subclass-specific repr

NormalImpl’s repr is now asserted to include the concrete subclass name, and WrapperImpl shares the same repr logic but lacks a direct test. Please add a test that registers a wrapper implementation (e.g., via a small plugin using `@hookimpl(wrapper=True)`) and asserts its repr begins with `<WrapperImpl ...>` and includes the correct `plugin_name` and `plugin` values, so the new behavior is fully covered.

Suggested implementation:

```python
    plugin = Plugin()
    pname = pm.register(plugin)
    assert repr(pm.hook.myhook.get_hookimpls()[0]) == (
        f"<NormalImpl plugin_name={pname!r}, plugin={plugin!r}>"
    )

    class WrapperPlugin:
        @hookimpl(wrapper=True)
        def myhook(self, result):
            return result

    wrapper_plugin = WrapperPlugin()
    wrapper_pname = pm.register(wrapper_plugin)
    # WrapperImpl should be the second hook implementation for myhook
    assert repr(pm.hook.myhook.get_hookimpls()[1]) == (
        f"<WrapperImpl plugin_name={wrapper_pname!r}, plugin={wrapper_plugin!r}>"
    )


```

1. Ensure `hookimpl` is already imported in `testing/test_details.py` (typically `from pluggy import HookimplMarker` or similar, aliased to `hookimpl`). If it is not, add the appropriate import using the existing conventions in the file.
2. Confirm that `pm` in this test has a `myhook` spec that accepts a `result` argument for wrapper implementations; if not, adjust the `WrapperPlugin.myhook` signature to match the defined hook spec.
3. If the `pm.register(plugin)` call changes the ordering of hook implementations (e.g., via `tryfirst`/`trylast` or other options in surrounding code), you may need to assert against the correct index for the `WrapperImpl` in `pm.hook.myhook.get_hookimpls()`.
</issue_to_address>

### Comment 3
<location path="src/pluggy/_implementation.py" line_range="30" />
<code_context>


-@final
+@runtime_checkable
+class CompletionHook(Protocol):
+    """Teardown callback returned by :meth:`WrapperImpl.setup_and_get_completion_hook`.
</code_context>
<issue_to_address>
**issue (complexity):** Consider reducing the exposed type-level surface by hiding the NormalImpl/WrapperImpl choice behind a factory, using a callable alias for CompletionHook, and extracting wrapper teardown into a separate helper.

The split into `NormalImpl` / `WrapperImpl` and the `CompletionHook` protocol adds quite a bit of surface area for the amount of new behaviour. You can keep all functionality while reducing the amount of “type-level” complexity by:

---

### 1. Hide the subclass distinction behind a factory

Right now callers must know which subclass to instantiate and are punished with `ValueError` if the config doesn’t match. Instead, centralise that logic and only expose a single creation entry point. This keeps the subclasses (and the wrapper‑specific methods) but removes the duplication and mental overhead at call sites.

```python
# helper near the class definitions
def create_hook_impl(
    plugin: _Plugin,
    plugin_name: str,
    function: _HookImplFunction[object],
    hook_impl_config: HookimplConfiguration,
) -> HookImpl:
    if hook_impl_config.wrapper or hook_impl_config.hookwrapper:
        return WrapperImpl(plugin, plugin_name, function, hook_impl_config)
    return NormalImpl(plugin, plugin_name, function, hook_impl_config)
```

Call sites would then use `create_hook_impl(...)` and never directly pick `NormalImpl` vs `WrapperImpl`. You can also drop the `ValueError` checks in the subclasses because the factory is the single gatekeeper.

---

### 2. Simplify `CompletionHook` to a callable alias if you don’t need runtime typing

If you don’t rely on `isinstance(x, CompletionHook)` / `issubclass` checks, a protocol is heavier than necessary. A type alias keeps the signature clear without introducing an extra concept:

```python
CompletionHook: TypeAlias = Callable[
    [object | list[object] | None, BaseException | None],
    tuple[object | list[object] | None, BaseException | None],
]
```

The return type of `WrapperImpl.setup_and_get_completion_hook` doesn’t need to change beyond using this alias, and all current usage will keep working.

---

### 3. Extract the teardown orchestration from `WrapperImpl.setup_and_get_completion_hook`

The nested `completion_hook` function mixes argument extraction, wrapper generator preparation, and teardown orchestration. You can move the teardown logic into `_execution` so that `WrapperImpl` only sets up the generator and delegates:

```python
# in ._execution (or similar)
def run_wrapper_teardown(
    wrapper_gen: Generator[None, object, object],
    result: object | list[object] | None,
    exception: BaseException | None,
) -> tuple[object | list[object] | None, BaseException | None]:
    try:
        if exception is not None:
            try:
                wrapper_gen.throw(exception)
            except RuntimeError as re:
                if isinstance(exception, StopIteration) and re.__cause__ is exception:
                    wrapper_gen.close()
                    return result, exception
                raise
        else:
            wrapper_gen.send(result)
        wrapper_gen.close()
        _raise_wrapfail(wrapper_gen, "has second yield")
    except StopIteration as si:
        return si.value, None
    except BaseException as e:
        return result, e
```

```python
# in WrapperImpl
from ._execution import run_old_style_hookwrapper, run_wrapper_teardown

def setup_and_get_completion_hook(
    self, hook_name: str, caller_kwargs: Mapping[str, object]
) -> CompletionHook:
    args = self._get_call_args(caller_kwargs)

    if self.hookwrapper:
        wrapper_gen = run_old_style_hookwrapper(self, hook_name, args)
    else:
        wrapper_gen = cast(Generator[None, object, object], self.function(*args))

    try:
        next(wrapper_gen)
    except StopIteration:
        _raise_wrapfail(wrapper_gen, "did not yield")

    def completion_hook(
        result: object | list[object] | None,
        exception: BaseException | None,
    ) -> tuple[object | list[object] | None, BaseException | None]:
        return run_wrapper_teardown(wrapper_gen, result, exception)

    return completion_hook
```

This keeps the completion‑hook behaviour exactly as it is, but makes the teardown flow reusable, testable in isolation, and easier to read.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +180 to +193
def test_old_style_hookwrapper_receives_result_object(self) -> None:
seen: list[object] = []

def old_style(arg: object) -> Generator[None, object, None]:
outcome = yield
seen.append(outcome)
outcome.force_result(f"forced: {arg}") # type: ignore[attr-defined]

impl = make_wrapper_impl(old_style, hookwrapper=True)
completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
result, exception = completion("orig", None)
assert result == "forced: a"
assert exception is None
assert seen and seen[0].__class__.__name__ == "Result"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding tests for exception handling with old-style hookwrappers

The existing tests for old-style hookwrappers only cover the happy-path Result and force_result. WrapperImpl.setup_and_get_completion_hook has more complex behaviour when exception is set and when the wrapper raises or swallows exceptions. For consistency with the new-style wrapper tests, please add at least one test where an old-style hookwrapper (a) inspects an exception and forces a result to swallow it, and (b) replaces it with a different exception. This will verify that run_old_style_hookwrapper interacts correctly with CompletionHook teardown in exceptional paths.

Suggested change
def test_old_style_hookwrapper_receives_result_object(self) -> None:
seen: list[object] = []
def old_style(arg: object) -> Generator[None, object, None]:
outcome = yield
seen.append(outcome)
outcome.force_result(f"forced: {arg}") # type: ignore[attr-defined]
impl = make_wrapper_impl(old_style, hookwrapper=True)
completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
result, exception = completion("orig", None)
assert result == "forced: a"
assert exception is None
assert seen and seen[0].__class__.__name__ == "Result"
def test_old_style_hookwrapper_receives_result_object(self) -> None:
seen: list[object] = []
def old_style(arg: object) -> Generator[None, object, None]:
outcome = yield
seen.append(outcome)
outcome.force_result(f"forced: {arg}") # type: ignore[attr-defined]
impl = make_wrapper_impl(old_style, hookwrapper=True)
completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
result, exception = completion("orig", None)
assert result == "forced: a"
assert exception is None
assert seen and seen[0].__class__.__name__ == "Result"
def test_old_style_hookwrapper_swallow_exception_with_forced_result(self) -> None:
seen: list[object] = []
def old_style(arg: object) -> Generator[None, object, None]:
outcome = yield
seen.append(outcome)
# The old-style hookwrapper sees the original exception and decides to
# recover by forcing a successful result instead.
assert outcome.excinfo is not None # type: ignore[attr-defined]
assert isinstance(outcome.excinfo[1], ValueError) # type: ignore[attr-defined]
outcome.force_result(f"recovered: {arg}") # type: ignore[attr-defined]
impl = make_wrapper_impl(old_style, hookwrapper=True)
completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
result, exception = completion("orig", ValueError("boom"))
assert result == "recovered: a"
assert exception is None
assert seen and seen[0].__class__.__name__ == "Result"
def test_old_style_hookwrapper_replaces_exception(self) -> None:
seen: list[object] = []
def old_style(arg: object) -> Generator[None, object, None]:
outcome = yield
seen.append(outcome)
# The old-style hookwrapper inspects the original exception and then
# raises a different one to replace it.
assert outcome.excinfo is not None # type: ignore[attr-defined]
assert isinstance(outcome.excinfo[1], ValueError) # type: ignore[attr-defined]
raise RuntimeError(f"wrapper failed for: {arg}")
impl = make_wrapper_impl(old_style, hookwrapper=True)
completion = impl.setup_and_get_completion_hook("myhook", {"arg": "a"})
result, exception = completion("orig", ValueError("boom"))
assert result is None
assert isinstance(exception, RuntimeError)
assert "wrapper failed for: a" in str(exception)
assert seen and seen[0].__class__.__name__ == "Result"

Comment thread testing/test_details.py
Comment on lines 195 to +196
assert repr(pm.hook.myhook.get_hookimpls()[0]) == (
f"<HookImpl plugin_name={pname!r}, plugin={plugin!r}>"
f"<NormalImpl plugin_name={pname!r}, plugin={plugin!r}>"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a complementary repr test for WrapperImpl to cover the new subclass-specific repr

NormalImpl’s repr is now asserted to include the concrete subclass name, and WrapperImpl shares the same repr logic but lacks a direct test. Please add a test that registers a wrapper implementation (e.g., via a small plugin using @hookimpl(wrapper=True)) and asserts its repr begins with <WrapperImpl ...> and includes the correct plugin_name and plugin values, so the new behavior is fully covered.

Suggested implementation:

    plugin = Plugin()
    pname = pm.register(plugin)
    assert repr(pm.hook.myhook.get_hookimpls()[0]) == (
        f"<NormalImpl plugin_name={pname!r}, plugin={plugin!r}>"
    )

    class WrapperPlugin:
        @hookimpl(wrapper=True)
        def myhook(self, result):
            return result

    wrapper_plugin = WrapperPlugin()
    wrapper_pname = pm.register(wrapper_plugin)
    # WrapperImpl should be the second hook implementation for myhook
    assert repr(pm.hook.myhook.get_hookimpls()[1]) == (
        f"<WrapperImpl plugin_name={wrapper_pname!r}, plugin={wrapper_plugin!r}>"
    )
  1. Ensure hookimpl is already imported in testing/test_details.py (typically from pluggy import HookimplMarker or similar, aliased to hookimpl). If it is not, add the appropriate import using the existing conventions in the file.
  2. Confirm that pm in this test has a myhook spec that accepts a result argument for wrapper implementations; if not, adjust the WrapperPlugin.myhook signature to match the defined hook spec.
  3. If the pm.register(plugin) call changes the ordering of hook implementations (e.g., via tryfirst/trylast or other options in surrounding code), you may need to assert against the correct index for the WrapperImpl in pm.hook.myhook.get_hookimpls().



@final
@runtime_checkable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider reducing the exposed type-level surface by hiding the NormalImpl/WrapperImpl choice behind a factory, using a callable alias for CompletionHook, and extracting wrapper teardown into a separate helper.

The split into NormalImpl / WrapperImpl and the CompletionHook protocol adds quite a bit of surface area for the amount of new behaviour. You can keep all functionality while reducing the amount of “type-level” complexity by:


1. Hide the subclass distinction behind a factory

Right now callers must know which subclass to instantiate and are punished with ValueError if the config doesn’t match. Instead, centralise that logic and only expose a single creation entry point. This keeps the subclasses (and the wrapper‑specific methods) but removes the duplication and mental overhead at call sites.

# helper near the class definitions
def create_hook_impl(
    plugin: _Plugin,
    plugin_name: str,
    function: _HookImplFunction[object],
    hook_impl_config: HookimplConfiguration,
) -> HookImpl:
    if hook_impl_config.wrapper or hook_impl_config.hookwrapper:
        return WrapperImpl(plugin, plugin_name, function, hook_impl_config)
    return NormalImpl(plugin, plugin_name, function, hook_impl_config)

Call sites would then use create_hook_impl(...) and never directly pick NormalImpl vs WrapperImpl. You can also drop the ValueError checks in the subclasses because the factory is the single gatekeeper.


2. Simplify CompletionHook to a callable alias if you don’t need runtime typing

If you don’t rely on isinstance(x, CompletionHook) / issubclass checks, a protocol is heavier than necessary. A type alias keeps the signature clear without introducing an extra concept:

CompletionHook: TypeAlias = Callable[
    [object | list[object] | None, BaseException | None],
    tuple[object | list[object] | None, BaseException | None],
]

The return type of WrapperImpl.setup_and_get_completion_hook doesn’t need to change beyond using this alias, and all current usage will keep working.


3. Extract the teardown orchestration from WrapperImpl.setup_and_get_completion_hook

The nested completion_hook function mixes argument extraction, wrapper generator preparation, and teardown orchestration. You can move the teardown logic into _execution so that WrapperImpl only sets up the generator and delegates:

# in ._execution (or similar)
def run_wrapper_teardown(
    wrapper_gen: Generator[None, object, object],
    result: object | list[object] | None,
    exception: BaseException | None,
) -> tuple[object | list[object] | None, BaseException | None]:
    try:
        if exception is not None:
            try:
                wrapper_gen.throw(exception)
            except RuntimeError as re:
                if isinstance(exception, StopIteration) and re.__cause__ is exception:
                    wrapper_gen.close()
                    return result, exception
                raise
        else:
            wrapper_gen.send(result)
        wrapper_gen.close()
        _raise_wrapfail(wrapper_gen, "has second yield")
    except StopIteration as si:
        return si.value, None
    except BaseException as e:
        return result, e
# in WrapperImpl
from ._execution import run_old_style_hookwrapper, run_wrapper_teardown

def setup_and_get_completion_hook(
    self, hook_name: str, caller_kwargs: Mapping[str, object]
) -> CompletionHook:
    args = self._get_call_args(caller_kwargs)

    if self.hookwrapper:
        wrapper_gen = run_old_style_hookwrapper(self, hook_name, args)
    else:
        wrapper_gen = cast(Generator[None, object, object], self.function(*args))

    try:
        next(wrapper_gen)
    except StopIteration:
        _raise_wrapfail(wrapper_gen, "did not yield")

    def completion_hook(
        result: object | list[object] | None,
        exception: BaseException | None,
    ) -> tuple[object | list[object] | None, BaseException | None]:
        return run_wrapper_teardown(wrapper_gen, result, exception)

    return completion_hook

This keeps the completion‑hook behaviour exactly as it is, but makes the teardown flow reusable, testable in isolation, and easier to read.

@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/markers-attach-config branch from 6238439 to e406f69 Compare July 24, 2026 16:38
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/hookimpl-wrapper-types branch from 53041e2 to c5cf018 Compare July 24, 2026 16:38
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/markers-attach-config branch from e406f69 to 5b654ff Compare July 24, 2026 16:54
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/hookimpl-wrapper-types branch from c5cf018 to 6338f75 Compare July 24, 2026 16:54
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/markers-attach-config branch from 5b654ff to 1ac246f Compare August 12, 2026 10:50
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/hookimpl-wrapper-types branch from 6338f75 to 6bb6aaf Compare August 12, 2026 10:50
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/markers-attach-config branch from 1ac246f to 4dc4e1f Compare August 12, 2026 10:57
@RonnyPfannschmidt
RonnyPfannschmidt force-pushed the refactor/hookimpl-wrapper-types branch from 6bb6aaf to 94a0f13 Compare August 12, 2026 10:57
… setup API

Complete design step 04:

- HookImpl becomes a base class storing hookimpl_config (deprecated
  .opts alias kept) with arg binding moved to _get_call_args.
- NormalImpl / WrapperImpl subclasses validate their configuration;
  HookimplConfiguration.create_hookimpl() returns the right subclass
  (fixing the try-claude footgun of bare HookImpl for normals).
- WrapperImpl.setup_and_get_completion_hook() runs wrapper setup and
  returns a CompletionHook (runtime-checkable Protocol) that owns
  teardown, adapting old-style hookwrappers uniformly.
- Registration and call_extra construct impls via create_hookimpl;
  multicall binds args via _get_call_args. Full dual-sequence multicall
  rewiring lands with design step 05.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant