Skip to content

WIP: split up and reshuffle hook calling - #2

Open
RonnyPfannschmidt wants to merge 1 commit into
mainfrom
steamline-internals
Open

WIP: split up and reshuffle hook calling#2
RonnyPfannschmidt wants to merge 1 commit into
mainfrom
steamline-internals

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented May 15, 2025

Copy link
Copy Markdown
Owner

the goal is to have wrappers and non-wrappers be split into separate lists as well as changing historic hooks

ong term experiment

Summary by Sourcery

Split hook execution into separate wrapper and non-wrapper flows, introduce a unified Result type for hook outcomes, and refactor HookImpl for cleaner argument handling.

Enhancements:

  • Refactor hook execution (_multicall and manager._hookexec) to accept distinct wrapper and non-wrapper lists
  • Introduce a DEFAULTS mapping and normalize_hookimpl_opts returning a new options dict
  • Implement a final Result class replacing internal _Result for unified result and exception management
  • Enhance HookImpl with slots, custom getattr, optimized argument getters, and wrapper support in call
  • Move HookCallError into a dedicated exceptions module for clearer error handling
  • Add type annotations across the plugin manager and DistFacade for improved type safety

Tests:

  • Update test_multicall and benchmark setup to use separate wrapper and non-wrapper argument signatures in _multicall

@sourcery-ai

sourcery-ai Bot commented May 15, 2025

Copy link
Copy Markdown

Reviewer's Guide

This PR reshapes hook invocation by splitting hook implementations into separate wrapper and non-wrapper lists, refactors option normalization and result handling, and streamlines internal HookImpl and caller logic for clearer flows and better error reporting.

File-Level Changes

Change Details Files
Split hook execution into wrappers and non-wrappers lists
  • Updated HookCaller.call, call_historic, and _maybe_apply_history to pass separate wrapper/non-wrapper lists
  • Modified _manager._hookexec signature and invocation to accept wrappers and non-wrappers
  • Refactored _callers._multicall to iterate wrappers then non-wrappers, collect teardown closures, and generate outcomes
  • Adapted tests and benchmarks to supply separate wrapper and non-wrapper lists
src/pluggy/_hooks.py
src/pluggy/_manager.py
src/pluggy/_callers.py
testing/benchmark.py
testing/test_multicall.py
Introduce centralized default hookimpl options
  • Defined DEFAULTS dict with standard hookimpl option keys
  • Rewrote normalize_hookimpl_opts to merge incoming opts with DEFAULTS
src/pluggy/_hooks.py
Refactor HookImpl for optimized argument handling and wrapper support
  • Added slots to HookImpl and replaced dict update
  • Generated _getter callables via itemgetter or lambdas based on argnames
  • Implemented getattr to proxy opts and raise HookCallError for missing args
  • Redefined call to yield cleanup closures for hookwrappers and raise wrap errors
src/pluggy/_hooks.py
src/pluggy/_exceptions.py
Replace internal _Result with final Result class
  • Renamed and annotated _Result ➔ Result with type hints and @Final
  • Unified exception storage as BaseException and adjusted excinfo, get_result, from_call, force_result
  • Removed legacy Result import from _callers in favor of new Result
src/pluggy/_result.py
src/pluggy/_callers.py
Add type hints and clean up imports
  • Imported typing annotations (Optional, List, Union) in _manager
  • Added return and param type hints for methods in _manager and _result
  • Organized imports across modules for consistency
src/pluggy/_manager.py
src/pluggy/_result.py
src/pluggy/_hooks.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 @RonnyPfannschmidt - I've reviewed your changes - here's some feedback:

  • normalize_hookimpl_opts now returns a new dict instead of mutating in‐place—verify all code consuming hookimpl_opts uses the returned mapping and no in‐place updates or identity checks remain.
  • Double-check that splitting into wrappers vs non-wrappers preserves the original hook invocation order (including tryfirst/trylast and nested wrappers) against existing behavior.
Here's what I looked at during the review
  • 🟡 General issues: 1 issue found
  • 🟡 Testing: 1 issue found
  • 🟡 Complexity: 1 issue found
  • 🟢 Documentation: all looks good

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 thread src/pluggy/_hooks.py
Comment thread testing/test_multicall.py
hookwrappers.append(f)
else:
hookfuncs.append(f)
return caller("foo", hookwrappers, hookfuncs, kwargs, firstresult)

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 targeted tests for new wrapper/non-wrapper call mechanics.

Add unit tests covering the new paths in _multicall and HookImpl.__call__:

  1. Execution order: wrapper setup (tryfirst/trylast), non-wrapper execution (tryfirst/trylast), then wrapper teardown in reverse.
  2. firstresult=True: wrappers still run setup and teardown even if a non-wrapper returns early.
  3. Calls with only wrappers.
  4. Wrapper exceptions both before and after the yield.
  5. Historic wrapper hooks exercising _maybe_apply_history.

Suggested implementation:

    return caller("foo", hookwrappers, hookfuncs, kwargs, firstresult)

# --------------------------------------------------------------------
# New unit tests for `_multicall` and `HookImpl.__call__`
# --------------------------------------------------------------------
import pytest
# adjust these imports to your actual module locations
from _pytest.config import _multicall, HookImpl

def test_wrapper_and_hook_execution_order():
    events = []

    @pytest.hookimpl(tryfirst=True, hookwrapper=True)
    def wrap1():
        events.append("setup_wrap1")
        yield
        events.append("teardown_wrap1")

    @pytest.hookimpl(trylast=True, hookwrapper=True)
    def wrap2():
        events.append("setup_wrap2")
        yield
        events.append("teardown_wrap2")

    @pytest.hookimpl(tryfirst=True)
    def hook1():
        events.append("hook1")

    @pytest.hookimpl(trylast=True)
    def hook2():
        events.append("hook2")

    # run with both wrappers and hooks
    _multicall("foo", [wrap1, wrap2], [hook1, hook2], {}, firstresult=False)
    assert events == [
        "setup_wrap1",
        "setup_wrap2",
        "hook1",
        "hook2",
        "teardown_wrap2",
        "teardown_wrap1",
    ]

def test_firstresult_true_wrappers_run():
    events = []

    @pytest.hookimpl(hookwrapper=True)
    def wrap():
        events.append("setup")
        yield
        events.append("teardown")

    @pytest.hookimpl(tryfirst=True)
    def early():
        events.append("early")
        return "result"

    @pytest.hookimpl()
    def never():
        events.append("never")

    result = _multicall("foo", [wrap], [early, never], {}, firstresult=True)
    assert result == "result"
    # even though 'never' never runs, setup and teardown do
    assert events == ["setup", "early", "teardown"]

def test_only_wrappers():
    events = []

    @pytest.hookimpl(tryfirst=True, hookwrapper=True)
    def w():
        events.append("setup")
        yield
        events.append("teardown")

    # no normal hook functions
    _multicall("foo", [w], [], {}, firstresult=False)
    assert events == ["setup", "teardown"]

def test_wrapper_exception_before_and_after():
    @pytest.hookimpl(hookwrapper=True)
    def bad_before():
        raise RuntimeError("before yield")
        yield  # unreachable

    with pytest.raises(RuntimeError):
        _multicall("foo", [bad_before], [], {}, firstresult=False)

    @pytest.hookimpl(hookwrapper=True)
    def bad_after():
        yield
        raise RuntimeError("after yield")

    with pytest.raises(RuntimeError):
        _multicall("foo", [bad_after], [], {}, firstresult=False)

def test_historic_wrapper_hooks():
    events = []

    @pytest.hookimpl(hookwrapper=True)
    def wrap_hist():
        events.append("hsetup")
        yield
        events.append("hteardown")

    # simulate that wrap_hist was used in a previous call
    wrap_impl = HookImpl(None, "foo", wrap_hist, wrap_hist)
    wrap_impl._was_called = True

    # now call with only historic wrapper; it must still run
    _multicall("foo", [wrap_impl], [], {}, firstresult=False)
    assert events == ["hsetup", "hteardown"]
  1. Ensure you have pytest installed and the correct import path for _multicall and HookImpl in your codebase.
  2. If _multicall lives under a different module (e.g. pytest._multicall), adjust the from … import _multicall, HookImpl line accordingly.
  3. Make sure the file ends with a newline after the appended tests.

Comment thread src/pluggy/_result.py

class HookCallError(Exception):
"""Hook was called wrongly."""
@final

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 replacing the manual class implementation with a dataclass using slots to simplify initialization and attribute management.

You’ve added a lot of boilerplate around storage and init. You can collapse most of it into a single `@dataclass(slots=True)` (Py-3.10+) and drop your manual `__slots__`, `__init__`, and the trivial `exception` property. For example:

```python
from dataclasses import dataclass, field
from types import TracebackType
from typing import Generic, Optional, TypeVar, Callable, final

R = TypeVar("R")

@final
@dataclass(slots=True)
class Result(Generic[R]):
    result: Optional[R] = None
    exception: Optional[BaseException] = None
    _tb: Optional[TracebackType] = field(default=None, repr=False)

    def __post_init__(self):
        # stash the original traceback if we were given an exception
        if self.exception and self._tb is None:
            self._tb = self.exception.__traceback__

    @property
    def excinfo(self) -> tuple[type[BaseException], BaseException, Optional[TracebackType]] | None:
        if not self.exception:
            return None
        return (type(self.exception), self.exception, self._tb)

    @classmethod
    def from_call(cls, func: Callable[[], R]) -> Result[R]:
        __tracebackhide__ = True
        try:
            return cls(result=func())
        except BaseException as e:
            return cls(exception=e)

    def force_result(self, result: R) -> None:
        self.result = result
        self.exception = None
        self._tb = None

    def force_exception(self, exc: BaseException) -> None:
        self.result = None
        self.exception = exc
        self._tb = exc.__traceback__

    def get_result(self) -> R:
        __tracebackhide__ = True
        if self.exception is None:
            return self.result  # type: ignore
        raise self.exception.with_traceback(self._tb)

Benefits:

  • removes manual __init__ + __slots__
  • drops the tiny @property def exception in favor of a public field
  • keeps all existing APIs (from_call, force_*, get_result, excinfo)
  • type‐safe and just ~30 lines of logic instead of ~60

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

dataclasses must be avoided in this library for its import and init cost

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Totally fair—if we’re shying away from dataclasses for import/init costs, we can stick with a handwritten class but still trim the boilerplate. Here’s one pattern that keeps __slots__ for memory savings, collapses the __init__ logic into one place, and drops the trivial exception property in favor of a public attribute:

from types import TracebackType
from typing import Generic, Optional, TypeVar, Tuple, Union, final

R = TypeVar("R")

@final
class Result(Generic[R]):
    __slots__ = ("result", "exception", "_tb")

    def __init__(
        self,
        result: Optional[R] = None,
        exception: Optional[BaseException] = None,
        tb: Optional[TracebackType] = None,
    ) -> None:
        self.result = result
        self.exception = exception
        # if we got an exception but no tb, grab it
        self._tb = tb if tb is not None or exception is None else exception.__traceback__

    @property
    def excinfo(self) -> Optional[Tuple[type[BaseException], BaseException, Optional[TracebackType]]]:
        if self.exception is None:
            return None
        return (type(self.exception), self.exception, self._tb)

    @classmethod
    def from_call(cls, func: Callable[[], R]) -> "Result[R]":
        __tracebackhide__ = True
        try:
            return cls(result=func())
        except BaseException as e:
            return cls(exception=e)

    def force_result(self, result: R) -> None:
        self.result = result
        self.exception = None
        self._tb = None

    def force_exception(self, exc: BaseException) -> None:
        self.result = None
        self.exception = exc
        self._tb = exc.__traceback__

    def get_result(self) -> R:
        __tracebackhide__ = True
        if self.exception is None:
            return self.result  # type: ignore
        raise self.exception.with_traceback(self._tb)

Advantages versus the pre-rewrite version:

• One __init__ instead of separate slot declaration + init boilerplate
• Still uses __slots__ for minimal memory overhead
• No extra @property exception wrapper—just a public attr
• Clear TB handling in one place

If that still feels too verbose, we can leave it as you currently have it—the semantics are sound. Let me know what you think!

Comment thread testing/benchmark.py
Comment on lines +39 to +41
for method in hooks:
f = HookImpl(None, "<temp>", method, method.example_impl)
hook_impls.append(f)
nonwrappers.append(f)

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 (code-quality): Avoid loops in tests. (no-loop-in-tests)

ExplanationAvoid complex code, like loops, in test functions.

Google's software engineering guidelines says:
"Clear tests are trivially correct upon inspection"
To reach that avoid complex code in tests:

  • loops
  • conditionals

Some ways to fix this:

  • Use parametrized tests to get rid of the loop.
  • Move the complex logic into helpers.
  • Move the complex part into pytest fixtures.

Complexity is most often introduced in the form of logic. Logic is defined via the imperative parts of programming languages such as operators, loops, and conditionals. When a piece of code contains logic, you need to do a bit of mental computation to determine its result instead of just reading it off of the screen. It doesn't take much logic to make a test more difficult to reason about.

Software Engineering at Google / Don't Put Logic in Tests

Comment thread testing/benchmark.py
Comment on lines +42 to +44
for method in wrappers:
f = HookImpl(None, "<temp>", method, method.example_impl)
wrapping.append(f)

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 (code-quality): Avoid loops in tests. (no-loop-in-tests)

ExplanationAvoid complex code, like loops, in test functions.

Google's software engineering guidelines says:
"Clear tests are trivially correct upon inspection"
To reach that avoid complex code in tests:

  • loops
  • conditionals

Some ways to fix this:

  • Use parametrized tests to get rid of the loop.
  • Move the complex logic into helpers.
  • Move the complex part into pytest fixtures.

Complexity is most often introduced in the form of logic. Logic is defined via the imperative parts of programming languages such as operators, loops, and conditionals. When a piece of code contains logic, you need to do a bit of mental computation to determine its result instead of just reading it off of the screen. It doesn't take much logic to make a test more difficult to reason about.

Software Engineering at Google / Don't Put Logic in Tests

Comment thread src/pluggy/_hooks.py
Comment on lines +348 to +349
except LookupError:
raise AttributeError(name, type(self))

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 (code-quality): Explicitly raise from a previous error (raise-from-previous-error)

Suggested change
except LookupError:
raise AttributeError(name, type(self))
except LookupError as e:
raise AttributeError(name, type(self)) from e

Comment thread src/pluggy/_result.py
Comment on lines +30 to +32
if e is None:
return None
return type(e), e, e.__traceback__

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 (code-quality): We've found these issues:

Suggested change
if e is None:
return None
return type(e), e, e.__traceback__
return None if e is None else (type(e), e, e.__traceback__)

Comment thread src/pluggy/_result.py
Comment on lines 61 to +63
else:
ex = self._excinfo
raise ex[1].with_traceback(ex[2])
ex = self._exc
raise ex.with_traceback(ex.__traceback__)

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 (code-quality): Remove unnecessary else after guard condition (remove-unnecessary-else)

Suggested change
else:
ex = self._excinfo
raise ex[1].with_traceback(ex[2])
ex = self._exc
raise ex.with_traceback(ex.__traceback__)
ex = self._exc
raise ex.with_traceback(ex.__traceback__)

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