From 4496c4adf08e1cbb637075e38b521014490610fd Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 10:49:45 +0200 Subject: [PATCH 1/8] docs(design): commit refactoring design writeups into history Add the design/ writeup chain (decisions + per-step docs 01-07) that guides the stacked refactoring branches. Exclude design/ from blacken-docs since the docs use elided pseudo-code signatures. Co-Authored-By: Claude Fable 5 --- .pre-commit-config.yaml | 1 + design/01-module-reorganization.md | 99 ++++++++++++ design/02-configuration-objects.md | 149 +++++++++++++++++ design/03-markers-attach-config.md | 102 ++++++++++++ design/04-hookimpl-wrapper-types.md | 169 ++++++++++++++++++++ design/05-hookcaller-and-execution.md | 222 ++++++++++++++++++++++++++ design/06-project-spec.md | 104 ++++++++++++ design/07-async-submitter.md | 169 ++++++++++++++++++++ design/DECISIONS.md | 140 ++++++++++++++++ design/README.md | 115 +++++++++++++ 10 files changed, 1270 insertions(+) create mode 100644 design/01-module-reorganization.md create mode 100644 design/02-configuration-objects.md create mode 100644 design/03-markers-attach-config.md create mode 100644 design/04-hookimpl-wrapper-types.md create mode 100644 design/05-hookcaller-and-execution.md create mode 100644 design/06-project-spec.md create mode 100644 design/07-async-submitter.md create mode 100644 design/DECISIONS.md create mode 100644 design/README.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b9f521a..8f31bfc4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,7 @@ repos: hooks: - id: blacken-docs additional_dependencies: [black==24.2.0] + exclude: ^design/ - repo: local hooks: - id: rst diff --git a/design/01-module-reorganization.md b/design/01-module-reorganization.md new file mode 100644 index 00000000..819b8803 --- /dev/null +++ b/design/01-module-reorganization.md @@ -0,0 +1,99 @@ +# 01 — Module reorganization + +**Status:** Foundation step (behavior-preserving move) +**Depends on:** nothing +**Next:** [02-configuration-objects.md](02-configuration-objects.md) + +## Problem + +On `main`, hook machinery is concentrated in: + +| File | Contents today | +|------|----------------| +| [`src/pluggy/_hooks.py`](../src/pluggy/_hooks.py) | TypedDicts, markers, callers, HookImpl, HookSpec, helpers | +| [`src/pluggy/_callers.py`](../src/pluggy/_callers.py) | `_multicall`, old-style wrapper adapter | + +Later steps (Protocols, CompletionHook, typed impls) need clear module +boundaries. try-claude already performed this split. + +## Goals + +- Split by role into focused modules (try-claude layout, clearer names OK). +- Preserve import paths via re-exports from `_hooks.py` / shims. +- Zero behavior change in this step. + +## Non-goals + +- New types, CompletionHook, Protocol callers (docs 02–05). +- Copying anything from `reiterate-claude`. + +## Target design (chosen) + +Cross-check summary: + +| Branch | Layout | Notes | +|--------|--------|-------| +| `try-claude` | `_hook_config`, `_hook_markers`, `_hook_callers` (callers+impls), `_callers` | Good role split; `_hook_callers` too large; `_hook_*` + `_callers` naming clash | +| `reiterate-claude` | `_config`, `_decorators`, `_caller`, `_implementation`, `_execution` | Cleaner names; **do not copy its logic** | +| **This step** | reiterate **names** + current `main` **content** | Move-only | + +```text +src/pluggy/ + _config.py # HookspecOpts, HookimplOpts, normalize_hookimpl_opts + _decorators.py # markers, HookSpec, varnames + _caller.py # HookCaller, HookRelay, _SubsetHookCaller + _implementation.py # HookImpl + _Plugin / _HookImplFunction + _execution.py # _multicall + wrapper helpers + _hooks.py # re-exports (compat) + _callers.py # thin re-export of _execution (compat) +``` + +Name mapping from try-claude: see [DECISIONS.md](DECISIONS.md) D1. For this +step, move **current `main` code** only — do not yet port try-claude’s new +types/Protocols. + +## Reference branch / files + +```bash +# Layout inspiration only — content for step 01 is current main +git show try-claude:src/pluggy/_hook_config.py +git show try-claude:src/pluggy/_hook_markers.py +git show try-claude:src/pluggy/_hook_callers.py +git show try-claude:src/pluggy/_callers.py +git show try-claude:src/pluggy/_hooks.py +``` + +## Implementation steps + +1. Create modules; cut-and-paste from `main`. +2. Fix relative imports; avoid cycles (`TYPE_CHECKING` where needed). +3. `_hooks.py` re-exports prior public/internal names. +4. Thin-shim or delete `_callers.py` after updating internal imports to + `_execution`. +5. Verify: + +```bash +uv run pytest +uv run pre-commit run -a +``` + +Commit message: + +```text +chore(structure): split hook modules into _config, _decorators, _caller, _implementation, _execution +``` + +## Public API / back-compat + +- `from pluggy import ...` unchanged. +- `from pluggy._hooks import ...` unchanged via re-exports. + +## Tests + +- No new semantic tests; suite must stay green. + +## Done when + +- [ ] Role modules exist; `_hooks.py` is re-export layer. +- [ ] Move-only diff (no intentional behavior change). +- [ ] pytest + pre-commit green. diff --git a/design/02-configuration-objects.md b/design/02-configuration-objects.md new file mode 100644 index 00000000..c3b373ff --- /dev/null +++ b/design/02-configuration-objects.md @@ -0,0 +1,149 @@ +# 02 — Configuration objects (TypedDicts removed) + +**Status:** Replace live options encoding +**Depends on:** [01-module-reorganization.md](01-module-reorganization.md) +**Next:** [03-markers-attach-config.md](03-markers-attach-config.md) + +## Problem + +On `main`, options are `HookspecOpts` / `HookimplOpts` TypedDicts — unvalidated +dicts copied around as the live representation. try-claude introduced proper +configuration classes; those are the API. + +## Goals + +- Add `HookspecConfiguration` and `HookimplConfiguration` (`__slots__`, + `Final`, validation). +- **Remove TypedDicts from the live and public API surface.** +- Provide a **narrow pytest support shim** that accepts mapping/dict-shaped + options and converts them to configuration objects (pytest migration only). +- Export configuration classes from `pluggy`. + +## Non-goals + +- Keeping TypedDicts “for compatibility” as a dual API ([DECISIONS.md](DECISIONS.md) D4). +- Marker attachment (doc 03) — can land in the same PR if cleaner, but + conceptually next. +- `create_hookimpl` body (needs NormalImpl/WrapperImpl — doc 04); may stub + or add method signature with a TODO. + +## Target design + +```python +# src/pluggy/_config.py + +class HookspecConfiguration: + __slots__ = ("firstresult", "historic", "warn_on_impl", "warn_on_impl_args") + firstresult: Final[bool] + historic: Final[bool] + warn_on_impl: Final[Warning | None] + warn_on_impl_args: Final[Mapping[str, Warning] | None] + + def __init__(self, firstresult=False, historic=False, ...): + if historic and firstresult: + raise ValueError("cannot have a historic firstresult hook") + ... + +class HookimplConfiguration: + __slots__ = ("wrapper", "hookwrapper", "optionalhook", + "tryfirst", "trylast", "specname") + ... + # create_hookimpl added in doc 04 +``` + +Pytest support shim (name flexible — e.g. `_pytest_compat.py` or helpers in +`_config.py` marked clearly): + +```python +def hookspec_config_from_mapping(opts: Mapping[str, Any]) -> HookspecConfiguration: + """Pytest/support only: accept legacy dict-shaped options.""" + return HookspecConfiguration( + firstresult=opts.get("firstresult", False), + historic=opts.get("historic", False), + warn_on_impl=opts.get("warn_on_impl"), + warn_on_impl_args=opts.get("warn_on_impl_args"), + ) + +def hookimpl_config_from_mapping(opts: Mapping[str, Any]) -> HookimplConfiguration: + ... +``` + +Do **not** keep `TypedDict` classes in `__all__`. Do not document dicts as +the options API. Markers take kwargs that construct configuration objects +directly (doc 03). + +## Reference branch / files + +```bash +git show try-claude:src/pluggy/_hook_config.py +``` + +Port classes from try-claude; then **delete** the TypedDict definitions from +the live module (try still kept them — we go further per D4). Isolate mapping +helpers as the only dict-facing surface. + +## Implementation steps + +### Step 2.1 — Add configuration classes + +Port from try-claude `_hook_config.py` into `_config.py`. + +### Step 2.2 — Remove TypedDicts from live path + +1. Delete `HookspecOpts` / `HookimplOpts` TypedDict definitions (or move to a + private pytest-shim module if pytest tests in-tree still need the names + during transition — prefer deletion + mapping helpers). +2. Update all internal imports/usages to configuration classes. +3. Update `__init__.py` exports: add configuration classes; drop TypedDict + exports. + +### Step 2.3 — Pytest support shim + +1. Add `hookspec_config_from_mapping` / `hookimpl_config_from_mapping`. +2. Document in docstring: for pytest/support migration only, not the public + options API. +3. If pytest (downstream) needs a temporary import path, keep it private + (`pluggy._config` or `pluggy._pytest_compat`), not in `__all__`. + +### Step 2.4 — Tests + +- Validation (`historic` + `firstresult`). +- Mapping shim round-trip. +- No remaining tests that treat TypedDicts as the primary API. + +```bash +uv run pytest +uv run pre-commit run -a +``` + +Commit message: + +```text +feat(config): replace TypedDict options with Hook*Configuration classes +``` + +## Public API / back-compat + +| Before | After | +|--------|-------| +| `HookspecOpts` / `HookimplOpts` TypedDicts | **Removed** from public API | +| dict attrs on marked functions | configuration objects (doc 03) | +| — | `HookspecConfiguration` / `HookimplConfiguration` exported | +| — | private mapping shim for pytest support | + +Marker **kwargs** (`firstresult=`, `wrapper=`, …) stay — they construct +objects, they are not TypedDict literals. + +## Tests to add/update + +| File | Coverage | +|------|----------| +| `testing/test_configuration.py` | Class validation; mapping shim | +| Anything importing TypedDicts | Migrate or delete | + +## Done when + +- [ ] Configuration classes are the only live options encoding. +- [ ] TypedDicts gone from public `__all__` / docs path. +- [ ] Pytest mapping shim exists and is clearly non-API. +- [ ] pytest + pre-commit green. diff --git a/design/03-markers-attach-config.md b/design/03-markers-attach-config.md new file mode 100644 index 00000000..8cc31f4e --- /dev/null +++ b/design/03-markers-attach-config.md @@ -0,0 +1,102 @@ +# 03 — Markers attach configuration objects + +**Status:** Markers emit new types only +**Depends on:** [02-configuration-objects.md](02-configuration-objects.md) +**Next:** [04-hookimpl-wrapper-types.md](04-hookimpl-wrapper-types.md) +**Also unlocks:** [06-project-spec.md](06-project-spec.md) + +## Problem + +Markers on `main` attach dicts. After doc 02, configuration classes exist; +markers and discovery must use them exclusively. + +## Goals + +- `HookspecMarker` / `HookimplMarker` attach `HookspecConfiguration` / + `HookimplConfiguration` as `_spec` / `_impl`. +- `HookSpec` stores `config: HookspecConfiguration`. +- Manager parsing reads configuration objects only (no dict branch except + calling the pytest mapping shim if an explicit compat path is required). + +## Non-goals + +- ProjectSpec on markers (doc 06) — optional to combine. +- Impl subclass factory (doc 04). + +## Target design + +```python +def setattr_hookspec_opts(func: _F) -> _F: + config = HookspecConfiguration( + firstresult=firstresult, + historic=historic, + warn_on_impl=warn_on_impl, + warn_on_impl_args=warn_on_impl_args, + ) + setattr(func, self.project_name + "_spec", config) + return func + + +def setattr_hookimpl_opts(func: _F) -> _F: + config = HookimplConfiguration(...) + setattr(func, self.project_name + "_impl", config) + return func +``` + +Decorator kwargs unchanged. Attribute **value type** is configuration object. + +```mermaid +sequenceDiagram + participant Dev + participant Marker as HookimplMarker + participant Func as function + participant PM as PluginManager + Dev->>Marker: "@hookimpl(tryfirst=True)" + Marker->>Func: "project_impl = HookimplConfiguration" + Dev->>PM: register(plugin) + PM->>Func: getattr config + Note over PM: no dict path +``` + +## Reference branch / files + +```bash +git show try-claude:src/pluggy/_hook_markers.py +git show try-claude:src/pluggy/_manager.py +``` + +## Implementation steps + +1. Update markers to construct/attach configuration objects. +2. Update `HookSpec` and manager discovery to use `.firstresult` etc. +3. Remove any `normalize_hookimpl_opts` dict mutation on the live path. +4. Tests: attached attr is configuration instance; historic+firstresult + raises at decoration time. + +```bash +uv run pytest && uv run pre-commit run -a +``` + +Commit message: + +```text +feat(decorators): attach Hook*Configuration objects on marked functions +``` + +## Public API / back-compat + +- Decorator kwargs stable. +- Private attribute payload is now a configuration object (not a dict). +- Callers that poked dict attrs must use the pytest mapping shim or migrate. + +## Tests + +| File | Coverage | +|------|----------| +| `testing/test_configuration.py` | Attachment + validation | +| Registration / marker tests | Adapt off dict assumptions | + +## Done when + +- [ ] No marker or manager live path attaches/reads TypedDicts. +- [ ] Suite green. diff --git a/design/04-hookimpl-wrapper-types.md b/design/04-hookimpl-wrapper-types.md new file mode 100644 index 00000000..833a6863 --- /dev/null +++ b/design/04-hookimpl-wrapper-types.md @@ -0,0 +1,169 @@ +# 04 — HookImpl / WrapperImpl types + CompletionHook setup + +**Status:** Core object encoding for implementations +**Depends on:** [03-markers-attach-config.md](03-markers-attach-config.md) +**Next:** [05-hookcaller-and-execution.md](05-hookcaller-and-execution.md) + +## Problem + +On `main`, one `HookImpl` class carries bool flags (`wrapper`, `hookwrapper`) +copied from a dict. Multicall branches on those flags. try-claude introduced +real subclasses and moved wrapper setup/teardown ownership onto `WrapperImpl` +via **CompletionHook** — that encoding is the point of this series. + +## Goals + +- `HookImpl` base; `NormalImpl` and `WrapperImpl` subclasses. +- `HookimplConfiguration.create_hookimpl(...) -> NormalImpl | WrapperImpl` + (**fix try footgun:** normals must be `NormalImpl`, not bare `HookImpl`). +- Store `hookimpl_config: HookimplConfiguration` (not a dict / TypedDict). +- Move arg binding to `HookImpl._get_call_args`. +- Define `@runtime_checkable` `CompletionHook` Protocol. +- Implement `WrapperImpl.setup_and_get_completion_hook(...)`. + +## Non-goals + +- Rewiring `_multicall` / callers to dual lists (doc 05) — but this doc should + leave APIs ready so 05 is mostly wiring. +- Async `maybe_submit` (doc 07). + +## Target design + +```python +# _implementation.py (from try-claude _hook_callers.py impl section) + +@runtime_checkable +class CompletionHook(Protocol): + def __call__( + self, + result: object | list[object] | None, + exception: BaseException | None, + ) -> tuple[object | list[object] | None, BaseException | None]: ... + +class HookImpl: + function: Final[...] + argnames: Final[tuple[str, ...]] + hookimpl_config: Final[HookimplConfiguration] # not opts dict + # convenience Final bools mirrored from config OK for attribute compat + def _get_call_args(self, caller_kwargs: Mapping[str, object]) -> list[object]: ... + +class NormalImpl(HookImpl): + def __init__(..., hook_impl_config: HookimplConfiguration): + if hook_impl_config.wrapper or hook_impl_config.hookwrapper: + raise ValueError("use WrapperImpl") + super().__init__(...) + +class WrapperImpl(HookImpl): + def __init__(..., hook_impl_config: HookimplConfiguration): + if not (hook_impl_config.wrapper or hook_impl_config.hookwrapper): + raise ValueError("use NormalImpl / HookImpl for normals") + super().__init__(...) + + 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 = self.function(*args) + next(wrapper_gen) # setup; raise wrapfail if no yield + + def completion_hook(result, exception): + # send/throw; map StopIteration.value; see try-claude body + ... + return new_result, new_exception + + return completion_hook +``` + +Factory on config: + +```python +# _config.py +def create_hookimpl(self, plugin, plugin_name, function) -> NormalImpl | WrapperImpl: + if self.wrapper or self.hookwrapper: + return WrapperImpl(plugin, plugin_name, function, self) + return NormalImpl(plugin, plugin_name, function, self) # NOT HookImpl +``` + +```mermaid +flowchart LR + Config[HookimplConfiguration] -->|"create_hookimpl"| Decision{wrapper?} + Decision -->|no| Normal[NormalImpl] + Decision -->|yes| Wrapper[WrapperImpl] + Wrapper -->|"setup_and_get_completion_hook"| CH[CompletionHook] +``` + +## Reference branch / files + +```bash +git show try-claude:src/pluggy/_hook_callers.py # HookImpl, NormalImpl, WrapperImpl, CompletionHook +git show try-claude:src/pluggy/_hook_config.py # create_hookimpl — fix return type +git show try-claude:src/pluggy/_callers.py # run_old_style_hookwrapper +``` + +**Do not** use reiterate’s unused subclasses. + +## Implementation steps + +### Step 4.1 — Types + factory + +1. Add `NormalImpl` / `WrapperImpl` / `CompletionHook` Protocol. +2. Switch `HookImpl` to hold `HookimplConfiguration`. +3. Add `_get_call_args`. +4. Implement `create_hookimpl` returning **`NormalImpl | WrapperImpl`**. +5. Point registration at `create_hookimpl`. + +### Step 4.2 — CompletionHook setup on WrapperImpl + +Port `setup_and_get_completion_hook` from try-claude carefully (StopIteration / +RuntimeError edge cases around `#544`). + +### Step 4.3 — Interim multicall + +Until doc 05, multicall may still accept a combined list but should start +calling `setup_and_get_completion_hook` for wrappers if feasible; otherwise +keep flag path one commit and flip fully in 05. Prefer **landing CompletionHook +multicall together with caller split in 05** if interim is messy — then this +step only adds types + factory + method, with unit tests for setup/teardown +in isolation. + +### Step 4.4 — Tests + +- Factory returns correct subclass. +- Wrong config on NormalImpl/WrapperImpl raises. +- `_get_call_args` missing arg → `HookCallError`. +- Wrapper setup “did not yield” / completion replaces result/exception + (can be fuller in 05). + +```bash +uv run pytest && uv run pre-commit run -a +``` + +Commit message: + +```text +feat(implementation): add NormalImpl, WrapperImpl, and CompletionHook setup API +``` + +## Public API / back-compat + +- `HookImpl` remains importable; subclasses are additive. +- Prefer exporting `HookImpl` (and optionally subclasses) from `pluggy`. +- `CompletionHook` may stay internal or be exported — try kept it near + callers; exporting is fine if useful for typing. + +## Tests + +| File | Coverage | +|------|----------| +| New or `testing/test_hookcaller.py` | Factory + subclass invariants | +| Multicall tests | Updated in doc 05 | + +## Done when + +- [ ] `create_hookimpl` never returns bare `HookImpl` for normals. +- [ ] `WrapperImpl.setup_and_get_completion_hook` exists and matches try semantics. +- [ ] `CompletionHook` is a `@runtime_checkable` Protocol. +- [ ] Suite green. diff --git a/design/05-hookcaller-and-execution.md b/design/05-hookcaller-and-execution.md new file mode 100644 index 00000000..cd540fc9 --- /dev/null +++ b/design/05-hookcaller-and-execution.md @@ -0,0 +1,222 @@ +# 05 — Protocol callers + CompletionHook multicall + +**Status:** Critical calling / wrapping / tracing redesign +**Depends on:** [04-hookimpl-wrapper-types.md](04-hookimpl-wrapper-types.md) +**Next:** [07-async-submitter.md](07-async-submitter.md) (async wires into this) +**Related:** [06-project-spec.md](06-project-spec.md) may land in parallel + +## Problem + +On `main`, one `HookCaller` class mixes normal and historic behavior; one +`_hookimpls` list interleaves wrappers; `_multicall` branches on bool flags and +manages generator teardowns inline. Tracing wraps a single-sequence `_hookexec`. + +try-claude replaces this with: + +- `@runtime_checkable` `HookCaller` Protocol +- `NormalHookCaller` / `HistoricHookCaller` / `SubsetHookCaller` +- Split typed lists +- Dual-sequence `_multicall` driven by **CompletionHook** + +This is the critical simplification of the inner hook engine. + +## Goals + +- Protocol-based caller surface (`isinstance(x, HookCaller)` works). +- Split storage: `_normal_hookimpls: list[NormalImpl]`, + `_wrapper_hookimpls: list[WrapperImpl]`. +- Historic caller: no wrappers; call history replay unchanged in behavior. +- `_multicall(hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult, ...)` + uses CompletionHook LIFO teardown — **no** `.wrapper` flag branching. +- Update `PluginManager._hookexec` / `add_hookcall_monitoring` / + `enable_tracing` for the new signature (combine lists for before/after + callbacks if needed for back-compat of monitoring shapes). +- Keep `Result` / `TagTracer` public APIs stable. + +## Non-goals + +- Async activation details beyond accepting a `Submitter` parameter stub + (full behavior in doc 07). For this step, either thread a default + `Submitter()` or add the parameter in 07 — prefer adding the parameter + here with a no-op-friendly submitter so 07 only activates `run_async`. +- Redesigning monitoring callback *semantics* beyond signature adaptation. + +## Target design + +### HookCaller Protocol (critical) + +```python +@runtime_checkable +class HookCaller(Protocol): + @property + def name(self) -> str: ... + @property + def spec(self) -> HookSpec | None: ... + def get_hookimpls(self) -> list[HookImpl]: ... + def _add_hookimpl(self, hookimpl: HookImpl) -> None: ... + + # call / call_historic / call_extra / etc. as in try-claude +``` + +Concrete classes (not a fat base with historic flags): + +| Class | Role | +|-------|------| +| `NormalHookCaller` | Split lists; `__call__` / `call_extra` | +| `HistoricHookCaller` | `_hookimpls` + `_call_history`; rejects wrappers | +| `SubsetHookCaller` | Filtered view over another caller | + +`_insert_hookimpl_into_list` preserves `[trylast, normal, tryfirst]` ordering +per list. + +### Multicall (CompletionHook) + +```python +def _multicall( + hook_name: str, + normal_impls: Sequence[NormalImpl], # or HookImpl until typed tightened + wrapper_impls: Sequence[WrapperImpl], + caller_kwargs: Mapping[str, object], + firstresult: bool, + async_submitter: Submitter, # wire fully in doc 07 +) -> object | list[object]: + completion_hooks: list[CompletionHook] = [] + results: list[object] = [] + exception: BaseException | None = None + try: + for wrapper_impl in reversed(wrapper_impls): + completion_hooks.append( + wrapper_impl.setup_and_get_completion_hook(hook_name, caller_kwargs) + ) + for normal_impl in reversed(normal_impls): + args = normal_impl._get_call_args(caller_kwargs) + res = normal_impl.function(*args) + if res is not None: + # doc 07: maybe_submit if Awaitable + results.append(res) + if firstresult: + break + except BaseException as exc: + exception = exc + + result: object | list[object] | None + result = (results[0] if results else None) if firstresult else results + + for completion_hook in reversed(completion_hooks): + result, exception = completion_hook(result, exception) + + if exception is not None: + raise exception + return result +``` + +```mermaid +sequenceDiagram + participant Caller as NormalHookCaller + participant MC as multicall + participant W as WrapperImpl + participant N as NormalImpl + participant CH as CompletionHook + Caller->>MC: normal_impls, wrapper_impls + loop wrappers reversed + MC->>W: setup_and_get_completion_hook + W-->>MC: CompletionHook + end + loop normals reversed + MC->>N: function args + end + loop completion LIFO + MC->>CH: result, exception + CH-->>MC: result, exception + end +``` + +### Tracing / monitoring + +Adapt `add_hookcall_monitoring` traced `_hookexec` to the dual-sequence + +submitter signature. For before/after callbacks that historically received one +list, combine `[*normal_impls, *wrapper_impls]` (try-claude pattern) so +external tracers keep working. + +## Reference branch / files + +```bash +git show try-claude:src/pluggy/_hook_callers.py +git show try-claude:src/pluggy/_callers.py +git show try-claude:src/pluggy/_manager.py +git show try-claude:testing/test_hookcaller.py +git show try-claude:testing/test_multicall.py +``` + +**Authority: try-claude only.** Ignore reiterate’s adapter multicall and +class-hierarchy callers. + +## Implementation steps + +### Step 5.1 — Protocol + concrete callers + +1. Introduce `HookCaller` Protocol (`@runtime_checkable`). +2. Implement `NormalHookCaller` / `HistoricHookCaller` / `SubsetHookCaller`. +3. Split lists + `_insert_hookimpl_into_list`. +4. Manager creates the correct caller when adding specs / registering impls. +5. Keep `_HookCaller` alias if needed. + +### Step 5.2 — CompletionHook multicall + +1. Replace `_execution._multicall` with try-claude CompletionHook version. +2. Remove flag-based wrapper branching from the loop. +3. Wire `_hookexec` signature through manager + callers. + +### Step 5.3 — Tracing + +1. Update `add_hookcall_monitoring` / `enable_tracing`. +2. Preserve before/after observable behavior via combined list if needed. + +### Step 5.4 — Tests + +Port/adapt try-claude `test_hookcaller.py` / `test_multicall.py` coverage: + +- Ordering across split lists. +- Wrapper teardown order (LIFO). +- Exception propagation through completion hooks. +- Historic rejects wrappers; replay works. +- `isinstance(caller, HookCaller)` is True for concretes. +- Tracing undo / before/after still fire. + +```bash +uv run pytest && uv run pre-commit run -a +``` + +Commit message(s): + +```text +refactor(caller): Protocol-based NormalHookCaller and HistoricHookCaller with split impl lists + +refactor(execution): CompletionHook-based multicall +``` + +## Public API / back-compat + +- `HookCaller` remains the public name; it is now a Protocol (runtime + checkable). Document the change for type checkers. +- Export `HistoricHookCaller` (and optionally `NormalHookCaller`) if try did. +- Calling behavior (firstresult, wrappers old/new, historic) unchanged + observably. +- Monitoring callbacks: keep combined impl list argument shape if that is + what try-claude preserved. + +## Tests + +| File | Coverage | +|------|----------| +| `testing/test_hookcaller.py` | Port try-claude additions | +| `testing/test_multicall.py` | CompletionHook semantics | +| Tracing tests | Signature adaptation | + +## Done when + +- [ ] `HookCaller` is `@runtime_checkable` Protocol; concretes pass isinstance. +- [ ] Dual lists + dual-sequence multicall; CompletionHook LIFO teardown. +- [ ] No wrapper/normal flag branching inside multicall. +- [ ] Historic + tracing behaviors preserved. +- [ ] Suite + pre-commit green. diff --git a/design/06-project-spec.md b/design/06-project-spec.md new file mode 100644 index 00000000..96f1c2cc --- /dev/null +++ b/design/06-project-spec.md @@ -0,0 +1,104 @@ +# 06 — ProjectSpec + +**Status:** Additive project hub API +**Depends on:** [03-markers-attach-config.md](03-markers-attach-config.md) +**May parallelize with:** docs 04–05 +**Next (series):** [07-async-submitter.md](07-async-submitter.md) still depends on 05 + +## Problem + +Projects today juggle a bare `project_name` string, separately constructed +`HookspecMarker` / `HookimplMarker`, and `PluginManager(project_name)`. +try-claude’s `ProjectSpec` unifies that hub and lets markers/managers accept +`str | ProjectSpec`. + +## Goals + +- Add `ProjectSpec` with `project_name`, `.hookspec`, `.hookimpl`, + `create_plugin_manager()`, config helpers. +- Markers and `PluginManager` accept `str | ProjectSpec`. +- Export `ProjectSpec` from `pluggy`. + +## Non-goals + +- Changing the meaning of project_name matching. +- Forcing all callers to migrate off strings. + +## Target design + +```python +# src/pluggy/_project.py (port try-claude) + + +class ProjectSpec: + def __init__( + self, + project_name: str, + plugin_manager_cls: type[PluginManager] | None = None, + ) -> None: ... + + @property + def hookspec(self) -> HookspecMarker: ... + + @property + def hookimpl(self) -> HookimplMarker: ... + + def create_plugin_manager(self, **kwargs: Any) -> PluginManager: ... + + def get_hookspec_config(self, **kwargs) -> HookspecConfiguration: ... + def get_hookimpl_config(self, **kwargs) -> HookimplConfiguration: ... +``` + +Markers / manager: + +```python +def __init__(self, project_name_or_spec: str | ProjectSpec): ... + + +# PluginManager likewise; project_name becomes a property from the spec +``` + +## Reference branch / files + +```bash +git show try-claude:src/pluggy/_project.py +git show try-claude:src/pluggy/_hook_markers.py # str | ProjectSpec +git show try-claude:src/pluggy/_manager.py +git show try-claude:testing/test_project_spec.py +git show try-claude:testing/benchmark.py # ProjectSpec usage +``` + +## Implementation steps + +1. Add `_project.py` from try-claude. +2. Teach markers + `PluginManager` to accept `str | ProjectSpec`. +3. Export from `__init__.py`. +4. Port `testing/test_project_spec.py`. +5. Update benchmark if it benefits from ProjectSpec helpers. + +```bash +uv run pytest && uv run pre-commit run -a +``` + +Commit message: + +```text +feat(project): add ProjectSpec hub for markers and PluginManager +``` + +## Public API / back-compat + +- Additive: strings still work everywhere. +- New: `ProjectSpec` and dual acceptance. + +## Tests + +| File | Coverage | +|------|----------| +| `testing/test_project_spec.py` | Port from try-claude | + +## Done when + +- [ ] `ProjectSpec` exported and tested. +- [ ] String and ProjectSpec construction paths both work. +- [ ] Suite green. diff --git a/design/07-async-submitter.md b/design/07-async-submitter.md new file mode 100644 index 00000000..54598c87 --- /dev/null +++ b/design/07-async-submitter.md @@ -0,0 +1,169 @@ +# 07 — Greenlet async Submitter + +**Status:** Optional async bridge for awaitable hook results +**Depends on:** [05-hookcaller-and-execution.md](05-hookcaller-and-execution.md) +**Authority:** try-claude only (persistent Submitter — not reiterate monkeypatch) + +## Problem + +Pluggy’s multicall is synchronous. Downstream (e.g. Datasette / +[await-me-maybe](https://simonwillison.net/2020/Sep/2/await-me-maybe/)) wants +hook impls to return awaitables that are awaited when the host runs under an +async context, without rewriting the entire plugin engine as async. + +## Goals + +- Add `src/pluggy/_async.py`: `Submitter`, `maybe_submit`, `require_await`, + `run`, `async_generator_to_sync`. +- Persistent `Submitter` on `PluginManager`, threaded through `_hookexec` / + callers into `_multicall` (already parameterized in doc 05). +- After each **normal** impl call, if result is `Awaitable`, + `async_submitter.maybe_submit(res)`. +- `await pm.run_async(lambda: pm.hook.some_hook())` activates the greenlet + bridge via `Submitter.run`. +- Optional packaging: `pluggy[async] = ["greenlet"]`; testing deps include + greenlet. +- Fix try-claude footgun: `Submitter.run` must allow legitimate `None` + returns (use a sentinel). + +## Non-goals + +- Auto-await of async wrapper generators (document + `async_generator_to_sync` as a manual helper). +- Nested `run_async` (hard-fail: Submitter already active). +- Making `Submitter` a public `__all__` export unless explicitly desired + (try kept it private — keep private). + +## Target design + +```mermaid +flowchart TB + asyncioTask["asyncio task parent greenlet"] + asyncioTask --> runAsync["pm.run_async sync_func"] + runAsync --> worker["worker greenlet runs sync hook tree"] + worker --> maybe["maybe_submit awaitable"] + maybe -->|"parent.switch coro"| asyncioTask + asyncioTask -->|"await coro"| worker +``` + +### Submitter + +```python +class Submitter: + def maybe_submit(self, coro: Awaitable[_T]) -> _T | Awaitable[_T]: + """Await if active; else return awaitable unchanged (await-me-maybe).""" + + def require_await(self, coro: Awaitable[_T]) -> _T: + """Await if active; else RuntimeError.""" + + async def run(self, sync_func: Callable[[], _T]) -> _T: + """Require greenlet; run sync_func in worker greenlet; await switched coros.""" +``` + +### PluginManager + +```python +def __init__(..., async_submitter: Submitter | None = None): + self._async_submitter = async_submitter or Submitter() + +async def run_async(self, func: Callable[[], _T]) -> _T: + return await self._async_submitter.run(func) +``` + +No temporary replacement of `_inner_hookexec` for async — activation is +entirely `Submitter.run` setting `_active_submitter`. + +### Multicall integration + +```python +res = normal_impl.function(*args) +if res is not None: + if isinstance(res, Awaitable): + res = async_submitter.maybe_submit(res) + results.append(res) +``` + +## Reference branch / files + +```bash +git show try-claude:src/pluggy/_async.py +git show try-claude:src/pluggy/_callers.py # maybe_submit site +git show try-claude:src/pluggy/_manager.py # run_async, persistent submitter +git show try-claude:testing/test_async.py +git show try-claude:pyproject.toml # optional-dependencies async +``` + +**Rejected:** `reiterate-claude` ephemeral submitter + `_inner_hookexec` wrap. + +## Implementation steps + +### Step 7.1 — `_async.py` + +1. Port from try-claude. +2. Fix `None` result footgun with a sentinel. +3. Lazy-import greenlet inside `run` / helpers. + +### Step 7.2 — Packaging + +```toml +[project.optional-dependencies] +async = ["greenlet"] +``` + +Add `greenlet` (and `types-greenlet` if needed) to the testing dependency group. + +### Step 7.3 — Wire Submitter + +1. Ensure doc 05’s `_hookexec` / multicall already take `async_submitter`. +2. Store on PM; pass into callers at construction (try-claude). +3. Implement `run_async` without monkeypatching `_inner_hookexec`. + +### Step 7.4 — Tests (`testing/test_async.py`) + +Port try-claude suite; minimum: + +1. Sync-only under `run_async` +2. Mixed sync + `async def` / coroutine-returning hooks +3. Outside context: awaitable left in results (await-me-maybe) +4. Missing greenlet → clear `RuntimeError` +5. `require_await` outside context fails +6. `async_generator_to_sync` basic / send / throw / inactive +7. Nested `run_async` rejected +8. `run_async` returning `None` succeeds (footgun regression test) + +```bash +uv run pytest && uv run pre-commit run -a +``` + +Commit message: + +```text +feat(async): greenlet Submitter and PluginManager.run_async +``` + +## Public API / back-compat + +| API | Notes | +|-----|-------| +| `PluginManager.run_async` | New public method | +| `pluggy[async]` | Optional extra | +| Sync `pm.hook.x()` | Unchanged; may contain raw awaitables in results | +| Wrappers | Still sync generators; async wrappers need manual helper | +| `Submitter` | Private module | + +## Behavior matrix + +| Scenario | Behavior | +|----------|----------| +| Sync hook, sync call | Unchanged | +| Awaitable outside `run_async` | Left as awaitable (`maybe_submit` no-op) | +| Awaitable inside `run_async` | Awaited; value enters results | +| Missing greenlet | `run_async` raises | +| Nested `run_async` | Raises “already active” | + +## Done when + +- [ ] Persistent Submitter wired; no exec monkeypatch. +- [ ] Optional `[async]` extra declared. +- [ ] try-claude async tests ported + `None` return regression test. +- [ ] Suite + pre-commit green. diff --git a/design/DECISIONS.md b/design/DECISIONS.md new file mode 100644 index 00000000..c4b3cb2c --- /dev/null +++ b/design/DECISIONS.md @@ -0,0 +1,140 @@ +# Design decisions (try-claude authority) + +Experimental work on **`try-claude`** established the intended architecture. +**`reiterate-claude` was a failed experiment** that destroyed good abstractions +(unused subclasses, flag-driven multicall, CompletionHook removed, ephemeral +submitter monkeypatch, class-hierarchy callers instead of Protocols). Do **not** +copy reiterate logic. At most skim it for accidental rename ideas; never treat +it as design authority. + +**Primary reference:** `try-claude` / `origin/try-claude-backup`. + +## D1 — Module layout + +**Decision:** Split roles into focused modules. Prefer try-claude’s split, +renamed for clarity if needed: + +| try-claude | Target | +|------------|--------| +| `_hook_config.py` | `_config.py` | +| `_hook_markers.py` | `_decorators.py` | +| `_hook_callers.py` | `_caller.py` (callers + Protocol) + `_implementation.py` (HookImpl hierarchy) | +| `_callers.py` | `_execution.py` (multicall + CompletionHook orchestration) | +| `_project.py` / `_async.py` | same names | + +`_hooks.py` remains a thin re-export/compat layer where useful. + +**Rejected:** Anything from reiterate’s weakened `_execution` / `_caller` / +`_implementation` bodies. + +## D2 — HookCaller: runtime_checkable Protocols (critical) + +**Decision:** `HookCaller` is a `@runtime_checkable` `Protocol`. Concrete +callers (`NormalHookCaller`, `HistoricHookCaller`, `SubsetHookCaller`) +structurally implement it. Use Protocols elsewhere for exec/monitoring +boundaries where try-claude did (`_HookExec`, `CompletionHook`, …). + +**Rejected:** reiterate’s concrete inheritance hierarchy that erased the +interface contract. + +**Why:** Checkable protocols are how we type and isinstance-test callers +without freezing a brittle base class. This is a **critical** design point, +not optional polish. + +## D3 — CompletionHook teardown (critical) + +**Decision:** Wrappers expose teardown as `CompletionHook`: + +```python +@runtime_checkable +class CompletionHook(Protocol): + def __call__( + self, + result: object | list[object] | None, + exception: BaseException | None, + ) -> tuple[object | list[object] | None, BaseException | None]: ... +``` + +`WrapperImpl.setup_and_get_completion_hook(hook_name, caller_kwargs)` runs +setup (`next(gen)`) and returns the completion closure. Old-style wrappers +are adapted *inside* that method via `run_old_style_hookwrapper`. + +`_multicall` phases: + +1. Setup wrappers → collect `CompletionHook`s +2. Run `NormalImpl`s (arg bind via `_get_call_args`) +3. Run completion hooks LIFO; each may replace `(result, exception)` +4. Raise or return + +**Rejected:** reiterate’s “CompletionHook no longer needed” adapter + flag +dispatch inside multicall. That is the failed simplification. + +**Why:** CompletionHook is the critical enhancement that simplifies the +inner hook engine: multicall orchestrates phases; wrappers own +setup/teardown; no `.wrapper` / `.hookwrapper` branching in the hot loop. + +## D4 — New types end-to-end; TypedDicts are gone + +**Decision:** Live API uses configuration **classes** and impl **subclasses**: + +- `HookspecConfiguration` / `HookimplConfiguration` (`__slots__` + `Final`) +- `HookImpl` / `NormalImpl` / `WrapperImpl` +- `HookimplConfiguration.create_hookimpl(...) -> NormalImpl | WrapperImpl` + (fix try-claude footgun: normals must be `NormalImpl`, not bare `HookImpl`) +- Typed split lists on `NormalHookCaller`; dual-sequence `_multicall` + +**TypedDicts (`HookspecOpts` / `HookimplOpts`) are removed from the public +and internal live path.** They are not “kept for compatibility.” + +**Pytest support shim only:** a narrow compatibility helper (for pytest / +downstream that still hand-builds dict-shaped options during migration) may +accept mappings and convert them into configuration objects. That shim is +not the API; the API is the configuration classes. Do not re-export +TypedDicts as the preferred types in `__all__`. + +**Rejected:** reiterate’s unused subclasses; keeping TypedDicts as the +long-term dual API. + +## D5 — Async: persistent Submitter (try-claude) + +**Decision:** `PluginManager` owns a `Submitter`, threaded through +`_hookexec` / callers into `_multicall`. `maybe_submit` on awaitable normal +results; inactive = pass-through (await-me-maybe). +`await pm.run_async(...)` → `Submitter.run`. Optional `pluggy[async] = +["greenlet"]`. + +**Rejected:** reiterate’s ephemeral Submitter + `_inner_hookexec` monkeypatch. + +## D6 — ProjectSpec + +**Decision:** Additive hub. Markers and `PluginManager` accept +`str | ProjectSpec` (try-claude). + +## D7 — Result and tracing + +**Decision:** Keep `Result` / `TagTracer` public APIs. Update monitoring / +`_hookexec` signatures for split lists, submitter, and CompletionHook-era +multicall. Tracing callbacks may receive a combined impl list for back-compat +of the before/after hook shapes (as try-claude did). + +## D8 — Bugs to fix when porting try-claude + +1. `create_hookimpl` → return `NormalImpl` for non-wrappers. +2. `Submitter.run` → sentinel instead of `if result is None` failure. +3. Normal list typed as `list[NormalImpl]`. +4. Remove TypedDict definitions from the live API surface; isolate any dict + acceptance in an explicit pytest/support shim module or helper. + +## Reference + +```bash +git show try-claude:src/pluggy/_hook_callers.py +git show try-claude:src/pluggy/_callers.py +git show try-claude:src/pluggy/_hook_config.py +git show try-claude:src/pluggy/_async.py +git show try-claude:testing/test_async.py +git show try-claude:testing/test_hookcaller.py +git show try-claude:testing/test_project_spec.py +``` + +Do not use `reiterate-claude` as a logic source. diff --git a/design/README.md b/design/README.md new file mode 100644 index 00000000..3a8bb790 --- /dev/null +++ b/design/README.md @@ -0,0 +1,115 @@ +# Pluggy internal refactorings — design docs + +Agent-oriented design documents for re-implementing **`try-claude`** onto +`main` as a clean, ordered series of commits. + +**`reiterate-claude` was a failed experiment** — do not copy its logic. +These docs are **not** part of the published Sphinx docs. + +Read [DECISIONS.md](DECISIONS.md) before implementing anything. + +## Goals + +- **New types only:** `HookspecConfiguration` / `HookimplConfiguration`; + TypedDicts removed from the live API (pytest support shim may convert dicts). +- **Typed impls:** `NormalImpl` / `WrapperImpl` + split lists + dual-sequence + `_multicall`. +- **CompletionHook** (critical): wrappers own setup/teardown; multicall is + phase orchestration only. +- **Protocols** (critical): `@runtime_checkable` `HookCaller`, `CompletionHook`, + `_HookExec`, etc. +- **Async:** persistent greenlet `Submitter` + `PluginManager.run_async`. +- Public *behavior* stays compatible; the *encoding* of options upgrades to + objects. + +## Non-goals + +- Keeping TypedDicts as a dual long-term API. +- Adopting anything from reiterate’s destroyed abstractions. +- Redesigning `Result` / `TagTracer` public APIs. +- Auto-await of async wrapper generators in v1 async. + +## Target architecture + +| Area | Choice | +|------|--------| +| Module layout | try-claude split; optional clearer file names (`_config`, `_decorators`, `_caller`, `_implementation`, `_execution`) | +| HookCaller | `@runtime_checkable` Protocol + Normal / Historic / Subset concretes | +| Wrapping | `CompletionHook` via `WrapperImpl.setup_and_get_completion_hook` | +| Options | Configuration classes only; dict shim for pytest support only | +| Impls | `NormalImpl` / `WrapperImpl`; factory returns the right subclass | +| Async | Persistent `Submitter`; `pluggy[async]` | +| Project | `ProjectSpec`; `str \| ProjectSpec` on markers/PM | + +```mermaid +flowchart TB + subgraph markers [Markers and config] + ProjectSpec --> HookspecMarker + ProjectSpec --> HookimplMarker + HookspecMarker --> HookspecConfiguration + HookimplMarker --> HookimplConfiguration + PytestShim["pytest dict shim"] -->|"to config objects"| HookimplConfiguration + end + subgraph callers [Callers] + HookCallerProto["HookCaller Protocol"] + NormalHookCaller --> NormalList["list of NormalImpl"] + NormalHookCaller --> WrapperList["list of WrapperImpl"] + HistoricHookCaller --> HistoricList["list of NormalImpl"] + end + subgraph exec [Execution] + WrapperList -->|"setup_and_get_completion_hook"| CompletionHooks["CompletionHook LIFO"] + NormalList --> Multicall["_multicall"] + CompletionHooks --> Multicall + Submitter --> Multicall + end + PluginManager --> NormalHookCaller + PluginManager --> HistoricHookCaller + PluginManager -->|"run_async"| Submitter + HookimplConfiguration -->|"create_hookimpl"| NormalList + HookimplConfiguration -->|"create_hookimpl"| WrapperList +``` + +## Document index (implement in this order) + +| # | Document | Depends on | Summary | +|---|----------|------------|---------| +| 0 | [DECISIONS.md](DECISIONS.md) | — | Authority, Protocols, CompletionHook, TypedDict removal | +| 1 | [01-module-reorganization.md](01-module-reorganization.md) | — | Split into role modules | +| 2 | [02-configuration-objects.md](02-configuration-objects.md) | 01 | Config classes; remove TypedDicts; pytest shim | +| 3 | [03-markers-attach-config.md](03-markers-attach-config.md) | 02 | Markers attach config objects | +| 4 | [04-hookimpl-wrapper-types.md](04-hookimpl-wrapper-types.md) | 03 | NormalImpl / WrapperImpl + CompletionHook setup API | +| 5 | [05-hookcaller-and-execution.md](05-hookcaller-and-execution.md) | 04 | Protocol callers + CompletionHook multicall + tracing | +| 6 | [06-project-spec.md](06-project-spec.md) | 03 | ProjectSpec hub | +| 7 | [07-async-submitter.md](07-async-submitter.md) | 05 | Persistent Submitter + run_async | + +## Branch map + +| Branch | Role | +|--------|------| +| **`try-claude`** | **Only design authority** | +| `reiterate-claude` | Failed experiment — **do not port** | +| `etablish-claude*` | Out of scope | + +```bash +git show try-claude:src/pluggy/_hook_callers.py +git show try-claude:src/pluggy/_callers.py +git show try-claude:src/pluggy/_hook_config.py +git show try-claude:src/pluggy/_async.py +``` + +## How to assign agents + +1. Read [DECISIONS.md](DECISIONS.md). +2. One agent per doc, in order (06 may follow 03). +3. Port from `try-claude`; fix known footguns listed in D8. +4. After every step: `uv run pytest` && `uv run pre-commit run -a`. + +## Verification checklist (whole series) + +- [ ] No live TypedDict options path; pytest shim isolated if present. +- [ ] `isinstance(caller, HookCaller)` works via runtime_checkable Protocol. +- [ ] Wrappers tear down only through CompletionHook. +- [ ] `create_hookimpl` → `NormalImpl | WrapperImpl`. +- [ ] Dual-sequence multicall; no flag branching for wrapper vs normal. +- [ ] Async: persistent submitter, no exec monkeypatch. +- [ ] `uv run pytest` / `uv run pre-commit run -a` green. From 7489fdb178004e3430e57ec27a77945830a34e74 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 22 Jul 2026 12:24:41 +0200 Subject: [PATCH 2/8] chore(structure): split hook modules into role-specific files Move hook types, markers, callers, implementations, and multicall out of the monolithic _hooks/_callers modules so later typed-config and CompletionHook work can land without thrashing one huge file. Keep _hooks and _callers as re-export shims for import compatibility. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- src/pluggy/_caller.py | 309 ++++++++++++++ src/pluggy/_callers.py | 183 +------- src/pluggy/_config.py | 54 +++ src/pluggy/_decorators.py | 354 +++++++++++++++ src/pluggy/_execution.py | 174 ++++++++ src/pluggy/_hooks.py | 783 ++-------------------------------- src/pluggy/_implementation.py | 80 ++++ 7 files changed, 1027 insertions(+), 910 deletions(-) create mode 100644 src/pluggy/_caller.py create mode 100644 src/pluggy/_config.py create mode 100644 src/pluggy/_decorators.py create mode 100644 src/pluggy/_execution.py create mode 100644 src/pluggy/_implementation.py diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py new file mode 100644 index 00000000..5569c631 --- /dev/null +++ b/src/pluggy/_caller.py @@ -0,0 +1,309 @@ +""" +Hook callers and relay. +""" + +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Mapping +from collections.abc import Sequence +from collections.abc import Set +from typing import Any +from typing import Final +from typing import final +from typing import TYPE_CHECKING +from typing import TypeAlias +import warnings + +from ._config import HookimplOpts +from ._config import HookspecOpts +from ._decorators import _Namespace +from ._decorators import HookSpec +from ._implementation import _Plugin +from ._implementation import HookImpl + + +_HookExec: TypeAlias = Callable[ + [str, Sequence[HookImpl], Mapping[str, object], bool], + object | list[object], +] + + +@final +class HookRelay: + """Hook holder object for performing 1:N hook calls where N is the number + of registered plugins.""" + + __slots__ = ("__dict__",) + + def __init__(self) -> None: + """:meta private:""" + + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> HookCaller: ... + + +# Historical name (pluggy<=1.2), kept for backward compatibility. +_HookRelay = HookRelay + + +_CallHistory: TypeAlias = list[ + tuple[Mapping[str, object], Callable[[Any], None] | None] +] + + +class HookCaller: + """A caller of all registered implementations of a hook specification.""" + + __slots__ = ( + "name", + "spec", + "_hookexec", + "_hookimpls", + "_call_history", + ) + + def __init__( + self, + name: str, + hook_execute: _HookExec, + specmodule_or_class: _Namespace | None = None, + spec_opts: HookspecOpts | None = None, + ) -> None: + """:meta private:""" + #: Name of the hook getting called. + self.name: Final = name + self._hookexec: Final = hook_execute + # The hookimpls list. The caller iterates it *in reverse*. Format: + # 1. trylast nonwrappers + # 2. nonwrappers + # 3. tryfirst nonwrappers + # 4. trylast wrappers + # 5. wrappers + # 6. tryfirst wrappers + self._hookimpls: Final[list[HookImpl]] = [] + self._call_history: _CallHistory | None = None + # TODO: Document, or make private. + self.spec: HookSpec | None = None + if specmodule_or_class is not None: + assert spec_opts is not None + self.set_specification(specmodule_or_class, spec_opts) + + # TODO: Document, or make private. + def has_spec(self) -> bool: + return self.spec is not None + + # TODO: Document, or make private. + def set_specification( + self, + specmodule_or_class: _Namespace, + spec_opts: HookspecOpts, + ) -> None: + if self.spec is not None: + raise ValueError( + f"Hook {self.spec.name!r} is already registered " + f"within namespace {self.spec.namespace}" + ) + self.spec = HookSpec(specmodule_or_class, self.name, spec_opts) + if spec_opts.get("historic"): + self._call_history = [] + + def is_historic(self) -> bool: + """Whether this caller is :ref:`historic `.""" + return self._call_history is not None + + def _remove_plugin(self, plugin: _Plugin) -> None: + """Remove all hook implementations registered by the given plugin.""" + remaining = [impl for impl in self._hookimpls if impl.plugin != plugin] + if len(remaining) == len(self._hookimpls): + raise ValueError(f"plugin {plugin!r} not found") + self._hookimpls[:] = remaining + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + return self._hookimpls.copy() + + def _add_hookimpl(self, hookimpl: HookImpl) -> None: + """Add an implementation to the callback chain.""" + for i, method in enumerate(self._hookimpls): + if method.hookwrapper or method.wrapper: + splitpoint = i + break + else: + splitpoint = len(self._hookimpls) + if hookimpl.hookwrapper or hookimpl.wrapper: + start, end = splitpoint, len(self._hookimpls) + else: + start, end = 0, splitpoint + + if hookimpl.trylast: + self._hookimpls.insert(start, hookimpl) + elif hookimpl.tryfirst: + self._hookimpls.insert(end, hookimpl) + else: + # find last non-tryfirst method + i = end - 1 + while i >= start and self._hookimpls[i].tryfirst: + i -= 1 + self._hookimpls.insert(i + 1, hookimpl) + + def __repr__(self) -> str: + return f"" + + def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: + # This is written to avoid expensive operations when not needed. + if self.spec: + for argname in self.spec.argnames: + if argname not in kwargs: + notincall = ", ".join( + repr(argname) + for argname in self.spec.argnames + # Avoid self.spec.argnames - kwargs.keys() + # it doesn't preserve order. + if argname not in kwargs.keys() + ) + warnings.warn( + f"Argument(s) {notincall} which are declared in the hookspec " + "cannot be found in this hook call", + stacklevel=2, + ) + break + + def __call__(self, **kwargs: object) -> Any: + """Call the hook. + + Only accepts keyword arguments, which should match the hook + specification. + + Returns the result(s) of calling all registered plugins, see + :ref:`calling`. + """ + assert not self.is_historic(), ( + "Cannot directly call a historic hook - use call_historic instead." + ) + self._verify_all_args_are_provided(kwargs) + firstresult = self.spec.opts.get("firstresult", False) if self.spec else False + # Copy because plugins may register other plugins during iteration (#438). + return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook with given ``kwargs`` for all registered plugins and + for all plugins which will be registered afterwards, see + :ref:`historic`. + + :param result_callback: + If provided, will be called for each non-``None`` result obtained + from a hook implementation. + """ + assert self._call_history is not None + kwargs = kwargs or {} + self._verify_all_args_are_provided(kwargs) + self._call_history.append((kwargs, result_callback)) + # Historizing hooks don't return results. + # Remember firstresult isn't compatible with historic. + # Copy because plugins may register other plugins during iteration (#438). + res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False) + if result_callback is None: + return + if isinstance(res, list): + for x in res: + result_callback(x) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating + methods using the specified ``kwargs`` as call parameters, see + :ref:`call_extra`.""" + assert not self.is_historic(), ( + "Cannot directly call a historic hook - use call_historic instead." + ) + self._verify_all_args_are_provided(kwargs) + opts: HookimplOpts = { + "wrapper": False, + "hookwrapper": False, + "optionalhook": False, + "trylast": False, + "tryfirst": False, + "specname": None, + } + hookimpls = self._hookimpls.copy() + for method in methods: + hookimpl = HookImpl(None, "", method, opts) + # Find last non-tryfirst nonwrapper method. + i = len(hookimpls) - 1 + while i >= 0 and ( + # Skip wrappers. + (hookimpls[i].hookwrapper or hookimpls[i].wrapper) + # Skip tryfirst nonwrappers. + or hookimpls[i].tryfirst + ): + i -= 1 + hookimpls.insert(i + 1, hookimpl) + firstresult = self.spec.opts.get("firstresult", False) if self.spec else False + return self._hookexec(self.name, hookimpls, kwargs, firstresult) + + def _maybe_apply_history(self, method: HookImpl) -> None: + """Apply call history to a new hookimpl if it is marked as historic.""" + if self.is_historic(): + assert self._call_history is not None + for kwargs, result_callback in self._call_history: + res = self._hookexec(self.name, [method], kwargs, False) + if res and result_callback is not None: + # XXX: remember firstresult isn't compat with historic + assert isinstance(res, list) + result_callback(res[0]) + + +# Historical name (pluggy<=1.2), kept for backward compatibility. +_HookCaller = HookCaller + + +class _SubsetHookCaller(HookCaller): + """A proxy to another HookCaller which manages calls to all registered + plugins except the ones from remove_plugins.""" + + # This class is unusual: in inhertits from `HookCaller` so all of + # the *code* runs in the class, but it delegates all underlying *data* + # to the original HookCaller. + # `subset_hook_caller` used to be implemented by creating a full-fledged + # HookCaller, copying all hookimpls from the original. This had problems + # with memory leaks (#346) and historic calls (#347), which make a proxy + # approach better. + # An alternative implementation is to use a `_getattr__`/`__getattribute__` + # proxy, however that adds more overhead and is more tricky to implement. + + __slots__ = ( + "_orig", + "_remove_plugins", + ) + + def __init__(self, orig: HookCaller, remove_plugins: Set[_Plugin]) -> None: + self._orig = orig + self._remove_plugins = remove_plugins + self.name = orig.name # type: ignore[misc] + self._hookexec = orig._hookexec # type: ignore[misc] + + @property # type: ignore[misc] + def _hookimpls(self) -> list[HookImpl]: + return [ + impl + for impl in self._orig._hookimpls + if impl.plugin not in self._remove_plugins + ] + + @property + def spec(self) -> HookSpec | None: # type: ignore[override] + return self._orig.spec + + @property + def _call_history(self) -> _CallHistory | None: # type: ignore[override] + return self._orig._call_history + + def __repr__(self) -> str: + return f"<_SubsetHookCaller {self.name!r}>" diff --git a/src/pluggy/_callers.py b/src/pluggy/_callers.py index 450db1a7..716e5e34 100644 --- a/src/pluggy/_callers.py +++ b/src/pluggy/_callers.py @@ -1,174 +1,23 @@ """ -Call loop machinery +Call loop machinery. + +This module re-exports the execution engine for backward compatibility. +Prefer importing from :mod:`pluggy._execution`. """ from __future__ import annotations -from collections.abc import Generator -from collections.abc import Mapping -from collections.abc import Sequence -from typing import cast -from typing import NoReturn -from typing import TYPE_CHECKING -from typing import TypeAlias -import warnings - -from ._hooks import HookImpl -from ._result import HookCallError -from ._result import Result -from ._warnings import PluggyTeardownRaisedWarning - - -# Need to distinguish between old- and new-style hook wrappers. -# Wrapping with a tuple is the fastest type-safe way I found to do it. -Teardown: TypeAlias = Generator[None, object, object] - - -def run_old_style_hookwrapper( - hook_impl: HookImpl, hook_name: str, args: Sequence[object] -) -> Teardown: - """ - backward compatibility wrapper to run a old style hookwrapper as a wrapper - """ - if TYPE_CHECKING: - teardown = cast(Teardown, hook_impl.function(*args)) - else: - teardown = hook_impl.function(*args) - try: - next(teardown) - except StopIteration: - _raise_wrapfail(teardown, "did not yield") - try: - res = yield - result = Result(res, None) - except BaseException as exc: - result = Result(None, exc) - try: - teardown.send(result) - except StopIteration: - pass - except BaseException as e: - _warn_teardown_exception(hook_name, hook_impl, e) - raise - else: - _raise_wrapfail(teardown, "has second yield") - finally: - teardown.close() - return result.get_result() - - -def _raise_wrapfail( - wrap_controller: Generator[None, object, object], - msg: str, -) -> NoReturn: - co = wrap_controller.gi_code # type: ignore[attr-defined] - raise RuntimeError( - f"wrap_controller at {co.co_name!r} {co.co_filename}:{co.co_firstlineno} {msg}" - ) - - -def _warn_teardown_exception( - hook_name: str, hook_impl: HookImpl, e: BaseException -) -> None: - msg = ( - f"A plugin raised an exception during an old-style hookwrapper teardown.\n" - f"Plugin: {hook_impl.plugin_name}, Hook: {hook_name}\n" - f"{type(e).__name__}: {e}\n" - f"For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning" # noqa: E501 - ) - warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=6) - - -def _multicall( - hook_name: str, - hook_impls: Sequence[HookImpl], - caller_kwargs: Mapping[str, object], - firstresult: bool, -) -> object | list[object]: - """Execute a call into multiple python functions/methods and return the - result(s). - - ``caller_kwargs`` comes from HookCaller.__call__(). - """ - __tracebackhide__ = True - results: list[object] = [] - exception = None - teardowns: list[Teardown] = [] - try: # run impl and wrapper setup functions in a loop - for hook_impl in reversed(hook_impls): - try: - args = [caller_kwargs[argname] for argname in hook_impl.argnames] - except KeyError as e: - raise HookCallError( - f"hook call must provide argument {e.args[0]!r}" - ) from e - - if hook_impl.hookwrapper: - function_gen = run_old_style_hookwrapper(hook_impl, hook_name, args) - - next(function_gen) # first yield - teardowns.append(function_gen) - - elif hook_impl.wrapper: - res = hook_impl.function(*args) - # If this cast is not valid, a type error is raised below, - # which is the desired response. - if TYPE_CHECKING: - function_gen = cast(Generator[None, object, object], res) - else: - function_gen = res - try: - next(function_gen) # first yield - except StopIteration: - _raise_wrapfail(function_gen, "did not yield") - teardowns.append(function_gen) - else: - res = hook_impl.function(*args) - if res is not None: - results.append(res) - if firstresult: # halt further impl calls - break - except BaseException as exc: - exception = exc - finally: - if firstresult: # first result hooks return a single value - result = results[0] if results else None - else: - result = results +from ._execution import _multicall +from ._execution import _raise_wrapfail +from ._execution import _warn_teardown_exception +from ._execution import run_old_style_hookwrapper +from ._execution import Teardown - # run all wrapper post-yield blocks - for teardown in reversed(teardowns): - try: - if exception is not None: - try: - teardown.throw(exception) - except RuntimeError as re: - # StopIteration from generator causes RuntimeError - # even for coroutine usage - see #544 - if ( - isinstance(exception, StopIteration) - and re.__cause__ is exception - ): - teardown.close() - continue - else: - raise - else: - teardown.send(result) - # Following is unreachable for a well behaved hook wrapper. - # Try to force finalizers otherwise postponed till GC action. - # Note: close() may raise if generator handles GeneratorExit. - teardown.close() - except StopIteration as si: - result = si.value - exception = None - continue - except BaseException as e: - exception = e - continue - _raise_wrapfail(teardown, "has second yield") - if exception is not None: - raise exception - else: - return result +__all__ = [ + "Teardown", + "run_old_style_hookwrapper", + "_raise_wrapfail", + "_warn_teardown_exception", + "_multicall", +] diff --git a/src/pluggy/_config.py b/src/pluggy/_config.py new file mode 100644 index 00000000..576a7794 --- /dev/null +++ b/src/pluggy/_config.py @@ -0,0 +1,54 @@ +""" +Configuration types for hook specifications and implementations. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TypedDict + + +class HookspecOpts(TypedDict): + """Options for a hook specification.""" + + #: Whether the hook is :ref:`first result only `. + firstresult: bool + #: Whether the hook is :ref:`historic `. + historic: bool + #: Whether the hook :ref:`warns when implemented `. + warn_on_impl: Warning | None + #: Whether the hook warns when :ref:`certain arguments are requested + #: `. + #: + #: .. versionadded:: 1.5 + warn_on_impl_args: Mapping[str, Warning] | None + + +class HookimplOpts(TypedDict): + """Options for a hook implementation.""" + + #: Whether the hook implementation is a :ref:`wrapper `. + wrapper: bool + #: Whether the hook implementation is an :ref:`old-style wrapper + #: `. + hookwrapper: bool + #: Whether validation against a hook specification is :ref:`optional + #: `. + optionalhook: bool + #: Whether to try to order this hook implementation :ref:`first + #: `. + tryfirst: bool + #: Whether to try to order this hook implementation :ref:`last + #: `. + trylast: bool + #: The name of the hook specification to match, see :ref:`specname`. + specname: str | None + + +def normalize_hookimpl_opts(opts: HookimplOpts) -> None: + opts.setdefault("tryfirst", False) + opts.setdefault("trylast", False) + opts.setdefault("wrapper", False) + opts.setdefault("hookwrapper", False) + opts.setdefault("optionalhook", False) + opts.setdefault("specname", None) diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py new file mode 100644 index 00000000..b92b53a0 --- /dev/null +++ b/src/pluggy/_decorators.py @@ -0,0 +1,354 @@ +""" +Hook markers, specifications, and related helpers. +""" + +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Mapping +import inspect +import sys +import types +from types import ModuleType +from typing import Final +from typing import final +from typing import overload +from typing import TypeAlias +from typing import TypeVar +import warnings + +from ._config import HookimplOpts +from ._config import HookspecOpts + + +_F = TypeVar("_F", bound=Callable[..., object]) + +_Namespace: TypeAlias = ModuleType | type + + +@final +class HookspecMarker: + """Decorator for marking functions as hook specifications. + + Instantiate it with a project_name to get a decorator. + Calling :meth:`PluginManager.add_hookspecs` later will discover all marked + functions if the :class:`PluginManager` uses the same project name. + """ + + __slots__ = ("project_name",) + + def __init__(self, project_name: str) -> None: + self.project_name: Final = project_name + + @overload + def __call__( + self, + function: _F, + firstresult: bool = False, + historic: bool = False, + warn_on_impl: Warning | None = None, + warn_on_impl_args: Mapping[str, Warning] | None = None, + ) -> _F: ... + + @overload # noqa: F811 + def __call__( # noqa: F811 + self, + function: None = ..., + firstresult: bool = ..., + historic: bool = ..., + warn_on_impl: Warning | None = ..., + warn_on_impl_args: Mapping[str, Warning] | None = ..., + ) -> Callable[[_F], _F]: ... + + def __call__( # noqa: F811 + self, + function: _F | None = None, + firstresult: bool = False, + historic: bool = False, + warn_on_impl: Warning | None = None, + warn_on_impl_args: Mapping[str, Warning] | None = None, + ) -> _F | Callable[[_F], _F]: + """If passed a function, directly sets attributes on the function + which will make it discoverable to :meth:`PluginManager.add_hookspecs`. + + If passed no function, returns a decorator which can be applied to a + function later using the attributes supplied. + + :param firstresult: + If ``True``, the 1:N hook call (N being the number of registered + hook implementation functions) will stop at I<=N when the I'th + function returns a non-``None`` result. See :ref:`firstresult`. + + :param historic: + If ``True``, every call to the hook will be memorized and replayed + on plugins registered after the call was made. See :ref:`historic`. + + :param warn_on_impl: + If given, every implementation of this hook will trigger the given + warning. See :ref:`warn_on_impl`. + + :param warn_on_impl_args: + If given, every implementation of this hook which requests one of + the arguments in the dict will trigger the corresponding warning. + See :ref:`warn_on_impl`. + + .. versionadded:: 1.5 + """ + + def setattr_hookspec_opts(func: _F) -> _F: + if historic and firstresult: + raise ValueError("cannot have a historic firstresult hook") + opts: HookspecOpts = { + "firstresult": firstresult, + "historic": historic, + "warn_on_impl": warn_on_impl, + "warn_on_impl_args": warn_on_impl_args, + } + setattr(func, self.project_name + "_spec", opts) + return func + + if function is not None: + return setattr_hookspec_opts(function) + else: + return setattr_hookspec_opts + + +@final +class HookimplMarker: + """Decorator for marking functions as hook implementations. + + Instantiate it with a ``project_name`` to get a decorator. + Calling :meth:`PluginManager.register` later will discover all marked + functions if the :class:`PluginManager` uses the same project name. + """ + + __slots__ = ("project_name",) + + def __init__(self, project_name: str) -> None: + self.project_name: Final = project_name + + @overload + def __call__( + self, + function: _F, + hookwrapper: bool = ..., + optionalhook: bool = ..., + tryfirst: bool = ..., + trylast: bool = ..., + specname: str | None = ..., + wrapper: bool = ..., + ) -> _F: ... + + @overload # noqa: F811 + def __call__( # noqa: F811 + self, + function: None = ..., + hookwrapper: bool = ..., + optionalhook: bool = ..., + tryfirst: bool = ..., + trylast: bool = ..., + specname: str | None = ..., + wrapper: bool = ..., + ) -> Callable[[_F], _F]: ... + + def __call__( # noqa: F811 + self, + function: _F | None = None, + hookwrapper: bool = False, + optionalhook: bool = False, + tryfirst: bool = False, + trylast: bool = False, + specname: str | None = None, + wrapper: bool = False, + ) -> _F | Callable[[_F], _F]: + """If passed a function, directly sets attributes on the function + which will make it discoverable to :meth:`PluginManager.register`. + + If passed no function, returns a decorator which can be applied to a + function later using the attributes supplied. + + :param optionalhook: + If ``True``, a missing matching hook specification will not result + in an error (by default it is an error if no matching spec is + found). See :ref:`optionalhook`. + + :param tryfirst: + If ``True``, this hook implementation will run as early as possible + in the chain of N hook implementations for a specification. See + :ref:`callorder`. + + :param trylast: + If ``True``, this hook implementation will run as late as possible + in the chain of N hook implementations for a specification. See + :ref:`callorder`. + + :param wrapper: + If ``True`` ("new-style hook wrapper"), the hook implementation + needs to execute exactly one ``yield``. The code before the + ``yield`` is run early before any non-hook-wrapper function is run. + The code after the ``yield`` is run after all non-hook-wrapper + functions have run. The ``yield`` receives the result value of the + inner calls, or raises the exception of inner calls (including + earlier hook wrapper calls). The return value of the function + becomes the return value of the hook, and a raised exception becomes + the exception of the hook. See :ref:`hookwrapper`. + + :param hookwrapper: + If ``True`` ("old-style hook wrapper"), the hook implementation + needs to execute exactly one ``yield``. The code before the + ``yield`` is run early before any non-hook-wrapper function is run. + The code after the ``yield`` is run after all non-hook-wrapper + function have run The ``yield`` receives a :class:`Result` object + representing the exception or result outcome of the inner calls + (including earlier hook wrapper calls). This option is mutually + exclusive with ``wrapper``. See :ref:`old_style_hookwrapper`. + + :param specname: + If provided, the given name will be used instead of the function + name when matching this hook implementation to a hook specification + during registration. See :ref:`specname`. + + .. versionadded:: 1.2.0 + The ``wrapper`` parameter. + """ + + def setattr_hookimpl_opts(func: _F) -> _F: + opts: HookimplOpts = { + "wrapper": wrapper, + "hookwrapper": hookwrapper, + "optionalhook": optionalhook, + "tryfirst": tryfirst, + "trylast": trylast, + "specname": specname, + } + setattr(func, self.project_name + "_impl", opts) + return func + + if function is None: + return setattr_hookimpl_opts + else: + return setattr_hookimpl_opts(function) + + +_PYPY = sys.implementation.name == "pypy" +_IMPLICIT_NAMES = ("self", "cls", "obj") if _PYPY else ("self", "cls") + +# Qualnames whose missing-self deprecation warning is suppressed because +# their upstream code is already fixed but not yet released. +# Remove entries once a release with the fix is available. +_NOSELF_WARN_SUPPRESS: frozenset[str] = frozenset( + { + # pytest-timeout >=2.3.2 has the fix, but is unreleased as of 2026-05. + "TimeoutHooks.pytest_timeout_set_timer", + "TimeoutHooks.pytest_timeout_cancel_timer", + } +) + + +def varnames( + func: object, *, legacy_noself: bool = False +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return tuple of positional and keyword parameter names for a callable. + + In case of a class, its ``__init__`` method is considered. + For bound methods, the already-bound first parameter is not included. + For unbound methods with a dotted ``__qualname__``, the first parameter is + stripped only if its name is a known implicit name (``self``, ``cls``). + Keyword-only parameters are not included. + + :param legacy_noself: + If ``True``, support hookspec classes whose methods omit ``self``. + When the function looks like a class method but has no implicit first + parameter, a :class:`DeprecationWarning` is emitted. + """ + is_bound = False + if inspect.isclass(func): + try: + func = func.__init__ + except AttributeError: # pragma: no cover - pypy special case + return (), () + is_bound = True + elif not inspect.isroutine(func): # callable object? + try: + func = getattr(func, "__call__", func) + except Exception: # pragma: no cover - pypy special case + return (), () + + # Track bound methods before unwrapping, since __func__ loses that info. + if inspect.ismethod(func): + is_bound = True + func = inspect.unwrap(func) # type: ignore[arg-type] + if inspect.ismethod(func): + is_bound = True + func = func.__func__ + + try: + code: types.CodeType = func.__code__ # type: ignore[attr-defined] + defaults: tuple[object, ...] | None = func.__defaults__ # type: ignore[attr-defined] + qualname: str = func.__qualname__ # type: ignore[attr-defined] + except AttributeError: # pragma: no cover + return (), () + + # Get positional argument names (positional-only + positional-or-keyword) + args: tuple[str, ...] = code.co_varnames[: code.co_argcount] + + # Determine which args have defaults + kwargs: tuple[str, ...] + if defaults: + index = -len(defaults) + args, kwargs = args[:index], args[index:] + else: + kwargs = () + + # Strip implicit instance/class arg. + # Check if this looks like a method defined in a class by examining the + # qualname after the last "." segment (if any). A remaining dot + # means it's a class method (e.g. "MyClass.method" or + # "func..MyClass.method"), not just a nested function. + _tail = qualname.rsplit(".", maxsplit=1)[-1] + _is_class_method = "." in _tail + if args: + if is_bound: + args = args[1:] + elif _is_class_method and args[0] in _IMPLICIT_NAMES: + args = args[1:] + elif _is_class_method and legacy_noself: + if _tail not in _NOSELF_WARN_SUPPRESS: + warnings.warn( + f"{qualname} is a method but its first parameter" + f" {args[0]!r} is not 'self'." + f" Add 'self' as the first parameter or use @staticmethod." + f" This will become an error in a future version of pluggy.", + DeprecationWarning, + stacklevel=2, + ) + + return args, kwargs + + +@final +class HookSpec: + __slots__ = ( + "namespace", + "function", + "name", + "argnames", + "kwargnames", + "opts", + "warn_on_impl", + "warn_on_impl_args", + ) + + def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None: + self.namespace = namespace + self.name = name + self.function: Callable[..., object] = getattr(namespace, name) + legacy_noself = inspect.isclass(namespace) and not isinstance( + inspect.getattr_static(namespace, name), staticmethod + ) + self.argnames, self.kwargnames = varnames( + self.function, legacy_noself=legacy_noself + ) + self.opts = opts + self.warn_on_impl = opts.get("warn_on_impl") + self.warn_on_impl_args = opts.get("warn_on_impl_args") diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py new file mode 100644 index 00000000..a41c9e1a --- /dev/null +++ b/src/pluggy/_execution.py @@ -0,0 +1,174 @@ +""" +Hook call execution (multicall) machinery. +""" + +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Mapping +from collections.abc import Sequence +from typing import cast +from typing import NoReturn +from typing import TYPE_CHECKING +from typing import TypeAlias +import warnings + +from ._implementation import HookImpl +from ._result import HookCallError +from ._result import Result +from ._warnings import PluggyTeardownRaisedWarning + + +# Need to distinguish between old- and new-style hook wrappers. +# Wrapping with a tuple is the fastest type-safe way I found to do it. +Teardown: TypeAlias = Generator[None, object, object] + + +def run_old_style_hookwrapper( + hook_impl: HookImpl, hook_name: str, args: Sequence[object] +) -> Teardown: + """ + backward compatibility wrapper to run a old style hookwrapper as a wrapper + """ + if TYPE_CHECKING: + teardown = cast(Teardown, hook_impl.function(*args)) + else: + teardown = hook_impl.function(*args) + try: + next(teardown) + except StopIteration: + _raise_wrapfail(teardown, "did not yield") + try: + res = yield + result = Result(res, None) + except BaseException as exc: + result = Result(None, exc) + try: + teardown.send(result) + except StopIteration: + pass + except BaseException as e: + _warn_teardown_exception(hook_name, hook_impl, e) + raise + else: + _raise_wrapfail(teardown, "has second yield") + finally: + teardown.close() + return result.get_result() + + +def _raise_wrapfail( + wrap_controller: Generator[None, object, object], + msg: str, +) -> NoReturn: + co = wrap_controller.gi_code # type: ignore[attr-defined] + raise RuntimeError( + f"wrap_controller at {co.co_name!r} {co.co_filename}:{co.co_firstlineno} {msg}" + ) + + +def _warn_teardown_exception( + hook_name: str, hook_impl: HookImpl, e: BaseException +) -> None: + msg = ( + f"A plugin raised an exception during an old-style hookwrapper teardown.\n" + f"Plugin: {hook_impl.plugin_name}, Hook: {hook_name}\n" + f"{type(e).__name__}: {e}\n" + f"For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning" # noqa: E501 + ) + warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=6) + + +def _multicall( + hook_name: str, + hook_impls: Sequence[HookImpl], + caller_kwargs: Mapping[str, object], + firstresult: bool, +) -> object | list[object]: + """Execute a call into multiple python functions/methods and return the + result(s). + + ``caller_kwargs`` comes from HookCaller.__call__(). + """ + __tracebackhide__ = True + results: list[object] = [] + exception = None + teardowns: list[Teardown] = [] + try: # run impl and wrapper setup functions in a loop + for hook_impl in reversed(hook_impls): + try: + args = [caller_kwargs[argname] for argname in hook_impl.argnames] + except KeyError as e: + raise HookCallError( + f"hook call must provide argument {e.args[0]!r}" + ) from e + + if hook_impl.hookwrapper: + function_gen = run_old_style_hookwrapper(hook_impl, hook_name, args) + + next(function_gen) # first yield + teardowns.append(function_gen) + + elif hook_impl.wrapper: + res = hook_impl.function(*args) + # If this cast is not valid, a type error is raised below, + # which is the desired response. + if TYPE_CHECKING: + function_gen = cast(Generator[None, object, object], res) + else: + function_gen = res + try: + next(function_gen) # first yield + except StopIteration: + _raise_wrapfail(function_gen, "did not yield") + teardowns.append(function_gen) + else: + res = hook_impl.function(*args) + if res is not None: + results.append(res) + if firstresult: # halt further impl calls + break + except BaseException as exc: + exception = exc + finally: + if firstresult: # first result hooks return a single value + result = results[0] if results else None + else: + result = results + + # run all wrapper post-yield blocks + for teardown in reversed(teardowns): + try: + if exception is not None: + try: + teardown.throw(exception) + except RuntimeError as re: + # StopIteration from generator causes RuntimeError + # even for coroutine usage - see #544 + if ( + isinstance(exception, StopIteration) + and re.__cause__ is exception + ): + teardown.close() + continue + else: + raise + else: + teardown.send(result) + # Following is unreachable for a well behaved hook wrapper. + # Try to force finalizers otherwise postponed till GC action. + # Note: close() may raise if generator handles GeneratorExit. + teardown.close() + except StopIteration as si: + result = si.value + exception = None + continue + except BaseException as e: + exception = e + continue + _raise_wrapfail(teardown, "has second yield") + + if exception is not None: + raise exception + else: + return result diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index f079d3b7..dab705ec 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -1,750 +1,47 @@ """ Internal hook annotation, representation and calling machinery. + +This module re-exports symbols from the role-specific modules for +backward compatibility. """ from __future__ import annotations -from collections.abc import Callable -from collections.abc import Generator -from collections.abc import Mapping -from collections.abc import Sequence -from collections.abc import Set -import inspect -import sys -import types -from types import ModuleType -from typing import Any -from typing import Final -from typing import final -from typing import overload -from typing import TYPE_CHECKING -from typing import TypeAlias -from typing import TypedDict -from typing import TypeVar -import warnings - -from ._result import Result - - -_T = TypeVar("_T") -_F = TypeVar("_F", bound=Callable[..., object]) - -_Namespace: TypeAlias = ModuleType | type -_Plugin: TypeAlias = object -_HookExec: TypeAlias = Callable[ - [str, Sequence["HookImpl"], Mapping[str, object], bool], - object | list[object], -] -_HookImplFunction: TypeAlias = Callable[..., _T | Generator[None, Result[_T], None]] - - -class HookspecOpts(TypedDict): - """Options for a hook specification.""" - - #: Whether the hook is :ref:`first result only `. - firstresult: bool - #: Whether the hook is :ref:`historic `. - historic: bool - #: Whether the hook :ref:`warns when implemented `. - warn_on_impl: Warning | None - #: Whether the hook warns when :ref:`certain arguments are requested - #: `. - #: - #: .. versionadded:: 1.5 - warn_on_impl_args: Mapping[str, Warning] | None - - -class HookimplOpts(TypedDict): - """Options for a hook implementation.""" - - #: Whether the hook implementation is a :ref:`wrapper `. - wrapper: bool - #: Whether the hook implementation is an :ref:`old-style wrapper - #: `. - hookwrapper: bool - #: Whether validation against a hook specification is :ref:`optional - #: `. - optionalhook: bool - #: Whether to try to order this hook implementation :ref:`first - #: `. - tryfirst: bool - #: Whether to try to order this hook implementation :ref:`last - #: `. - trylast: bool - #: The name of the hook specification to match, see :ref:`specname`. - specname: str | None - - -@final -class HookspecMarker: - """Decorator for marking functions as hook specifications. - - Instantiate it with a project_name to get a decorator. - Calling :meth:`PluginManager.add_hookspecs` later will discover all marked - functions if the :class:`PluginManager` uses the same project name. - """ - - __slots__ = ("project_name",) - - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name - - @overload - def __call__( - self, - function: _F, - firstresult: bool = False, - historic: bool = False, - warn_on_impl: Warning | None = None, - warn_on_impl_args: Mapping[str, Warning] | None = None, - ) -> _F: ... - - @overload # noqa: F811 - def __call__( # noqa: F811 - self, - function: None = ..., - firstresult: bool = ..., - historic: bool = ..., - warn_on_impl: Warning | None = ..., - warn_on_impl_args: Mapping[str, Warning] | None = ..., - ) -> Callable[[_F], _F]: ... - - def __call__( # noqa: F811 - self, - function: _F | None = None, - firstresult: bool = False, - historic: bool = False, - warn_on_impl: Warning | None = None, - warn_on_impl_args: Mapping[str, Warning] | None = None, - ) -> _F | Callable[[_F], _F]: - """If passed a function, directly sets attributes on the function - which will make it discoverable to :meth:`PluginManager.add_hookspecs`. - - If passed no function, returns a decorator which can be applied to a - function later using the attributes supplied. - - :param firstresult: - If ``True``, the 1:N hook call (N being the number of registered - hook implementation functions) will stop at I<=N when the I'th - function returns a non-``None`` result. See :ref:`firstresult`. - - :param historic: - If ``True``, every call to the hook will be memorized and replayed - on plugins registered after the call was made. See :ref:`historic`. - - :param warn_on_impl: - If given, every implementation of this hook will trigger the given - warning. See :ref:`warn_on_impl`. - - :param warn_on_impl_args: - If given, every implementation of this hook which requests one of - the arguments in the dict will trigger the corresponding warning. - See :ref:`warn_on_impl`. - - .. versionadded:: 1.5 - """ - - def setattr_hookspec_opts(func: _F) -> _F: - if historic and firstresult: - raise ValueError("cannot have a historic firstresult hook") - opts: HookspecOpts = { - "firstresult": firstresult, - "historic": historic, - "warn_on_impl": warn_on_impl, - "warn_on_impl_args": warn_on_impl_args, - } - setattr(func, self.project_name + "_spec", opts) - return func - - if function is not None: - return setattr_hookspec_opts(function) - else: - return setattr_hookspec_opts - - -@final -class HookimplMarker: - """Decorator for marking functions as hook implementations. - - Instantiate it with a ``project_name`` to get a decorator. - Calling :meth:`PluginManager.register` later will discover all marked - functions if the :class:`PluginManager` uses the same project name. - """ - - __slots__ = ("project_name",) - - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name - - @overload - def __call__( - self, - function: _F, - hookwrapper: bool = ..., - optionalhook: bool = ..., - tryfirst: bool = ..., - trylast: bool = ..., - specname: str | None = ..., - wrapper: bool = ..., - ) -> _F: ... - - @overload # noqa: F811 - def __call__( # noqa: F811 - self, - function: None = ..., - hookwrapper: bool = ..., - optionalhook: bool = ..., - tryfirst: bool = ..., - trylast: bool = ..., - specname: str | None = ..., - wrapper: bool = ..., - ) -> Callable[[_F], _F]: ... - - def __call__( # noqa: F811 - self, - function: _F | None = None, - hookwrapper: bool = False, - optionalhook: bool = False, - tryfirst: bool = False, - trylast: bool = False, - specname: str | None = None, - wrapper: bool = False, - ) -> _F | Callable[[_F], _F]: - """If passed a function, directly sets attributes on the function - which will make it discoverable to :meth:`PluginManager.register`. - - If passed no function, returns a decorator which can be applied to a - function later using the attributes supplied. - - :param optionalhook: - If ``True``, a missing matching hook specification will not result - in an error (by default it is an error if no matching spec is - found). See :ref:`optionalhook`. - - :param tryfirst: - If ``True``, this hook implementation will run as early as possible - in the chain of N hook implementations for a specification. See - :ref:`callorder`. - - :param trylast: - If ``True``, this hook implementation will run as late as possible - in the chain of N hook implementations for a specification. See - :ref:`callorder`. - - :param wrapper: - If ``True`` ("new-style hook wrapper"), the hook implementation - needs to execute exactly one ``yield``. The code before the - ``yield`` is run early before any non-hook-wrapper function is run. - The code after the ``yield`` is run after all non-hook-wrapper - functions have run. The ``yield`` receives the result value of the - inner calls, or raises the exception of inner calls (including - earlier hook wrapper calls). The return value of the function - becomes the return value of the hook, and a raised exception becomes - the exception of the hook. See :ref:`hookwrapper`. - - :param hookwrapper: - If ``True`` ("old-style hook wrapper"), the hook implementation - needs to execute exactly one ``yield``. The code before the - ``yield`` is run early before any non-hook-wrapper function is run. - The code after the ``yield`` is run after all non-hook-wrapper - function have run The ``yield`` receives a :class:`Result` object - representing the exception or result outcome of the inner calls - (including earlier hook wrapper calls). This option is mutually - exclusive with ``wrapper``. See :ref:`old_style_hookwrapper`. - - :param specname: - If provided, the given name will be used instead of the function - name when matching this hook implementation to a hook specification - during registration. See :ref:`specname`. - - .. versionadded:: 1.2.0 - The ``wrapper`` parameter. - """ - - def setattr_hookimpl_opts(func: _F) -> _F: - opts: HookimplOpts = { - "wrapper": wrapper, - "hookwrapper": hookwrapper, - "optionalhook": optionalhook, - "tryfirst": tryfirst, - "trylast": trylast, - "specname": specname, - } - setattr(func, self.project_name + "_impl", opts) - return func - - if function is None: - return setattr_hookimpl_opts - else: - return setattr_hookimpl_opts(function) - - -def normalize_hookimpl_opts(opts: HookimplOpts) -> None: - opts.setdefault("tryfirst", False) - opts.setdefault("trylast", False) - opts.setdefault("wrapper", False) - opts.setdefault("hookwrapper", False) - opts.setdefault("optionalhook", False) - opts.setdefault("specname", None) - - -_PYPY = sys.implementation.name == "pypy" -_IMPLICIT_NAMES = ("self", "cls", "obj") if _PYPY else ("self", "cls") - -# Qualnames whose missing-self deprecation warning is suppressed because -# their upstream code is already fixed but not yet released. -# Remove entries once a release with the fix is available. -_NOSELF_WARN_SUPPRESS: frozenset[str] = frozenset( - { - # pytest-timeout >=2.3.2 has the fix, but is unreleased as of 2026-05. - "TimeoutHooks.pytest_timeout_set_timer", - "TimeoutHooks.pytest_timeout_cancel_timer", - } -) - - -def varnames( - func: object, *, legacy_noself: bool = False -) -> tuple[tuple[str, ...], tuple[str, ...]]: - """Return tuple of positional and keyword parameter names for a callable. - - In case of a class, its ``__init__`` method is considered. - For bound methods, the already-bound first parameter is not included. - For unbound methods with a dotted ``__qualname__``, the first parameter is - stripped only if its name is a known implicit name (``self``, ``cls``). - Keyword-only parameters are not included. - - :param legacy_noself: - If ``True``, support hookspec classes whose methods omit ``self``. - When the function looks like a class method but has no implicit first - parameter, a :class:`DeprecationWarning` is emitted. - """ - is_bound = False - if inspect.isclass(func): - try: - func = func.__init__ - except AttributeError: # pragma: no cover - pypy special case - return (), () - is_bound = True - elif not inspect.isroutine(func): # callable object? - try: - func = getattr(func, "__call__", func) - except Exception: # pragma: no cover - pypy special case - return (), () - - # Track bound methods before unwrapping, since __func__ loses that info. - if inspect.ismethod(func): - is_bound = True - func = inspect.unwrap(func) # type: ignore[arg-type] - if inspect.ismethod(func): - is_bound = True - func = func.__func__ - - try: - code: types.CodeType = func.__code__ # type: ignore[attr-defined] - defaults: tuple[object, ...] | None = func.__defaults__ # type: ignore[attr-defined] - qualname: str = func.__qualname__ # type: ignore[attr-defined] - except AttributeError: # pragma: no cover - return (), () - - # Get positional argument names (positional-only + positional-or-keyword) - args: tuple[str, ...] = code.co_varnames[: code.co_argcount] - - # Determine which args have defaults - kwargs: tuple[str, ...] - if defaults: - index = -len(defaults) - args, kwargs = args[:index], args[index:] - else: - kwargs = () - - # Strip implicit instance/class arg. - # Check if this looks like a method defined in a class by examining the - # qualname after the last "." segment (if any). A remaining dot - # means it's a class method (e.g. "MyClass.method" or - # "func..MyClass.method"), not just a nested function. - _tail = qualname.rsplit(".", maxsplit=1)[-1] - _is_class_method = "." in _tail - if args: - if is_bound: - args = args[1:] - elif _is_class_method and args[0] in _IMPLICIT_NAMES: - args = args[1:] - elif _is_class_method and legacy_noself: - if _tail not in _NOSELF_WARN_SUPPRESS: - warnings.warn( - f"{qualname} is a method but its first parameter" - f" {args[0]!r} is not 'self'." - f" Add 'self' as the first parameter or use @staticmethod." - f" This will become an error in a future version of pluggy.", - DeprecationWarning, - stacklevel=2, - ) - - return args, kwargs - - -@final -class HookRelay: - """Hook holder object for performing 1:N hook calls where N is the number - of registered plugins.""" - - __slots__ = ("__dict__",) - - def __init__(self) -> None: - """:meta private:""" - - if TYPE_CHECKING: - - def __getattr__(self, name: str) -> HookCaller: ... - - -# Historical name (pluggy<=1.2), kept for backward compatibility. -_HookRelay = HookRelay - - -_CallHistory: TypeAlias = list[ - tuple[Mapping[str, object], Callable[[Any], None] | None] +from ._caller import _HookCaller +from ._caller import _HookExec +from ._caller import _HookRelay +from ._caller import _SubsetHookCaller +from ._caller import HookCaller +from ._caller import HookRelay +from ._config import HookimplOpts +from ._config import HookspecOpts +from ._config import normalize_hookimpl_opts +from ._decorators import _Namespace +from ._decorators import HookimplMarker +from ._decorators import HookSpec +from ._decorators import HookspecMarker +from ._decorators import varnames +from ._implementation import _HookImplFunction +from ._implementation import _Plugin +from ._implementation import HookImpl + + +__all__ = [ + "HookspecOpts", + "HookimplOpts", + "normalize_hookimpl_opts", + "HookspecMarker", + "HookimplMarker", + "HookSpec", + "varnames", + "HookCaller", + "HookRelay", + "_HookCaller", + "_HookRelay", + "_SubsetHookCaller", + "HookImpl", + "_HookImplFunction", + "_Namespace", + "_Plugin", + "_HookExec", ] - - -class HookCaller: - """A caller of all registered implementations of a hook specification.""" - - __slots__ = ( - "name", - "spec", - "_hookexec", - "_hookimpls", - "_call_history", - ) - - def __init__( - self, - name: str, - hook_execute: _HookExec, - specmodule_or_class: _Namespace | None = None, - spec_opts: HookspecOpts | None = None, - ) -> None: - """:meta private:""" - #: Name of the hook getting called. - self.name: Final = name - self._hookexec: Final = hook_execute - # The hookimpls list. The caller iterates it *in reverse*. Format: - # 1. trylast nonwrappers - # 2. nonwrappers - # 3. tryfirst nonwrappers - # 4. trylast wrappers - # 5. wrappers - # 6. tryfirst wrappers - self._hookimpls: Final[list[HookImpl]] = [] - self._call_history: _CallHistory | None = None - # TODO: Document, or make private. - self.spec: HookSpec | None = None - if specmodule_or_class is not None: - assert spec_opts is not None - self.set_specification(specmodule_or_class, spec_opts) - - # TODO: Document, or make private. - def has_spec(self) -> bool: - return self.spec is not None - - # TODO: Document, or make private. - def set_specification( - self, - specmodule_or_class: _Namespace, - spec_opts: HookspecOpts, - ) -> None: - if self.spec is not None: - raise ValueError( - f"Hook {self.spec.name!r} is already registered " - f"within namespace {self.spec.namespace}" - ) - self.spec = HookSpec(specmodule_or_class, self.name, spec_opts) - if spec_opts.get("historic"): - self._call_history = [] - - def is_historic(self) -> bool: - """Whether this caller is :ref:`historic `.""" - return self._call_history is not None - - def _remove_plugin(self, plugin: _Plugin) -> None: - """Remove all hook implementations registered by the given plugin.""" - remaining = [impl for impl in self._hookimpls if impl.plugin != plugin] - if len(remaining) == len(self._hookimpls): - raise ValueError(f"plugin {plugin!r} not found") - self._hookimpls[:] = remaining - - def get_hookimpls(self) -> list[HookImpl]: - """Get all registered hook implementations for this hook.""" - return self._hookimpls.copy() - - def _add_hookimpl(self, hookimpl: HookImpl) -> None: - """Add an implementation to the callback chain.""" - for i, method in enumerate(self._hookimpls): - if method.hookwrapper or method.wrapper: - splitpoint = i - break - else: - splitpoint = len(self._hookimpls) - if hookimpl.hookwrapper or hookimpl.wrapper: - start, end = splitpoint, len(self._hookimpls) - else: - start, end = 0, splitpoint - - if hookimpl.trylast: - self._hookimpls.insert(start, hookimpl) - elif hookimpl.tryfirst: - self._hookimpls.insert(end, hookimpl) - else: - # find last non-tryfirst method - i = end - 1 - while i >= start and self._hookimpls[i].tryfirst: - i -= 1 - self._hookimpls.insert(i + 1, hookimpl) - - def __repr__(self) -> str: - return f"" - - def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: - # This is written to avoid expensive operations when not needed. - if self.spec: - for argname in self.spec.argnames: - if argname not in kwargs: - notincall = ", ".join( - repr(argname) - for argname in self.spec.argnames - # Avoid self.spec.argnames - kwargs.keys() - # it doesn't preserve order. - if argname not in kwargs.keys() - ) - warnings.warn( - f"Argument(s) {notincall} which are declared in the hookspec " - "cannot be found in this hook call", - stacklevel=2, - ) - break - - def __call__(self, **kwargs: object) -> Any: - """Call the hook. - - Only accepts keyword arguments, which should match the hook - specification. - - Returns the result(s) of calling all registered plugins, see - :ref:`calling`. - """ - assert not self.is_historic(), ( - "Cannot directly call a historic hook - use call_historic instead." - ) - self._verify_all_args_are_provided(kwargs) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False - # Copy because plugins may register other plugins during iteration (#438). - return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) - - def call_historic( - self, - result_callback: Callable[[Any], None] | None = None, - kwargs: Mapping[str, object] | None = None, - ) -> None: - """Call the hook with given ``kwargs`` for all registered plugins and - for all plugins which will be registered afterwards, see - :ref:`historic`. - - :param result_callback: - If provided, will be called for each non-``None`` result obtained - from a hook implementation. - """ - assert self._call_history is not None - kwargs = kwargs or {} - self._verify_all_args_are_provided(kwargs) - self._call_history.append((kwargs, result_callback)) - # Historizing hooks don't return results. - # Remember firstresult isn't compatible with historic. - # Copy because plugins may register other plugins during iteration (#438). - res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False) - if result_callback is None: - return - if isinstance(res, list): - for x in res: - result_callback(x) - - def call_extra( - self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] - ) -> Any: - """Call the hook with some additional temporarily participating - methods using the specified ``kwargs`` as call parameters, see - :ref:`call_extra`.""" - assert not self.is_historic(), ( - "Cannot directly call a historic hook - use call_historic instead." - ) - self._verify_all_args_are_provided(kwargs) - opts: HookimplOpts = { - "wrapper": False, - "hookwrapper": False, - "optionalhook": False, - "trylast": False, - "tryfirst": False, - "specname": None, - } - hookimpls = self._hookimpls.copy() - for method in methods: - hookimpl = HookImpl(None, "", method, opts) - # Find last non-tryfirst nonwrapper method. - i = len(hookimpls) - 1 - while i >= 0 and ( - # Skip wrappers. - (hookimpls[i].hookwrapper or hookimpls[i].wrapper) - # Skip tryfirst nonwrappers. - or hookimpls[i].tryfirst - ): - i -= 1 - hookimpls.insert(i + 1, hookimpl) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False - return self._hookexec(self.name, hookimpls, kwargs, firstresult) - - def _maybe_apply_history(self, method: HookImpl) -> None: - """Apply call history to a new hookimpl if it is marked as historic.""" - if self.is_historic(): - assert self._call_history is not None - for kwargs, result_callback in self._call_history: - res = self._hookexec(self.name, [method], kwargs, False) - if res and result_callback is not None: - # XXX: remember firstresult isn't compat with historic - assert isinstance(res, list) - result_callback(res[0]) - - -# Historical name (pluggy<=1.2), kept for backward compatibility. -_HookCaller = HookCaller - - -class _SubsetHookCaller(HookCaller): - """A proxy to another HookCaller which manages calls to all registered - plugins except the ones from remove_plugins.""" - - # This class is unusual: in inhertits from `HookCaller` so all of - # the *code* runs in the class, but it delegates all underlying *data* - # to the original HookCaller. - # `subset_hook_caller` used to be implemented by creating a full-fledged - # HookCaller, copying all hookimpls from the original. This had problems - # with memory leaks (#346) and historic calls (#347), which make a proxy - # approach better. - # An alternative implementation is to use a `_getattr__`/`__getattribute__` - # proxy, however that adds more overhead and is more tricky to implement. - - __slots__ = ( - "_orig", - "_remove_plugins", - ) - - def __init__(self, orig: HookCaller, remove_plugins: Set[_Plugin]) -> None: - self._orig = orig - self._remove_plugins = remove_plugins - self.name = orig.name # type: ignore[misc] - self._hookexec = orig._hookexec # type: ignore[misc] - - @property # type: ignore[misc] - def _hookimpls(self) -> list[HookImpl]: - return [ - impl - for impl in self._orig._hookimpls - if impl.plugin not in self._remove_plugins - ] - - @property - def spec(self) -> HookSpec | None: # type: ignore[override] - return self._orig.spec - - @property - def _call_history(self) -> _CallHistory | None: # type: ignore[override] - return self._orig._call_history - - def __repr__(self) -> str: - return f"<_SubsetHookCaller {self.name!r}>" - - -@final -class HookImpl: - """A hook implementation in a :class:`HookCaller`.""" - - __slots__ = ( - "function", - "argnames", - "kwargnames", - "plugin", - "opts", - "plugin_name", - "wrapper", - "hookwrapper", - "optionalhook", - "tryfirst", - "trylast", - ) - - def __init__( - self, - plugin: _Plugin, - plugin_name: str, - function: _HookImplFunction[object], - hook_impl_opts: HookimplOpts, - ) -> None: - """:meta private:""" - #: The hook implementation function. - self.function: Final = function - argnames, kwargnames = varnames(self.function) - #: The positional parameter names of ``function```. - self.argnames: Final = argnames - #: The keyword parameter names of ``function```. - self.kwargnames: Final = kwargnames - #: The plugin which defined this hook implementation. - self.plugin: Final = plugin - #: The :class:`HookimplOpts` used to configure this hook implementation. - self.opts: Final = hook_impl_opts - #: The name of the plugin which defined this hook implementation. - self.plugin_name: Final = plugin_name - #: Whether the hook implementation is a :ref:`wrapper `. - self.wrapper: Final = hook_impl_opts["wrapper"] - #: Whether the hook implementation is an :ref:`old-style wrapper - #: `. - self.hookwrapper: Final = hook_impl_opts["hookwrapper"] - #: Whether validation against a hook specification is :ref:`optional - #: `. - self.optionalhook: Final = hook_impl_opts["optionalhook"] - #: Whether to try to order this hook implementation :ref:`first - #: `. - self.tryfirst: Final = hook_impl_opts["tryfirst"] - #: Whether to try to order this hook implementation :ref:`last - #: `. - self.trylast: Final = hook_impl_opts["trylast"] - - def __repr__(self) -> str: - return f"" - - -@final -class HookSpec: - __slots__ = ( - "namespace", - "function", - "name", - "argnames", - "kwargnames", - "opts", - "warn_on_impl", - "warn_on_impl_args", - ) - - def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None: - self.namespace = namespace - self.name = name - self.function: Callable[..., object] = getattr(namespace, name) - legacy_noself = inspect.isclass(namespace) and not isinstance( - inspect.getattr_static(namespace, name), staticmethod - ) - self.argnames, self.kwargnames = varnames( - self.function, legacy_noself=legacy_noself - ) - self.opts = opts - self.warn_on_impl = opts.get("warn_on_impl") - self.warn_on_impl_args = opts.get("warn_on_impl_args") diff --git a/src/pluggy/_implementation.py b/src/pluggy/_implementation.py new file mode 100644 index 00000000..19cb91ff --- /dev/null +++ b/src/pluggy/_implementation.py @@ -0,0 +1,80 @@ +""" +Hook implementation representation. +""" + +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Generator +from typing import Final +from typing import final +from typing import TypeAlias +from typing import TypeVar + +from ._config import HookimplOpts +from ._decorators import varnames +from ._result import Result + + +_T = TypeVar("_T") + +_Plugin: TypeAlias = object +_HookImplFunction: TypeAlias = Callable[..., _T | Generator[None, Result[_T], None]] + + +@final +class HookImpl: + """A hook implementation in a :class:`HookCaller`.""" + + __slots__ = ( + "function", + "argnames", + "kwargnames", + "plugin", + "opts", + "plugin_name", + "wrapper", + "hookwrapper", + "optionalhook", + "tryfirst", + "trylast", + ) + + def __init__( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + hook_impl_opts: HookimplOpts, + ) -> None: + """:meta private:""" + #: The hook implementation function. + self.function: Final = function + argnames, kwargnames = varnames(self.function) + #: The positional parameter names of ``function```. + self.argnames: Final = argnames + #: The keyword parameter names of ``function```. + self.kwargnames: Final = kwargnames + #: The plugin which defined this hook implementation. + self.plugin: Final = plugin + #: The :class:`HookimplOpts` used to configure this hook implementation. + self.opts: Final = hook_impl_opts + #: The name of the plugin which defined this hook implementation. + self.plugin_name: Final = plugin_name + #: Whether the hook implementation is a :ref:`wrapper `. + self.wrapper: Final = hook_impl_opts["wrapper"] + #: Whether the hook implementation is an :ref:`old-style wrapper + #: `. + self.hookwrapper: Final = hook_impl_opts["hookwrapper"] + #: Whether validation against a hook specification is :ref:`optional + #: `. + self.optionalhook: Final = hook_impl_opts["optionalhook"] + #: Whether to try to order this hook implementation :ref:`first + #: `. + self.tryfirst: Final = hook_impl_opts["tryfirst"] + #: Whether to try to order this hook implementation :ref:`last + #: `. + self.trylast: Final = hook_impl_opts["trylast"] + + def __repr__(self) -> str: + return f"" From 6993f1079498e547d441f141822b90cc6535debf Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 22 Jul 2026 14:40:06 +0200 Subject: [PATCH 3/8] feat(config): replace TypedDict options with Hook*Configuration Markers attach HookspecConfiguration/HookimplConfiguration objects. Registration discovers those privately; parse_hookimpl_opts and parse_hookspec_opts remain a deprecated pytest concession that returns legacy dicts and is only called when a subclass overrides them and no modern configuration attribute was found. Co-authored-by: Cursor AI Co-authored-by: Cursor Grok 4.5 --- changelog/704.removal.rst | 8 + docs/api_reference.rst | 6 +- docs/index.rst | 9 +- src/pluggy/__init__.py | 8 +- src/pluggy/_caller.py | 23 +-- src/pluggy/_config.py | 207 +++++++++++++++++++------ src/pluggy/_decorators.py | 46 +++--- src/pluggy/_hooks.py | 10 +- src/pluggy/_implementation.py | 17 ++- src/pluggy/_manager.py | 159 ++++++++++++++------ src/pluggy/_pytest_compat.py | 55 +++++++ testing/test_configuration.py | 276 ++++++++++++++++++++++++++++++++++ testing/test_details.py | 9 +- testing/test_hookcaller.py | 8 +- 14 files changed, 681 insertions(+), 160 deletions(-) create mode 100644 changelog/704.removal.rst create mode 100644 src/pluggy/_pytest_compat.py create mode 100644 testing/test_configuration.py diff --git a/changelog/704.removal.rst b/changelog/704.removal.rst new file mode 100644 index 00000000..4d5eef43 --- /dev/null +++ b/changelog/704.removal.rst @@ -0,0 +1,8 @@ +Hook options are now :class:`pluggy.HookspecConfiguration` / +:class:`pluggy.HookimplConfiguration` objects (markers attach these instead of +dicts). ``PluginManager.parse_hookimpl_opts`` / +``parse_hookspec_opts`` remain as a deprecated pytest/support concession that +returns legacy dicts and are only invoked during registration when a subclass +overrides them and no modern configuration attribute was found. +``HookspecOpts`` / ``HookimplOpts`` TypedDicts remain importable for +pytest/typing compatibility. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index b14d725d..7d19a4a6 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -40,12 +40,10 @@ API Reference .. autoclass:: pluggy.HookImpl() :members: -.. autoclass:: pluggy.HookspecOpts() - :show-inheritance: +.. autoclass:: pluggy.HookspecConfiguration() :members: -.. autoclass:: pluggy.HookimplOpts() - :show-inheritance: +.. autoclass:: pluggy.HookimplConfiguration() :members: diff --git a/docs/index.rst b/docs/index.rst index b56278ed..1f1292ae 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -767,10 +767,13 @@ and particular plugins in it: Parsing mark options ^^^^^^^^^^^^^^^^^^^^ -You can retrieve the *options* applied to a particular -*hookspec* or *hookimpl* as per :ref:`marking_hooks` using the +Markers attach :class:`~pluggy.HookspecConfiguration` / +:class:`~pluggy.HookimplConfiguration` objects to functions. The :py:meth:`~pluggy.PluginManager.parse_hookspec_opts()` and -:py:meth:`~pluggy.PluginManager.parse_hookimpl_opts()` respectively. +:py:meth:`~pluggy.PluginManager.parse_hookimpl_opts()` methods remain as a +**deprecated** pytest/support concession that returns legacy dict-shaped +options; registration only calls them when a subclass overrides them and no +modern configuration attribute was found. .. _calling: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index 3d81d0a3..ba9ed05d 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -4,6 +4,8 @@ "PluginValidationError", "HookCaller", "HookCallError", + "HookspecConfiguration", + "HookimplConfiguration", "HookspecOpts", "HookimplOpts", "HookImpl", @@ -14,15 +16,17 @@ "PluggyWarning", "PluggyTeardownRaisedWarning", ] +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration from ._hooks import HookCaller from ._hooks import HookImpl from ._hooks import HookimplMarker -from ._hooks import HookimplOpts from ._hooks import HookRelay from ._hooks import HookspecMarker -from ._hooks import HookspecOpts from ._manager import PluginManager from ._manager import PluginValidationError +from ._pytest_compat import HookimplOpts +from ._pytest_compat import HookspecOpts from ._result import HookCallError from ._result import Result from ._warnings import PluggyTeardownRaisedWarning diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 5569c631..10f61043 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -15,8 +15,8 @@ from typing import TypeAlias import warnings -from ._config import HookimplOpts -from ._config import HookspecOpts +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration from ._decorators import _Namespace from ._decorators import HookSpec from ._implementation import _Plugin @@ -69,7 +69,7 @@ def __init__( name: str, hook_execute: _HookExec, specmodule_or_class: _Namespace | None = None, - spec_opts: HookspecOpts | None = None, + spec_opts: HookspecConfiguration | None = None, ) -> None: """:meta private:""" #: Name of the hook getting called. @@ -98,7 +98,7 @@ def has_spec(self) -> bool: def set_specification( self, specmodule_or_class: _Namespace, - spec_opts: HookspecOpts, + spec_opts: HookspecConfiguration, ) -> None: if self.spec is not None: raise ValueError( @@ -106,7 +106,7 @@ def set_specification( f"within namespace {self.spec.namespace}" ) self.spec = HookSpec(specmodule_or_class, self.name, spec_opts) - if spec_opts.get("historic"): + if spec_opts.historic: self._call_history = [] def is_historic(self) -> bool: @@ -183,7 +183,7 @@ def __call__(self, **kwargs: object) -> Any: "Cannot directly call a historic hook - use call_historic instead." ) self._verify_all_args_are_provided(kwargs) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False + firstresult = self.spec.opts.firstresult if self.spec else False # Copy because plugins may register other plugins during iteration (#438). return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) @@ -224,14 +224,7 @@ def call_extra( "Cannot directly call a historic hook - use call_historic instead." ) self._verify_all_args_are_provided(kwargs) - opts: HookimplOpts = { - "wrapper": False, - "hookwrapper": False, - "optionalhook": False, - "trylast": False, - "tryfirst": False, - "specname": None, - } + opts = HookimplConfiguration() hookimpls = self._hookimpls.copy() for method in methods: hookimpl = HookImpl(None, "", method, opts) @@ -245,7 +238,7 @@ def call_extra( ): i -= 1 hookimpls.insert(i + 1, hookimpl) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False + firstresult = self.spec.opts.firstresult if self.spec else False return self._hookexec(self.name, hookimpls, kwargs, firstresult) def _maybe_apply_history(self, method: HookImpl) -> None: diff --git a/src/pluggy/_config.py b/src/pluggy/_config.py index 576a7794..43eef7c6 100644 --- a/src/pluggy/_config.py +++ b/src/pluggy/_config.py @@ -5,50 +5,163 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TypedDict - - -class HookspecOpts(TypedDict): - """Options for a hook specification.""" - - #: Whether the hook is :ref:`first result only `. - firstresult: bool - #: Whether the hook is :ref:`historic `. - historic: bool - #: Whether the hook :ref:`warns when implemented `. - warn_on_impl: Warning | None - #: Whether the hook warns when :ref:`certain arguments are requested - #: `. - #: - #: .. versionadded:: 1.5 - warn_on_impl_args: Mapping[str, Warning] | None - - -class HookimplOpts(TypedDict): - """Options for a hook implementation.""" - - #: Whether the hook implementation is a :ref:`wrapper `. - wrapper: bool - #: Whether the hook implementation is an :ref:`old-style wrapper - #: `. - hookwrapper: bool - #: Whether validation against a hook specification is :ref:`optional - #: `. - optionalhook: bool - #: Whether to try to order this hook implementation :ref:`first - #: `. - tryfirst: bool - #: Whether to try to order this hook implementation :ref:`last - #: `. - trylast: bool - #: The name of the hook specification to match, see :ref:`specname`. - specname: str | None - - -def normalize_hookimpl_opts(opts: HookimplOpts) -> None: - opts.setdefault("tryfirst", False) - opts.setdefault("trylast", False) - opts.setdefault("wrapper", False) - opts.setdefault("hookwrapper", False) - opts.setdefault("optionalhook", False) - opts.setdefault("specname", None) +from typing import Any +from typing import Final +from typing import final + + +@final +class HookspecConfiguration: + """Configuration for a hook specification.""" + + __slots__ = ( + "firstresult", + "historic", + "warn_on_impl", + "warn_on_impl_args", + ) + firstresult: Final[bool] + historic: Final[bool] + warn_on_impl: Final[Warning | None] + warn_on_impl_args: Final[Mapping[str, Warning] | None] + + def __init__( + self, + firstresult: bool = False, + historic: bool = False, + warn_on_impl: Warning | None = None, + warn_on_impl_args: Mapping[str, Warning] | None = None, + ) -> None: + if historic and firstresult: + raise ValueError("cannot have a historic firstresult hook") + #: Whether the hook is :ref:`first result only `. + self.firstresult = firstresult + #: Whether the hook is :ref:`historic `. + self.historic = historic + #: Whether the hook :ref:`warns when implemented `. + self.warn_on_impl = warn_on_impl + #: Whether the hook warns when :ref:`certain arguments are requested + #: `. + self.warn_on_impl_args = warn_on_impl_args + + def __repr__(self) -> str: + attrs = [ + f"{slot}={getattr(self, slot)!r}" + for slot in self.__slots__ + if getattr(self, slot) + ] + return f"HookspecConfiguration({', '.join(attrs)})" + + +@final +class HookimplConfiguration: + """Configuration for a hook implementation.""" + + __slots__ = ( + "wrapper", + "hookwrapper", + "optionalhook", + "tryfirst", + "trylast", + "specname", + ) + wrapper: Final[bool] + hookwrapper: Final[bool] + optionalhook: Final[bool] + tryfirst: Final[bool] + trylast: Final[bool] + specname: Final[str | None] + + def __init__( + self, + wrapper: bool = False, + hookwrapper: bool = False, + optionalhook: bool = False, + tryfirst: bool = False, + trylast: bool = False, + specname: str | None = None, + ) -> None: + #: Whether the hook implementation is a :ref:`wrapper `. + self.wrapper = wrapper + #: Whether the hook implementation is an :ref:`old-style wrapper + #: `. + self.hookwrapper = hookwrapper + #: Whether validation against a hook specification is :ref:`optional + #: `. + self.optionalhook = optionalhook + #: Whether to try to order this hook implementation :ref:`first + #: `. + self.tryfirst = tryfirst + #: Whether to try to order this hook implementation :ref:`last + #: `. + self.trylast = trylast + #: The name of the hook specification to match, see :ref:`specname`. + self.specname = specname + + def __repr__(self) -> str: + attrs = [ + f"{slot}={getattr(self, slot)!r}" + for slot in self.__slots__ + if getattr(self, slot) + ] + return f"HookimplConfiguration({', '.join(attrs)})" + + +def hookspec_config_from_mapping( + opts: Mapping[str, Any], +) -> HookspecConfiguration: + """Build a :class:`HookspecConfiguration` from a mapping. + + Intended for pytest/support migration only — not the public options API. + Prefer constructing :class:`HookspecConfiguration` directly. + """ + return HookspecConfiguration( + firstresult=bool(opts.get("firstresult", False)), + historic=bool(opts.get("historic", False)), + warn_on_impl=opts.get("warn_on_impl"), + warn_on_impl_args=opts.get("warn_on_impl_args"), + ) + + +def hookimpl_config_from_mapping( + opts: Mapping[str, Any], +) -> HookimplConfiguration: + """Build a :class:`HookimplConfiguration` from a mapping. + + Intended for pytest/support migration only — not the public options API. + Prefer constructing :class:`HookimplConfiguration` directly. + """ + return HookimplConfiguration( + wrapper=bool(opts.get("wrapper", False)), + hookwrapper=bool(opts.get("hookwrapper", False)), + optionalhook=bool(opts.get("optionalhook", False)), + tryfirst=bool(opts.get("tryfirst", False)), + trylast=bool(opts.get("trylast", False)), + specname=opts.get("specname"), + ) + + +def hookspec_config_to_mapping( + config: HookspecConfiguration, +) -> dict[str, Any]: + """Serialize configuration to a legacy mapping (pytest/support only).""" + return { + "firstresult": config.firstresult, + "historic": config.historic, + "warn_on_impl": config.warn_on_impl, + "warn_on_impl_args": config.warn_on_impl_args, + } + + +def hookimpl_config_to_mapping( + config: HookimplConfiguration, +) -> dict[str, Any]: + """Serialize configuration to a legacy mapping (pytest/support only).""" + return { + "wrapper": config.wrapper, + "hookwrapper": config.hookwrapper, + "optionalhook": config.optionalhook, + "tryfirst": config.tryfirst, + "trylast": config.trylast, + "specname": config.specname, + } diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index b92b53a0..347c70ad 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -17,8 +17,8 @@ from typing import TypeVar import warnings -from ._config import HookimplOpts -from ._config import HookspecOpts +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration _F = TypeVar("_F", bound=Callable[..., object]) @@ -96,15 +96,13 @@ def __call__( # noqa: F811 """ def setattr_hookspec_opts(func: _F) -> _F: - if historic and firstresult: - raise ValueError("cannot have a historic firstresult hook") - opts: HookspecOpts = { - "firstresult": firstresult, - "historic": historic, - "warn_on_impl": warn_on_impl, - "warn_on_impl_args": warn_on_impl_args, - } - setattr(func, self.project_name + "_spec", opts) + config = HookspecConfiguration( + firstresult=firstresult, + historic=historic, + warn_on_impl=warn_on_impl, + warn_on_impl_args=warn_on_impl_args, + ) + setattr(func, self.project_name + "_spec", config) return func if function is not None: @@ -213,15 +211,15 @@ def __call__( # noqa: F811 """ def setattr_hookimpl_opts(func: _F) -> _F: - opts: HookimplOpts = { - "wrapper": wrapper, - "hookwrapper": hookwrapper, - "optionalhook": optionalhook, - "tryfirst": tryfirst, - "trylast": trylast, - "specname": specname, - } - setattr(func, self.project_name + "_impl", opts) + config = HookimplConfiguration( + wrapper=wrapper, + hookwrapper=hookwrapper, + optionalhook=optionalhook, + tryfirst=tryfirst, + trylast=trylast, + specname=specname, + ) + setattr(func, self.project_name + "_impl", config) return func if function is None: @@ -339,7 +337,9 @@ class HookSpec: "warn_on_impl_args", ) - def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None: + def __init__( + self, namespace: _Namespace, name: str, opts: HookspecConfiguration + ) -> None: self.namespace = namespace self.name = name self.function: Callable[..., object] = getattr(namespace, name) @@ -350,5 +350,5 @@ def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None self.function, legacy_noself=legacy_noself ) self.opts = opts - self.warn_on_impl = opts.get("warn_on_impl") - self.warn_on_impl_args = opts.get("warn_on_impl_args") + self.warn_on_impl = opts.warn_on_impl + self.warn_on_impl_args = opts.warn_on_impl_args diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index dab705ec..721c58b9 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -13,9 +13,8 @@ from ._caller import _SubsetHookCaller from ._caller import HookCaller from ._caller import HookRelay -from ._config import HookimplOpts -from ._config import HookspecOpts -from ._config import normalize_hookimpl_opts +from ._config import HookimplConfiguration +from ._config import HookspecConfiguration from ._decorators import _Namespace from ._decorators import HookimplMarker from ._decorators import HookSpec @@ -27,9 +26,8 @@ __all__ = [ - "HookspecOpts", - "HookimplOpts", - "normalize_hookimpl_opts", + "HookspecConfiguration", + "HookimplConfiguration", "HookspecMarker", "HookimplMarker", "HookSpec", diff --git a/src/pluggy/_implementation.py b/src/pluggy/_implementation.py index 19cb91ff..b631be01 100644 --- a/src/pluggy/_implementation.py +++ b/src/pluggy/_implementation.py @@ -11,7 +11,7 @@ from typing import TypeAlias from typing import TypeVar -from ._config import HookimplOpts +from ._config import HookimplConfiguration from ._decorators import varnames from ._result import Result @@ -45,7 +45,7 @@ def __init__( plugin: _Plugin, plugin_name: str, function: _HookImplFunction[object], - hook_impl_opts: HookimplOpts, + hook_impl_opts: HookimplConfiguration, ) -> None: """:meta private:""" #: The hook implementation function. @@ -57,24 +57,25 @@ def __init__( self.kwargnames: Final = kwargnames #: The plugin which defined this hook implementation. self.plugin: Final = plugin - #: The :class:`HookimplOpts` used to configure this hook implementation. + #: The :class:`HookimplConfiguration` used to configure this hook + #: implementation. self.opts: Final = hook_impl_opts #: The name of the plugin which defined this hook implementation. self.plugin_name: Final = plugin_name #: Whether the hook implementation is a :ref:`wrapper `. - self.wrapper: Final = hook_impl_opts["wrapper"] + self.wrapper: Final = hook_impl_opts.wrapper #: Whether the hook implementation is an :ref:`old-style wrapper #: `. - self.hookwrapper: Final = hook_impl_opts["hookwrapper"] + self.hookwrapper: Final = hook_impl_opts.hookwrapper #: Whether validation against a hook specification is :ref:`optional #: `. - self.optionalhook: Final = hook_impl_opts["optionalhook"] + self.optionalhook: Final = hook_impl_opts.optionalhook #: Whether to try to order this hook implementation :ref:`first #: `. - self.tryfirst: Final = hook_impl_opts["tryfirst"] + self.tryfirst: Final = hook_impl_opts.tryfirst #: Whether to try to order this hook implementation :ref:`last #: `. - self.trylast: Final = hook_impl_opts["trylast"] + self.trylast: Final = hook_impl_opts.trylast def __repr__(self) -> str: return f"" diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 426e0a3b..e5c19092 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -15,16 +15,21 @@ from . import _tracing from ._callers import _multicall +from ._config import hookimpl_config_from_mapping +from ._config import hookimpl_config_to_mapping +from ._config import HookimplConfiguration +from ._config import hookspec_config_from_mapping +from ._config import hookspec_config_to_mapping +from ._config import HookspecConfiguration from ._hooks import _HookImplFunction from ._hooks import _Namespace from ._hooks import _Plugin from ._hooks import _SubsetHookCaller from ._hooks import HookCaller from ._hooks import HookImpl -from ._hooks import HookimplOpts from ._hooks import HookRelay -from ._hooks import HookspecOpts -from ._hooks import normalize_hookimpl_opts +from ._pytest_compat import HookimplOpts +from ._pytest_compat import HookspecOpts from ._result import Result @@ -142,12 +147,11 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: # register matching hook implementations of the plugin for name in dir(plugin): - hookimpl_opts = self.parse_hookimpl_opts(plugin, name) - if hookimpl_opts is not None: - normalize_hookimpl_opts(hookimpl_opts) + hookimpl_config = self._discover_hookimpl_configuration(plugin, name) + if hookimpl_config is not None: method: _HookImplFunction[object] = getattr(plugin, name) - hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts) - name = hookimpl_opts.get("specname") or name + hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_config) + name = hookimpl_config.specname or name hook: HookCaller | None = getattr(self.hook, name, None) if hook is None: hook = HookCaller(name, self._hookexec) @@ -158,30 +162,61 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: hook._add_hookimpl(hookimpl) return plugin_name - def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None: - """Try to obtain a hook implementation from an item with the given name - in the given plugin which is being searched for hook impls. - - :returns: - The parsed hookimpl options, or None to skip the given item. - - This method can be overridden by ``PluginManager`` subclasses to - customize how hook implementation are picked up. By default, returns the - options for items decorated with :class:`HookimplMarker`. - """ - method: object = getattr(plugin, name) + def _read_hookimpl_configuration( + self, plugin: _Plugin, name: str + ) -> HookimplConfiguration | None: + """Read a modern :class:`HookimplConfiguration` from a plugin attribute.""" + try: + method: object = getattr(plugin, name) + except Exception: + # dir() can include properties that are not safely readable yet + # (e.g. pytest Config during early registration). + return None if not inspect.isroutine(method): return None try: - res: HookimplOpts | None = getattr( - method, self.project_name + "_impl", None - ) + res: object = getattr(method, self.project_name + "_impl", None) except Exception: # pragma: no cover - res = {} # type: ignore[assignment] #pragma: no cover - if res is not None and not isinstance(res, dict): - # false positive - res = None # type:ignore[unreachable] #pragma: no cover - return res + return None + if isinstance(res, HookimplConfiguration): + return res + if isinstance(res, Mapping): + return hookimpl_config_from_mapping(res) + return None + + def _discover_hookimpl_configuration( + self, plugin: _Plugin, name: str + ) -> HookimplConfiguration | None: + """Discover hookimpl configuration for registration. + + Prefer the modern marker attribute. Only call the deprecated + :meth:`parse_hookimpl_opts` when a subclass actually overrides it and + no modern configuration was found (pytest unmarked-hook concession). + """ + config = self._read_hookimpl_configuration(plugin, name) + if config is not None: + return config + parse_hookimpl_opts = type(self).parse_hookimpl_opts + if parse_hookimpl_opts is PluginManager.parse_hookimpl_opts: + return None + legacy = parse_hookimpl_opts(self, plugin, name) + if legacy is None: + return None + return hookimpl_config_from_mapping(legacy) + + def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None: + """Return legacy dict-shaped hookimpl options, if any. + + .. deprecated:: + Thin pytest/support concession. Registration uses private discovery + of :class:`HookimplConfiguration` and only invokes this method when + a subclass overrides it and no modern configuration attribute was + found. Prefer marker-attached configuration objects. + """ + config = self._read_hookimpl_configuration(plugin, name) + if config is None: + return None + return cast(HookimplOpts, hookimpl_config_to_mapping(config)) def unregister( self, plugin: _Plugin | None = None, name: str | None = None @@ -242,15 +277,15 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: """ names = [] for name in dir(module_or_class): - spec_opts = self.parse_hookspec_opts(module_or_class, name) - if spec_opts is not None: + spec_config = self._discover_hookspec_configuration(module_or_class, name) + if spec_config is not None: hc: HookCaller | None = getattr(self.hook, name, None) if hc is None: - hc = HookCaller(name, self._hookexec, module_or_class, spec_opts) + hc = HookCaller(name, self._hookexec, module_or_class, spec_config) setattr(self.hook, name, hc) else: # Plugins registered this hook without knowing the spec. - hc.set_specification(module_or_class, spec_opts) + hc.set_specification(module_or_class, spec_config) for hookfunction in hc.get_hookimpls(): self._verify_hook(hc, hookfunction) names.append(name) @@ -260,23 +295,59 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: f"did not find any {self.project_name!r} hooks in {module_or_class!r}" ) + def _read_hookspec_configuration( + self, module_or_class: _Namespace, name: str + ) -> HookspecConfiguration | None: + """Read a modern :class:`HookspecConfiguration` from a marked function.""" + try: + method = getattr(module_or_class, name) + except Exception: + return None + try: + opts: object = getattr(method, self.project_name + "_spec", None) + except Exception: # pragma: no cover + return None + if isinstance(opts, HookspecConfiguration): + return opts + if isinstance(opts, Mapping): + return hookspec_config_from_mapping(opts) + return None + + def _discover_hookspec_configuration( + self, module_or_class: _Namespace, name: str + ) -> HookspecConfiguration | None: + """Discover hookspec configuration for ``add_hookspecs``. + + Prefer the modern marker attribute. Only call the deprecated + :meth:`parse_hookspec_opts` when a subclass actually overrides it and + no modern configuration was found. + """ + config = self._read_hookspec_configuration(module_or_class, name) + if config is not None: + return config + parse_hookspec_opts = type(self).parse_hookspec_opts + if parse_hookspec_opts is PluginManager.parse_hookspec_opts: + return None + legacy = parse_hookspec_opts(self, module_or_class, name) + if legacy is None: + return None + return hookspec_config_from_mapping(legacy) + def parse_hookspec_opts( self, module_or_class: _Namespace, name: str ) -> HookspecOpts | None: - """Try to obtain a hook specification from an item with the given name - in the given module or class which is being searched for hook specs. - - :returns: - The parsed hookspec options for defining a hook, or None to skip the - given item. + """Return legacy dict-shaped hookspec options, if any. - This method can be overridden by ``PluginManager`` subclasses to - customize how hook specifications are picked up. By default, returns the - options for items decorated with :class:`HookspecMarker`. + .. deprecated:: + Thin pytest/support concession. ``add_hookspecs`` uses private + discovery of :class:`HookspecConfiguration` and only invokes this + method when a subclass overrides it and no modern configuration + attribute was found. Prefer marker-attached configuration objects. """ - method = getattr(module_or_class, name) - opts: HookspecOpts | None = getattr(method, self.project_name + "_spec", None) - return opts + config = self._read_hookspec_configuration(module_or_class, name) + if config is None: + return None + return cast(HookspecOpts, hookspec_config_to_mapping(config)) def get_plugins(self) -> set[Any]: """Return a set of all registered plugin objects.""" diff --git a/src/pluggy/_pytest_compat.py b/src/pluggy/_pytest_compat.py new file mode 100644 index 00000000..9088c0f8 --- /dev/null +++ b/src/pluggy/_pytest_compat.py @@ -0,0 +1,55 @@ +"""Pytest/support compatibility helpers for legacy option encodings. + +The live pluggy API uses :class:`~pluggy.HookspecConfiguration` and +:class:`~pluggy.HookimplConfiguration`. This module keeps TypedDict shapes and +mapping conversion for pytest and other callers that still type or attach +dict-shaped options during migration. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TypedDict + +from ._config import hookimpl_config_from_mapping +from ._config import HookimplConfiguration +from ._config import hookspec_config_from_mapping +from ._config import HookspecConfiguration + + +class HookspecOpts(TypedDict): + """Legacy TypedDict for hook specification options. + + Prefer :class:`~pluggy.HookspecConfiguration`. Kept for pytest/typing + compatibility during migration. + """ + + firstresult: bool + historic: bool + warn_on_impl: Warning | None + warn_on_impl_args: Mapping[str, Warning] | None + + +class HookimplOpts(TypedDict): + """Legacy TypedDict for hook implementation options. + + Prefer :class:`~pluggy.HookimplConfiguration`. Kept for pytest/typing + compatibility during migration. + """ + + wrapper: bool + hookwrapper: bool + optionalhook: bool + tryfirst: bool + trylast: bool + specname: str | None + + +__all__ = [ + "HookspecOpts", + "HookimplOpts", + "HookspecConfiguration", + "HookimplConfiguration", + "hookspec_config_from_mapping", + "hookimpl_config_from_mapping", +] diff --git a/testing/test_configuration.py b/testing/test_configuration.py new file mode 100644 index 00000000..c92ee6f3 --- /dev/null +++ b/testing/test_configuration.py @@ -0,0 +1,276 @@ +""" +Tests for configuration classes. +""" + +from __future__ import annotations + +import pytest + +from pluggy import HookimplConfiguration +from pluggy import HookimplMarker +from pluggy import HookspecConfiguration +from pluggy import HookspecMarker +from pluggy import PluginManager +from pluggy._config import hookimpl_config_from_mapping +from pluggy._config import hookspec_config_from_mapping + + +class TestHookspecConfiguration: + def test_basic_creation(self) -> None: + config = HookspecConfiguration() + assert config.firstresult is False + assert config.historic is False + assert config.warn_on_impl is None + assert config.warn_on_impl_args is None + + def test_firstresult(self) -> None: + config = HookspecConfiguration(firstresult=True) + assert config.firstresult is True + assert config.historic is False + + def test_historic(self) -> None: + config = HookspecConfiguration(historic=True) + assert config.firstresult is False + assert config.historic is True + + def test_historic_firstresult_validation(self) -> None: + with pytest.raises(ValueError, match="cannot have a historic firstresult"): + HookspecConfiguration(historic=True, firstresult=True) + + def test_warn_on_impl(self) -> None: + warning = UserWarning("test warning") + config = HookspecConfiguration(warn_on_impl=warning) + assert config.warn_on_impl is warning + + def test_warn_on_impl_args(self) -> None: + warnings_dict = {"arg1": UserWarning("arg1 warning")} + config = HookspecConfiguration(warn_on_impl_args=warnings_dict) + assert config.warn_on_impl_args is warnings_dict + + +class TestHookimplConfiguration: + def test_basic_creation(self) -> None: + config = HookimplConfiguration() + assert config.wrapper is False + assert config.hookwrapper is False + assert config.optionalhook is False + assert config.tryfirst is False + assert config.trylast is False + assert config.specname is None + + def test_wrapper(self) -> None: + config = HookimplConfiguration(wrapper=True) + assert config.wrapper is True + assert config.hookwrapper is False + + def test_hookwrapper(self) -> None: + config = HookimplConfiguration(hookwrapper=True) + assert config.wrapper is False + assert config.hookwrapper is True + + def test_both_wrappers_allowed(self) -> None: + """Both wrapper types are allowed at config level; validation is later.""" + config = HookimplConfiguration(wrapper=True, hookwrapper=True) + assert config.wrapper is True + assert config.hookwrapper is True + + def test_tryfirst(self) -> None: + config = HookimplConfiguration(tryfirst=True) + assert config.tryfirst is True + assert config.trylast is False + + def test_trylast(self) -> None: + config = HookimplConfiguration(trylast=True) + assert config.tryfirst is False + assert config.trylast is True + + def test_optionalhook(self) -> None: + config = HookimplConfiguration(optionalhook=True) + assert config.optionalhook is True + + def test_specname(self) -> None: + config = HookimplConfiguration(specname="custom_name") + assert config.specname == "custom_name" + + +class TestMappingShim: + def test_hookspec_config_from_mapping(self) -> None: + warning = UserWarning("w") + config = hookspec_config_from_mapping( + { + "firstresult": True, + "warn_on_impl": warning, + } + ) + assert config.firstresult is True + assert config.historic is False + assert config.warn_on_impl is warning + + def test_hookimpl_config_from_mapping(self) -> None: + config = hookimpl_config_from_mapping( + { + "tryfirst": True, + "specname": "other", + } + ) + assert config.tryfirst is True + assert config.specname == "other" + assert config.wrapper is False + + def test_read_hookimpl_accepts_legacy_dict_attribute(self) -> None: + pm = PluginManager("test") + + def method() -> str: + return "ok" + + setattr(method, "test_impl", {"tryfirst": True}) + + class Plugin: + pass + + plugin = Plugin() + plugin.method = method # type: ignore[attr-defined] + config = pm._read_hookimpl_configuration(plugin, "method") + assert isinstance(config, HookimplConfiguration) + assert config.tryfirst is True + + def test_parse_hookimpl_opts_returns_legacy_dict(self) -> None: + hookimpl = HookimplMarker("test") + + @hookimpl(tryfirst=True) + def method() -> str: + return "ok" + + class Plugin: + pass + + plugin = Plugin() + plugin.method = method # type: ignore[attr-defined] + opts = PluginManager("test").parse_hookimpl_opts(plugin, "method") + assert opts == { + "wrapper": False, + "hookwrapper": False, + "optionalhook": False, + "tryfirst": True, + "trylast": False, + "specname": None, + } + + def test_read_hookspec_accepts_legacy_dict_attribute(self) -> None: + pm = PluginManager("test") + + class Spec: + def myhook(self) -> None: + pass + + setattr(Spec.myhook, "test_spec", {"firstresult": True}) + config = pm._read_hookspec_configuration(Spec, "myhook") + assert isinstance(config, HookspecConfiguration) + assert config.firstresult is True + + def test_discover_skips_parse_hookimpl_opts_unless_overridden(self) -> None: + calls: list[str] = [] + + class TrackingPluginManager(PluginManager): + def parse_hookimpl_opts(self, plugin: object, name: str): + calls.append(name) + return super().parse_hookimpl_opts(plugin, name) + + class Spec: + @HookspecMarker("test") + def marked(self) -> None: + pass + + def unmarked(self) -> None: + pass + + class Plugin: + @HookimplMarker("test") + def marked(self) -> str: + return "marked" + + def unmarked(self) -> str: + return "unmarked" + + pm = TrackingPluginManager("test") + pm.add_hookspecs(Spec) + pm.register(Plugin()) + # Marked impl is discovered privately; unmarked has no config and the + # override returns None, so parse_hookimpl_opts is only tried for names + # without a modern configuration attribute. + assert "marked" not in calls + assert "unmarked" in calls + + +def test_markers_attach_configuration_objects() -> None: + hookspec = HookspecMarker("test") + hookimpl = HookimplMarker("test") + + @hookspec(firstresult=True) + def myspec(arg: object) -> None: + pass + + @hookimpl(tryfirst=True) + def myimpl(arg: object) -> str: + return "x" + + spec_config = getattr(myspec, "test_spec") + impl_config = getattr(myimpl, "test_impl") + assert isinstance(spec_config, HookspecConfiguration) + assert spec_config.firstresult is True + assert isinstance(impl_config, HookimplConfiguration) + assert impl_config.tryfirst is True + + +def test_config_integration_with_hooks() -> None: + pm = PluginManager("test") + hookspec = HookspecMarker("test") + hookimpl = HookimplMarker("test") + + class MySpec: + @hookspec(firstresult=True) + def myhook(self, arg: object) -> None: + pass + + class Plugin1: + @hookimpl(trylast=True) + def myhook(self, arg: object) -> str: + return f"plugin1: {arg}" + + class Plugin2: + @hookimpl(tryfirst=True) + def myhook(self, arg: object) -> str: + return f"plugin2: {arg}" + + pm.add_hookspecs(MySpec) + pm.register(Plugin1()) + pm.register(Plugin2()) + + result = pm.hook.myhook(arg="test") + assert result == "plugin2: test" + + +def test_historic_hook_configuration() -> None: + pm = PluginManager("test") + hookspec = HookspecMarker("test") + hookimpl = HookimplMarker("test") + + results: list[str] = [] + + class MySpec: + @hookspec(historic=True) + def myhook(self, arg: object) -> None: + pass + + pm.add_hookspecs(MySpec) + pm.hook.myhook.call_historic( + kwargs={"arg": "call1"}, result_callback=results.append + ) + + class Plugin1: + @hookimpl + def myhook(self, arg: object) -> str: + return f"plugin1: {arg}" + + pm.register(Plugin1()) + assert "plugin1: call1" in results diff --git a/testing/test_details.py b/testing/test_details.py index 237b7de1..838a7d35 100644 --- a/testing/test_details.py +++ b/testing/test_details.py @@ -16,10 +16,11 @@ def test_parse_hookimpl_override() -> None: class MyPluginManager(PluginManager): def parse_hookimpl_opts(self, module_or_class, name): opts = PluginManager.parse_hookimpl_opts(self, module_or_class, name) - if opts is None: - if name.startswith("x1"): - opts = {} # type: ignore[assignment] - return opts + if opts is not None: + return opts + if name.startswith("x1"): + return {} + return None class Plugin: def x1meth(self): diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index 0c9f91bd..17e3d812 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -317,11 +317,11 @@ def he_myhook3(self, arg1) -> None: pm.add_hookspecs(HookSpec) assert pm.hook.he_myhook1.spec is not None - assert not pm.hook.he_myhook1.spec.opts["firstresult"] + assert not pm.hook.he_myhook1.spec.opts.firstresult assert pm.hook.he_myhook2.spec is not None - assert pm.hook.he_myhook2.spec.opts["firstresult"] + assert pm.hook.he_myhook2.spec.opts.firstresult assert pm.hook.he_myhook3.spec is not None - assert not pm.hook.he_myhook3.spec.opts["firstresult"] + assert not pm.hook.he_myhook3.spec.opts.firstresult @pytest.mark.parametrize("name", ["hookwrapper", "optionalhook", "tryfirst", "trylast"]) @@ -332,7 +332,7 @@ def he_myhook1(arg1) -> None: pass if val: - assert he_myhook1.example_impl.get(name) + assert getattr(he_myhook1.example_impl, name) else: assert not hasattr(he_myhook1, name) From 5b654ff56ff35aeb990f83408699f9a04a549994 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 11:20:10 +0200 Subject: [PATCH 4/8] feat(decorators): attach Hook*Configuration objects on marked functions Complete design step 03: markers already attach configuration objects since step 02; this finishes the step by storing the spec configuration as HookSpec.config (try-claude naming) with a deprecated .opts alias, reading .config in HookCaller firstresult resolution, and covering decoration-time historic+firstresult validation and configuration attachment with tests. Co-Authored-By: Claude Fable 5 --- changelog/706.trivial.rst | 3 +++ src/pluggy/_caller.py | 4 ++-- src/pluggy/_decorators.py | 19 ++++++++++++++----- testing/test_configuration.py | 28 ++++++++++++++++++++++++++++ testing/test_hookcaller.py | 6 +++--- 5 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 changelog/706.trivial.rst diff --git a/changelog/706.trivial.rst b/changelog/706.trivial.rst new file mode 100644 index 00000000..88d43f7e --- /dev/null +++ b/changelog/706.trivial.rst @@ -0,0 +1,3 @@ +``HookSpec`` now stores its :class:`pluggy.HookspecConfiguration` as +``config``; the old ``opts`` attribute remains as a deprecated alias +property. diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 10f61043..502a5d15 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -183,7 +183,7 @@ def __call__(self, **kwargs: object) -> Any: "Cannot directly call a historic hook - use call_historic instead." ) self._verify_all_args_are_provided(kwargs) - firstresult = self.spec.opts.firstresult if self.spec else False + firstresult = self.spec.config.firstresult if self.spec else False # Copy because plugins may register other plugins during iteration (#438). return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) @@ -238,7 +238,7 @@ def call_extra( ): i -= 1 hookimpls.insert(i + 1, hookimpl) - firstresult = self.spec.opts.firstresult if self.spec else False + firstresult = self.spec.config.firstresult if self.spec else False return self._hookexec(self.name, hookimpls, kwargs, firstresult) def _maybe_apply_history(self, method: HookImpl) -> None: diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index 347c70ad..6c15df46 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -332,13 +332,13 @@ class HookSpec: "name", "argnames", "kwargnames", - "opts", + "config", "warn_on_impl", "warn_on_impl_args", ) def __init__( - self, namespace: _Namespace, name: str, opts: HookspecConfiguration + self, namespace: _Namespace, name: str, config: HookspecConfiguration ) -> None: self.namespace = namespace self.name = name @@ -349,6 +349,15 @@ def __init__( self.argnames, self.kwargnames = varnames( self.function, legacy_noself=legacy_noself ) - self.opts = opts - self.warn_on_impl = opts.warn_on_impl - self.warn_on_impl_args = opts.warn_on_impl_args + self.config = config + self.warn_on_impl = config.warn_on_impl + self.warn_on_impl_args = config.warn_on_impl_args + + @property + def opts(self) -> HookspecConfiguration: + """Alias for :attr:`config`. + + .. deprecated:: + Use :attr:`config` instead. + """ + return self.config diff --git a/testing/test_configuration.py b/testing/test_configuration.py index c92ee6f3..513091b9 100644 --- a/testing/test_configuration.py +++ b/testing/test_configuration.py @@ -222,6 +222,34 @@ def myimpl(arg: object) -> str: assert impl_config.tryfirst is True +def test_historic_firstresult_raises_at_decoration_time() -> None: + hookspec = HookspecMarker("test") + + with pytest.raises(ValueError, match="cannot have a historic firstresult"): + + @hookspec(historic=True, firstresult=True) + def myspec() -> None: + pass + + +def test_hookspec_stores_configuration() -> None: + pm = PluginManager("test") + hookspec = HookspecMarker("test") + + class Spec: + @hookspec(firstresult=True) + def myhook(self, arg: object) -> None: + pass + + pm.add_hookspecs(Spec) + spec = pm.hook.myhook.spec + assert spec is not None + assert isinstance(spec.config, HookspecConfiguration) + assert spec.config.firstresult is True + # Deprecated alias. + assert spec.opts is spec.config + + def test_config_integration_with_hooks() -> None: pm = PluginManager("test") hookspec = HookspecMarker("test") diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index 17e3d812..5db79718 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -317,11 +317,11 @@ def he_myhook3(self, arg1) -> None: pm.add_hookspecs(HookSpec) assert pm.hook.he_myhook1.spec is not None - assert not pm.hook.he_myhook1.spec.opts.firstresult + assert not pm.hook.he_myhook1.spec.config.firstresult assert pm.hook.he_myhook2.spec is not None - assert pm.hook.he_myhook2.spec.opts.firstresult + assert pm.hook.he_myhook2.spec.config.firstresult assert pm.hook.he_myhook3.spec is not None - assert not pm.hook.he_myhook3.spec.opts.firstresult + assert not pm.hook.he_myhook3.spec.config.firstresult @pytest.mark.parametrize("name", ["hookwrapper", "optionalhook", "tryfirst", "trylast"]) From 6338f759660b80f6331cb396116a467dd3f34ef5 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 12:30:04 +0200 Subject: [PATCH 5/8] feat(implementation): add NormalImpl, WrapperImpl, and CompletionHook 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 --- changelog/707.feature.rst | 9 ++ docs/api_reference.rst | 8 ++ src/pluggy/__init__.py | 4 + src/pluggy/_caller.py | 4 +- src/pluggy/_config.py | 29 +++++ src/pluggy/_execution.py | 8 +- src/pluggy/_hooks.py | 6 + src/pluggy/_implementation.py | 165 +++++++++++++++++++++++-- src/pluggy/_manager.py | 2 +- testing/test_details.py | 2 +- testing/test_implementation.py | 218 +++++++++++++++++++++++++++++++++ 11 files changed, 433 insertions(+), 22 deletions(-) create mode 100644 changelog/707.feature.rst create mode 100644 testing/test_implementation.py diff --git a/changelog/707.feature.rst b/changelog/707.feature.rst new file mode 100644 index 00000000..cbdca55e --- /dev/null +++ b/changelog/707.feature.rst @@ -0,0 +1,9 @@ +Hook implementations are now represented by dedicated types: +:class:`pluggy.NormalImpl` for normal implementations and +:class:`pluggy.WrapperImpl` for (old- and new-style) wrappers, both +subclasses of :class:`pluggy.HookImpl`. +``HookimplConfiguration.create_hookimpl()`` selects the appropriate +subclass, and ``WrapperImpl.setup_and_get_completion_hook()`` exposes +wrapper setup/teardown as a ``CompletionHook`` callback. +``HookImpl`` now stores its configuration as ``hookimpl_config``; the old +``opts`` attribute remains as a deprecated alias property. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 7d19a4a6..a62272f7 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -40,6 +40,14 @@ API Reference .. autoclass:: pluggy.HookImpl() :members: +.. autoclass:: pluggy.NormalImpl() + :show-inheritance: + :members: + +.. autoclass:: pluggy.WrapperImpl() + :show-inheritance: + :members: + .. autoclass:: pluggy.HookspecConfiguration() :members: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index ba9ed05d..52592ce1 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -9,6 +9,8 @@ "HookspecOpts", "HookimplOpts", "HookImpl", + "NormalImpl", + "WrapperImpl", "HookRelay", "HookspecMarker", "HookimplMarker", @@ -23,6 +25,8 @@ from ._hooks import HookimplMarker from ._hooks import HookRelay from ._hooks import HookspecMarker +from ._hooks import NormalImpl +from ._hooks import WrapperImpl from ._manager import PluginManager from ._manager import PluginValidationError from ._pytest_compat import HookimplOpts diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 502a5d15..25e4a3d4 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -224,10 +224,10 @@ def call_extra( "Cannot directly call a historic hook - use call_historic instead." ) self._verify_all_args_are_provided(kwargs) - opts = HookimplConfiguration() + config = HookimplConfiguration() hookimpls = self._hookimpls.copy() for method in methods: - hookimpl = HookImpl(None, "", method, opts) + hookimpl = config.create_hookimpl(None, "", method) # Find last non-tryfirst nonwrapper method. i = len(hookimpls) - 1 while i >= 0 and ( diff --git a/src/pluggy/_config.py b/src/pluggy/_config.py index 43eef7c6..d3d6291a 100644 --- a/src/pluggy/_config.py +++ b/src/pluggy/_config.py @@ -8,6 +8,14 @@ from typing import Any from typing import Final from typing import final +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from ._implementation import _HookImplFunction + from ._implementation import _Plugin + from ._implementation import NormalImpl + from ._implementation import WrapperImpl @final @@ -98,6 +106,27 @@ def __init__( #: The name of the hook specification to match, see :ref:`specname`. self.specname = specname + def create_hookimpl( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + ) -> NormalImpl | WrapperImpl: + """Create the appropriate :class:`HookImpl` subclass for this + configuration. + + Wrapper configurations produce a :class:`WrapperImpl`; all others + produce a :class:`NormalImpl`. + """ + # Local import to avoid a circular import with the implementation + # module. + from ._implementation import NormalImpl + from ._implementation import WrapperImpl + + if self.wrapper or self.hookwrapper: + return WrapperImpl(plugin, plugin_name, function, self) + return NormalImpl(plugin, plugin_name, function, self) + def __repr__(self) -> str: attrs = [ f"{slot}={getattr(self, slot)!r}" diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index a41c9e1a..ef644941 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -14,7 +14,6 @@ import warnings from ._implementation import HookImpl -from ._result import HookCallError from ._result import Result from ._warnings import PluggyTeardownRaisedWarning @@ -96,12 +95,7 @@ def _multicall( teardowns: list[Teardown] = [] try: # run impl and wrapper setup functions in a loop for hook_impl in reversed(hook_impls): - try: - args = [caller_kwargs[argname] for argname in hook_impl.argnames] - except KeyError as e: - raise HookCallError( - f"hook call must provide argument {e.args[0]!r}" - ) from e + args = hook_impl._get_call_args(caller_kwargs) if hook_impl.hookwrapper: function_gen = run_old_style_hookwrapper(hook_impl, hook_name, args) diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 721c58b9..4b717ae0 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -22,7 +22,10 @@ from ._decorators import varnames from ._implementation import _HookImplFunction from ._implementation import _Plugin +from ._implementation import CompletionHook from ._implementation import HookImpl +from ._implementation import NormalImpl +from ._implementation import WrapperImpl __all__ = [ @@ -38,6 +41,9 @@ "_HookRelay", "_SubsetHookCaller", "HookImpl", + "NormalImpl", + "WrapperImpl", + "CompletionHook", "_HookImplFunction", "_Namespace", "_Plugin", diff --git a/src/pluggy/_implementation.py b/src/pluggy/_implementation.py index b631be01..78961b0b 100644 --- a/src/pluggy/_implementation.py +++ b/src/pluggy/_implementation.py @@ -6,13 +6,18 @@ from collections.abc import Callable from collections.abc import Generator +from collections.abc import Mapping +from typing import cast from typing import Final from typing import final +from typing import Protocol +from typing import runtime_checkable from typing import TypeAlias from typing import TypeVar from ._config import HookimplConfiguration from ._decorators import varnames +from ._result import HookCallError from ._result import Result @@ -22,16 +27,30 @@ _HookImplFunction: TypeAlias = Callable[..., _T | Generator[None, Result[_T], None]] -@final +@runtime_checkable +class CompletionHook(Protocol): + """Teardown callback returned by :meth:`WrapperImpl.setup_and_get_completion_hook`. + + Receives the current ``(result, exception)`` outcome of the hook call and + returns the possibly replaced ``(result, exception)`` pair. + """ + + def __call__( + self, + result: object | list[object] | None, + exception: BaseException | None, + ) -> tuple[object | list[object] | None, BaseException | None]: ... + + class HookImpl: - """A hook implementation in a :class:`HookCaller`.""" + """Base class for hook implementations in a :class:`HookCaller`.""" __slots__ = ( "function", "argnames", "kwargnames", "plugin", - "opts", + "hookimpl_config", "plugin_name", "wrapper", "hookwrapper", @@ -45,7 +64,7 @@ def __init__( plugin: _Plugin, plugin_name: str, function: _HookImplFunction[object], - hook_impl_opts: HookimplConfiguration, + hook_impl_config: HookimplConfiguration, ) -> None: """:meta private:""" #: The hook implementation function. @@ -59,23 +78,147 @@ def __init__( self.plugin: Final = plugin #: The :class:`HookimplConfiguration` used to configure this hook #: implementation. - self.opts: Final = hook_impl_opts + self.hookimpl_config: Final = hook_impl_config #: The name of the plugin which defined this hook implementation. self.plugin_name: Final = plugin_name #: Whether the hook implementation is a :ref:`wrapper `. - self.wrapper: Final = hook_impl_opts.wrapper + self.wrapper: Final = hook_impl_config.wrapper #: Whether the hook implementation is an :ref:`old-style wrapper #: `. - self.hookwrapper: Final = hook_impl_opts.hookwrapper + self.hookwrapper: Final = hook_impl_config.hookwrapper #: Whether validation against a hook specification is :ref:`optional #: `. - self.optionalhook: Final = hook_impl_opts.optionalhook + self.optionalhook: Final = hook_impl_config.optionalhook #: Whether to try to order this hook implementation :ref:`first #: `. - self.tryfirst: Final = hook_impl_opts.tryfirst + self.tryfirst: Final = hook_impl_config.tryfirst #: Whether to try to order this hook implementation :ref:`last #: `. - self.trylast: Final = hook_impl_opts.trylast + self.trylast: Final = hook_impl_config.trylast + + @property + def opts(self) -> HookimplConfiguration: + """Alias for :attr:`hookimpl_config`. + + .. deprecated:: + Use :attr:`hookimpl_config` instead. + """ + return self.hookimpl_config + + def _get_call_args(self, caller_kwargs: Mapping[str, object]) -> list[object]: + """Extract the positional arguments for calling this hook implementation. + + :raises HookCallError: If a required argument is missing. + """ + try: + return [caller_kwargs[argname] for argname in self.argnames] + except KeyError as e: + raise HookCallError(f"hook call must provide argument {e.args[0]!r}") from e def __repr__(self) -> str: - return f"" + return ( + f"<{type(self).__name__} " + f"plugin_name={self.plugin_name!r}, plugin={self.plugin!r}>" + ) + + +@final +class NormalImpl(HookImpl): + """A normal (non-wrapper) hook implementation in a :class:`HookCaller`.""" + + def __init__( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + hook_impl_config: HookimplConfiguration, + ) -> None: + """:meta private:""" + if hook_impl_config.wrapper or hook_impl_config.hookwrapper: + raise ValueError( + "NormalImpl cannot be used for wrapper implementations. " + "Use WrapperImpl instead." + ) + super().__init__(plugin, plugin_name, function, hook_impl_config) + + +@final +class WrapperImpl(HookImpl): + """A wrapper hook implementation in a :class:`HookCaller`.""" + + def __init__( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + hook_impl_config: HookimplConfiguration, + ) -> None: + """:meta private:""" + if not (hook_impl_config.wrapper or hook_impl_config.hookwrapper): + raise ValueError( + "WrapperImpl can only be used for wrapper implementations. " + "Use NormalImpl for normal implementations." + ) + super().__init__(plugin, plugin_name, function, hook_impl_config) + + def setup_and_get_completion_hook( + self, hook_name: str, caller_kwargs: Mapping[str, object] + ) -> CompletionHook: + """Run the wrapper setup phase and return its :class:`CompletionHook`. + + Old-style hookwrappers and new-style wrappers are handled uniformly by + adapting old-style wrappers via ``run_old_style_hookwrapper``. + + The returned completion hook performs the teardown: it sends the + current outcome into the wrapper generator (or throws the current + exception) and returns the possibly replaced ``(result, exception)`` + pair. + """ + # Local import to avoid a circular import with the execution module. + from ._execution import _raise_wrapfail + from ._execution import run_old_style_hookwrapper + + args = self._get_call_args(caller_kwargs) + + wrapper_gen: Generator[None, object, object] + 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) # first yield / setup phase + 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]: + try: + if exception is not None: + try: + wrapper_gen.throw(exception) + except RuntimeError as re: + # StopIteration from generator causes RuntimeError + # even for coroutine usage - see #544 + if ( + isinstance(exception, StopIteration) + and re.__cause__ is exception + ): + wrapper_gen.close() + return result, exception + else: + raise + else: + wrapper_gen.send(result) + # Following is unreachable for a well behaved hook wrapper. + # Try to force finalizers otherwise postponed till GC action. + # Note: close() may raise if generator handles GeneratorExit. + 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 + + return completion_hook diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index e5c19092..440bc06c 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -150,7 +150,7 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: hookimpl_config = self._discover_hookimpl_configuration(plugin, name) if hookimpl_config is not None: method: _HookImplFunction[object] = getattr(plugin, name) - hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_config) + hookimpl = hookimpl_config.create_hookimpl(plugin, plugin_name, method) name = hookimpl_config.specname or name hook: HookCaller | None = getattr(self.hook, name, None) if hook is None: diff --git a/testing/test_details.py b/testing/test_details.py index 838a7d35..cec23c9d 100644 --- a/testing/test_details.py +++ b/testing/test_details.py @@ -193,7 +193,7 @@ def myhook(self): plugin = Plugin() pname = pm.register(plugin) assert repr(pm.hook.myhook.get_hookimpls()[0]) == ( - f"" + f"" ) diff --git a/testing/test_implementation.py b/testing/test_implementation.py new file mode 100644 index 00000000..5e78cc73 --- /dev/null +++ b/testing/test_implementation.py @@ -0,0 +1,218 @@ +""" +Tests for the HookImpl hierarchy and the CompletionHook setup API. +""" + +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Generator +from typing import Any + +import pytest + +from pluggy import HookCallError +from pluggy import HookImpl +from pluggy import HookimplConfiguration +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import NormalImpl +from pluggy import PluginManager +from pluggy import WrapperImpl +from pluggy._implementation import CompletionHook + + +hookspec = HookspecMarker("example") +hookimpl = HookimplMarker("example") + + +def func(arg: object) -> object: + return arg + + +def wrapper_func(arg: object) -> Generator[None, object, object]: + return (yield) + + +class TestCreateHookimpl: + def test_normal_config_returns_normal_impl(self) -> None: + config = HookimplConfiguration() + impl = config.create_hookimpl(None, "test", func) + assert type(impl) is NormalImpl + assert isinstance(impl, HookImpl) + assert impl.hookimpl_config is config + + def test_wrapper_config_returns_wrapper_impl(self) -> None: + config = HookimplConfiguration(wrapper=True) + impl = config.create_hookimpl(None, "test", wrapper_func) + assert type(impl) is WrapperImpl + + def test_hookwrapper_config_returns_wrapper_impl(self) -> None: + config = HookimplConfiguration(hookwrapper=True) + impl = config.create_hookimpl(None, "test", wrapper_func) + assert type(impl) is WrapperImpl + + def test_normal_impl_rejects_wrapper_config(self) -> None: + config = HookimplConfiguration(wrapper=True) + with pytest.raises(ValueError, match="Use WrapperImpl"): + NormalImpl(None, "test", wrapper_func, config) + + def test_wrapper_impl_rejects_normal_config(self) -> None: + config = HookimplConfiguration() + with pytest.raises(ValueError, match="Use NormalImpl"): + WrapperImpl(None, "test", func, config) + + def test_opts_alias(self) -> None: + config = HookimplConfiguration(tryfirst=True) + impl = config.create_hookimpl(None, "test", func) + assert impl.opts is config + + +class TestGetCallArgs: + def test_binds_in_argname_order(self) -> None: + def f(b: object, a: object) -> None: + pass + + impl = HookimplConfiguration().create_hookimpl(None, "test", f) + assert impl._get_call_args({"a": 1, "b": 2, "extra": 3}) == [2, 1] + + def test_missing_argument_raises_hook_call_error(self) -> None: + impl = HookimplConfiguration().create_hookimpl(None, "test", func) + with pytest.raises(HookCallError, match="must provide argument 'arg'"): + impl._get_call_args({}) + + +def make_wrapper_impl( + function: Callable[..., Any], *, hookwrapper: bool = False +) -> WrapperImpl: + config = HookimplConfiguration(wrapper=not hookwrapper, hookwrapper=hookwrapper) + impl = config.create_hookimpl(None, "test", function) + assert isinstance(impl, WrapperImpl) + return impl + + +class TestSetupAndGetCompletionHook: + def test_returns_completion_hook_protocol_instance(self) -> None: + impl = make_wrapper_impl(wrapper_func) + completion = impl.setup_and_get_completion_hook("myhook", {"arg": 1}) + assert isinstance(completion, CompletionHook) + assert completion(21, None) == (21, None) + + def test_setup_runs_code_before_yield(self) -> None: + events: list[str] = [] + + def wrapper() -> Generator[None, object, object]: + events.append("setup") + res = yield + events.append("teardown") + return res + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + assert events == ["setup"] + assert completion("x", None) == ("x", None) + assert events == ["setup", "teardown"] + + def test_completion_replaces_result(self) -> None: + def wrapper() -> Generator[None, object, object]: + res = yield + assert isinstance(res, int) + return res + 1 + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + assert completion(41, None) == (42, None) + + def test_completion_can_swallow_exception(self) -> None: + def wrapper() -> Generator[None, object, object]: + try: + yield + except ValueError: + return "fallback" + raise AssertionError("unreachable") + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + result, exception = completion(None, ValueError("boom")) + assert result == "fallback" + assert exception is None + + def test_completion_passes_through_unhandled_exception(self) -> None: + impl = make_wrapper_impl(wrapper_func) + completion = impl.setup_and_get_completion_hook("myhook", {"arg": 1}) + exc = ValueError("boom") + result, exception = completion("kept", exc) + assert result == "kept" + assert exception is exc + + def test_completion_can_raise_new_exception(self) -> None: + def wrapper() -> Generator[None, object, object]: + yield + raise RuntimeError("replaced") + + impl = make_wrapper_impl(wrapper) + completion = impl.setup_and_get_completion_hook("myhook", {}) + result, exception = completion("x", None) + assert result == "x" + assert isinstance(exception, RuntimeError) + assert str(exception) == "replaced" + + def test_did_not_yield_raises(self) -> None: + def no_yield() -> Generator[None, object, object]: + return "nope" + yield # type: ignore[unreachable] # pragma: no cover + + impl = make_wrapper_impl(no_yield) + with pytest.raises(RuntimeError, match="did not yield"): + impl.setup_and_get_completion_hook("myhook", {}) + + def test_second_yield_raises(self) -> None: + def two_yields() -> Generator[None, object, None]: + yield + yield + + impl = make_wrapper_impl(two_yields) + completion = impl.setup_and_get_completion_hook("myhook", {}) + result, exception = completion("x", None) + assert result == "x" + 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" + + +class TestRegistrationCreatesSubclasses: + def test_registered_impl_types(self) -> None: + class Spec: + @hookspec + def myhook(self, arg: object) -> None: + pass + + class Plugin: + @hookimpl + def myhook(self, arg: object) -> object: + return arg + + @hookimpl(wrapper=True) + def myhook_wrapper(self, arg: object) -> Generator[None, object, object]: + return (yield) + + pm = PluginManager("example") + pm.add_hookspecs(Spec) + pm.register(Plugin()) + impls = {type(impl).__name__ for impl in pm.hook.myhook.get_hookimpls()} + assert impls == {"NormalImpl"} + wrapper_impls = pm.hook.myhook_wrapper.get_hookimpls() + assert {type(impl).__name__ for impl in wrapper_impls} == {"WrapperImpl"} From 0bd7b6b78a470a40692dd4d4d4428649b3715e20 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 15:11:47 +0200 Subject: [PATCH 6/8] refactor(caller): Protocol HookCaller, split callers, CompletionHook multicall Complete design step 05: - HookCaller is now a @runtime_checkable Protocol; concrete callers are NormalHookCaller (split list[NormalImpl] / list[WrapperImpl] storage), HistoricHookCaller (memorize/replay, rejects wrappers) and SubsetHookCaller (read-only filtered proxy). _HookCaller and _SubsetHookCaller remain as compat aliases. - _multicall takes dual sequences and orchestrates phases only: wrapper setup collects CompletionHooks, normals run, completion hooks run LIFO and may replace (result, exception) - no wrapper flag branching. - add_hookspecs hands a NormalHookCaller over to a HistoricHookCaller when a historic spec arrives after impl registration. - PluginManager._hookexec and tracing use the dual-sequence signature; monitoring callbacks keep receiving one combined impl list. - HookSpec.verify_all_args_are_provided replaces the caller-side helper; set_specification accepts a config object or legacy mapping (shim). - New tests: protocol isinstance for all concretes, historic handover, historic direct-call/call_extra rejection. Co-Authored-By: Claude Fable 5 --- changelog/708.feature.rst | 12 + docs/api_reference.rst | 11 + src/pluggy/__init__.py | 6 + src/pluggy/_caller.py | 545 +++++++++++++++++++++++++--------- src/pluggy/_decorators.py | 19 ++ src/pluggy/_execution.py | 118 +++----- src/pluggy/_hooks.py | 6 + src/pluggy/_manager.py | 72 ++++- testing/benchmark.py | 14 +- testing/test_hookcaller.py | 120 ++++++-- testing/test_multicall.py | 17 +- testing/test_pluginmanager.py | 2 +- 12 files changed, 692 insertions(+), 250 deletions(-) create mode 100644 changelog/708.feature.rst diff --git a/changelog/708.feature.rst b/changelog/708.feature.rst new file mode 100644 index 00000000..7e0e6608 --- /dev/null +++ b/changelog/708.feature.rst @@ -0,0 +1,12 @@ +:class:`pluggy.HookCaller` is now a runtime-checkable +:class:`~typing.Protocol` implemented by the new concrete callers +:class:`pluggy.NormalHookCaller` (split normal/wrapper implementation +lists), :class:`pluggy.HistoricHookCaller` (call memorization and replay, +no wrappers) and :class:`pluggy.SubsetHookCaller`. +``isinstance(caller, HookCaller)`` keeps working for all of them. + +The hook execution engine now runs a dual-sequence multicall: wrappers own +their setup/teardown through ``CompletionHook`` callbacks which run LIFO +after the normal implementations, removing all wrapper flag branching from +the hot loop. Hook call monitoring callbacks still receive a single +combined implementation list. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index a62272f7..c4ce49ae 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -29,6 +29,17 @@ API Reference :members: :special-members: __call__ +.. autoclass:: pluggy.NormalHookCaller() + :members: + :special-members: __call__ + +.. autoclass:: pluggy.HistoricHookCaller() + :members: + :special-members: __call__ + +.. autoclass:: pluggy.SubsetHookCaller() + :members: + .. autoclass:: pluggy.HookCallError() :show-inheritance: :members: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index 52592ce1..f352c980 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -3,6 +3,9 @@ "PluginManager", "PluginValidationError", "HookCaller", + "NormalHookCaller", + "HistoricHookCaller", + "SubsetHookCaller", "HookCallError", "HookspecConfiguration", "HookimplConfiguration", @@ -20,12 +23,15 @@ ] from ._config import HookimplConfiguration from ._config import HookspecConfiguration +from ._hooks import HistoricHookCaller from ._hooks import HookCaller from ._hooks import HookImpl from ._hooks import HookimplMarker from ._hooks import HookRelay from ._hooks import HookspecMarker +from ._hooks import NormalHookCaller from ._hooks import NormalImpl +from ._hooks import SubsetHookCaller from ._hooks import WrapperImpl from ._manager import PluginManager from ._manager import PluginValidationError diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 25e4a3d4..1b6cfe6e 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -6,28 +6,124 @@ from collections.abc import Callable from collections.abc import Mapping +from collections.abc import MutableSequence from collections.abc import Sequence from collections.abc import Set from typing import Any +from typing import cast from typing import Final from typing import final +from typing import Protocol +from typing import runtime_checkable from typing import TYPE_CHECKING from typing import TypeAlias -import warnings +from typing import TypeVar from ._config import HookimplConfiguration +from ._config import hookspec_config_from_mapping from ._config import HookspecConfiguration from ._decorators import _Namespace from ._decorators import HookSpec from ._implementation import _Plugin from ._implementation import HookImpl +from ._implementation import NormalImpl +from ._implementation import WrapperImpl _HookExec: TypeAlias = Callable[ - [str, Sequence[HookImpl], Mapping[str, object], bool], - object | list[object], + [str, Sequence[NormalImpl], Sequence[WrapperImpl], Mapping[str, object], bool], + "object | list[object]", ] +_T_HookImpl = TypeVar("_T_HookImpl", bound=HookImpl) + + +def _insert_hookimpl_into_list( + hookimpl: _T_HookImpl, target_list: MutableSequence[_T_HookImpl] +) -> None: + """Insert a hookimpl into the target list maintaining proper ordering. + + The ordering is: [trylast, normal, tryfirst]. + """ + if hookimpl.trylast: + target_list.insert(0, hookimpl) + elif hookimpl.tryfirst: + target_list.append(hookimpl) + else: + # find last non-tryfirst method + i = len(target_list) - 1 + while i >= 0 and target_list[i].tryfirst: + i -= 1 + target_list.insert(i + 1, hookimpl) + + +def _coerce_spec_config( + spec_config: HookspecConfiguration | Mapping[str, Any], +) -> HookspecConfiguration: + """Accept a configuration object or a legacy mapping (pytest shim).""" + if isinstance(spec_config, HookspecConfiguration): + return spec_config + return hookspec_config_from_mapping(spec_config) + + +@runtime_checkable +class HookCaller(Protocol): + """Protocol defining the interface for hook callers. + + .. versionchanged:: 1.7 + ``HookCaller`` is now a :class:`~typing.Protocol` (runtime checkable). + The concrete implementations are :class:`NormalHookCaller`, + :class:`HistoricHookCaller` and ``SubsetHookCaller``. + """ + + @property + def name(self) -> str: + """Name of the hook getting called.""" + ... + + @property + def spec(self) -> HookSpec | None: + """The hook specification, if any.""" + ... + + def has_spec(self) -> bool: + """Whether this caller has a hook specification.""" + ... + + def is_historic(self) -> bool: + """Whether this caller is :ref:`historic `.""" + ... + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + ... + + def set_specification( + self, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration | Mapping[str, Any], + ) -> None: + """Set the hook specification.""" + ... + + def __call__(self, **kwargs: object) -> Any: + """Call the hook with given kwargs.""" + ... + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook historically.""" + ... + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with additional methods.""" + ... + @final class HookRelay: @@ -41,7 +137,7 @@ def __init__(self) -> None: if TYPE_CHECKING: - def __getattr__(self, name: str) -> HookCaller: ... + def __getattr__(self, name: str) -> NormalHookCaller | HistoricHookCaller: ... # Historical name (pluggy<=1.2), kept for backward compatibility. @@ -53,15 +149,15 @@ def __getattr__(self, name: str) -> HookCaller: ... ] -class HookCaller: +class NormalHookCaller: """A caller of all registered implementations of a hook specification.""" __slots__ = ( "name", "spec", "_hookexec", - "_hookimpls", - "_call_history", + "_normal_hookimpls", + "_wrapper_hookimpls", ) def __init__( @@ -69,26 +165,22 @@ def __init__( name: str, hook_execute: _HookExec, specmodule_or_class: _Namespace | None = None, - spec_opts: HookspecConfiguration | None = None, + spec_config: HookspecConfiguration | None = None, ) -> None: """:meta private:""" #: Name of the hook getting called. self.name: Final = name self._hookexec: Final = hook_execute - # The hookimpls list. The caller iterates it *in reverse*. Format: - # 1. trylast nonwrappers - # 2. nonwrappers - # 3. tryfirst nonwrappers - # 4. trylast wrappers - # 5. wrappers - # 6. tryfirst wrappers - self._hookimpls: Final[list[HookImpl]] = [] - self._call_history: _CallHistory | None = None + # Split hook implementations into two lists for simpler management: + # Normal hooks: [trylast, normal, tryfirst] + # Wrapper hooks: [trylast, normal, tryfirst] + self._normal_hookimpls: Final[list[NormalImpl]] = [] + self._wrapper_hookimpls: Final[list[WrapperImpl]] = [] # TODO: Document, or make private. self.spec: HookSpec | None = None if specmodule_or_class is not None: - assert spec_opts is not None - self.set_specification(specmodule_or_class, spec_opts) + assert spec_config is not None + self.set_specification(specmodule_or_class, spec_config) # TODO: Document, or make private. def has_spec(self) -> bool: @@ -98,77 +190,60 @@ def has_spec(self) -> bool: def set_specification( self, specmodule_or_class: _Namespace, - spec_opts: HookspecConfiguration, + spec_config: HookspecConfiguration | Mapping[str, Any], ) -> None: if self.spec is not None: raise ValueError( f"Hook {self.spec.name!r} is already registered " f"within namespace {self.spec.namespace}" ) - self.spec = HookSpec(specmodule_or_class, self.name, spec_opts) - if spec_opts.historic: - self._call_history = [] + config = _coerce_spec_config(spec_config) + if config.historic: + raise ValueError( + f"NormalHookCaller cannot handle historic hooks. " + f"Use HistoricHookCaller for {self.name!r}" + ) + self.spec = HookSpec(specmodule_or_class, self.name, config) def is_historic(self) -> bool: """Whether this caller is :ref:`historic `.""" - return self._call_history is not None + return False def _remove_plugin(self, plugin: _Plugin) -> None: """Remove all hook implementations registered by the given plugin.""" - remaining = [impl for impl in self._hookimpls if impl.plugin != plugin] - if len(remaining) == len(self._hookimpls): + remaining_normal = [i for i in self._normal_hookimpls if i.plugin != plugin] + remaining_wrapper = [i for i in self._wrapper_hookimpls if i.plugin != plugin] + if len(remaining_normal) == len(self._normal_hookimpls) and len( + remaining_wrapper + ) == len(self._wrapper_hookimpls): raise ValueError(f"plugin {plugin!r} not found") - self._hookimpls[:] = remaining + self._normal_hookimpls[:] = remaining_normal + self._wrapper_hookimpls[:] = remaining_wrapper def get_hookimpls(self) -> list[HookImpl]: - """Get all registered hook implementations for this hook.""" - return self._hookimpls.copy() + """Get all registered hook implementations for this hook. + + Normal implementations come first, then wrappers (matching the + historical combined-list ordering). + """ + return [*self._normal_hookimpls, *self._wrapper_hookimpls] def _add_hookimpl(self, hookimpl: HookImpl) -> None: """Add an implementation to the callback chain.""" - for i, method in enumerate(self._hookimpls): - if method.hookwrapper or method.wrapper: - splitpoint = i - break - else: - splitpoint = len(self._hookimpls) - if hookimpl.hookwrapper or hookimpl.wrapper: - start, end = splitpoint, len(self._hookimpls) - else: - start, end = 0, splitpoint - - if hookimpl.trylast: - self._hookimpls.insert(start, hookimpl) - elif hookimpl.tryfirst: - self._hookimpls.insert(end, hookimpl) + if isinstance(hookimpl, WrapperImpl): + _insert_hookimpl_into_list(hookimpl, self._wrapper_hookimpls) else: - # find last non-tryfirst method - i = end - 1 - while i >= start and self._hookimpls[i].tryfirst: - i -= 1 - self._hookimpls.insert(i + 1, hookimpl) + assert isinstance(hookimpl, NormalImpl), ( + "normal hook implementations must be NormalImpl instances" + ) + _insert_hookimpl_into_list(hookimpl, self._normal_hookimpls) def __repr__(self) -> str: - return f"" + return f"" def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: - # This is written to avoid expensive operations when not needed. if self.spec: - for argname in self.spec.argnames: - if argname not in kwargs: - notincall = ", ".join( - repr(argname) - for argname in self.spec.argnames - # Avoid self.spec.argnames - kwargs.keys() - # it doesn't preserve order. - if argname not in kwargs.keys() - ) - warnings.warn( - f"Argument(s) {notincall} which are declared in the hookspec " - "cannot be found in this hook call", - stacklevel=2, - ) - break + self.spec.verify_all_args_are_provided(kwargs) def __call__(self, **kwargs: object) -> Any: """Call the hook. @@ -179,13 +254,140 @@ def __call__(self, **kwargs: object) -> Any: Returns the result(s) of calling all registered plugins, see :ref:`calling`. """ - assert not self.is_historic(), ( - "Cannot directly call a historic hook - use call_historic instead." - ) self._verify_all_args_are_provided(kwargs) firstresult = self.spec.config.firstresult if self.spec else False # Copy because plugins may register other plugins during iteration (#438). - return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) + return self._hookexec( + self.name, + self._normal_hookimpls.copy(), + self._wrapper_hookimpls.copy(), + kwargs, + firstresult, + ) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Historic calls are only supported by historic hooks.""" + raise AssertionError( + f"Hook {self.name!r} is not historic - cannot call call_historic" + ) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating + methods using the specified ``kwargs`` as call parameters, see + :ref:`call_extra`.""" + self._verify_all_args_are_provided(kwargs) + config = HookimplConfiguration() + normal_hookimpls = self._normal_hookimpls.copy() + for method in methods: + hookimpl = config.create_hookimpl(None, "", method) + # call_extra only supports normal implementations. + assert isinstance(hookimpl, NormalImpl) + _insert_hookimpl_into_list(hookimpl, normal_hookimpls) + firstresult = self.spec.config.firstresult if self.spec else False + return self._hookexec( + self.name, + normal_hookimpls, + self._wrapper_hookimpls.copy(), + kwargs, + firstresult, + ) + + def _maybe_apply_history(self, method: HookImpl) -> None: + """Nothing to do - normal hooks have no call history.""" + + +# Historical name (pluggy<=1.2), kept for backward compatibility. +_HookCaller = NormalHookCaller + + +class HistoricHookCaller: + """A caller for historic hook specifications that memorizes and replays + calls. + + Historic hooks memorize every call and replay them on plugins registered + after the call was made. Historic hooks do not support wrappers. + """ + + __slots__ = ( + "name", + "spec", + "_hookexec", + "_hookimpls", + "_call_history", + ) + + spec: HookSpec + + def __init__( + self, + name: str, + hook_execute: _HookExec, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration, + ) -> None: + """:meta private:""" + assert spec_config.historic, "HistoricHookCaller requires historic=True" + #: Name of the hook getting called. + self.name: Final = name + self._hookexec: Final = hook_execute + # The hookimpls list for historic hooks (no wrappers supported). + self._hookimpls: Final[list[NormalImpl]] = [] + self._call_history: Final[_CallHistory] = [] + # TODO: Document, or make private. + self.spec = HookSpec(specmodule_or_class, name, spec_config) + + def has_spec(self) -> bool: + return True + + def set_specification( + self, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration | Mapping[str, Any], + ) -> None: + """Historic hooks cannot have their specification changed.""" + raise ValueError( + f"Hook {self.spec.name!r} is already registered " + f"within namespace {self.spec.namespace}" + ) + + def is_historic(self) -> bool: + """Whether this caller is :ref:`historic `.""" + return True + + def _remove_plugin(self, plugin: _Plugin) -> None: + remaining = [impl for impl in self._hookimpls if impl.plugin != plugin] + if len(remaining) == len(self._hookimpls): + raise ValueError(f"plugin {plugin!r} not found") + self._hookimpls[:] = remaining + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + return list(self._hookimpls) + + def _add_hookimpl(self, hookimpl: HookImpl) -> None: + """Add an implementation to the callback chain.""" + assert isinstance(hookimpl, NormalImpl), ( + "historic hooks do not support wrappers" + ) + _insert_hookimpl_into_list(hookimpl, self._hookimpls) + + def __repr__(self) -> str: + return f"" + + def __call__(self, **kwargs: object) -> Any: + """Historic hooks cannot be called directly. + + Use :meth:`call_historic` instead. + """ + raise AssertionError( + "Cannot directly call a historic hook - use call_historic instead." + ) def call_historic( self, @@ -200,14 +402,13 @@ def call_historic( If provided, will be called for each non-``None`` result obtained from a hook implementation. """ - assert self._call_history is not None kwargs = kwargs or {} - self._verify_all_args_are_provided(kwargs) + self.spec.verify_all_args_are_provided(kwargs) self._call_history.append((kwargs, result_callback)) # Historizing hooks don't return results. # Remember firstresult isn't compatible with historic. # Copy because plugins may register other plugins during iteration (#438). - res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False) + res = self._hookexec(self.name, self._hookimpls.copy(), [], kwargs, False) if result_callback is None: return if isinstance(res, list): @@ -217,86 +418,164 @@ def call_historic( def call_extra( self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] ) -> Any: - """Call the hook with some additional temporarily participating - methods using the specified ``kwargs`` as call parameters, see - :ref:`call_extra`.""" - assert not self.is_historic(), ( + """Historic hooks do not support call_extra.""" + raise AssertionError( "Cannot directly call a historic hook - use call_historic instead." ) - self._verify_all_args_are_provided(kwargs) - config = HookimplConfiguration() - hookimpls = self._hookimpls.copy() - for method in methods: - hookimpl = config.create_hookimpl(None, "", method) - # Find last non-tryfirst nonwrapper method. - i = len(hookimpls) - 1 - while i >= 0 and ( - # Skip wrappers. - (hookimpls[i].hookwrapper or hookimpls[i].wrapper) - # Skip tryfirst nonwrappers. - or hookimpls[i].tryfirst - ): - i -= 1 - hookimpls.insert(i + 1, hookimpl) - firstresult = self.spec.config.firstresult if self.spec else False - return self._hookexec(self.name, hookimpls, kwargs, firstresult) def _maybe_apply_history(self, method: HookImpl) -> None: - """Apply call history to a new hookimpl if it is marked as historic.""" - if self.is_historic(): - assert self._call_history is not None - for kwargs, result_callback in self._call_history: - res = self._hookexec(self.name, [method], kwargs, False) - if res and result_callback is not None: - # XXX: remember firstresult isn't compat with historic - assert isinstance(res, list) - result_callback(res[0]) - - -# Historical name (pluggy<=1.2), kept for backward compatibility. -_HookCaller = HookCaller - - -class _SubsetHookCaller(HookCaller): - """A proxy to another HookCaller which manages calls to all registered + """Apply call history to a new hookimpl.""" + assert isinstance(method, NormalImpl) + for kwargs, result_callback in self._call_history: + res = self._hookexec(self.name, [method], [], kwargs, False) + if res and result_callback is not None: + # XXX: remember firstresult isn't compat with historic + assert isinstance(res, list) + result_callback(res[0]) + + +class SubsetHookCaller: + """A proxy to another hook caller which manages calls to all registered plugins except the ones from remove_plugins.""" - # This class is unusual: in inhertits from `HookCaller` so all of - # the *code* runs in the class, but it delegates all underlying *data* - # to the original HookCaller. # `subset_hook_caller` used to be implemented by creating a full-fledged # HookCaller, copying all hookimpls from the original. This had problems # with memory leaks (#346) and historic calls (#347), which make a proxy # approach better. - # An alternative implementation is to use a `_getattr__`/`__getattribute__` - # proxy, however that adds more overhead and is more tricky to implement. __slots__ = ( "_orig", "_remove_plugins", ) - def __init__(self, orig: HookCaller, remove_plugins: Set[_Plugin]) -> None: - self._orig = orig - self._remove_plugins = remove_plugins - self.name = orig.name # type: ignore[misc] - self._hookexec = orig._hookexec # type: ignore[misc] + def __init__( + self, + orig: NormalHookCaller | HistoricHookCaller, + remove_plugins: Set[_Plugin], + ) -> None: + """:meta private:""" + self._orig: Final = orig + self._remove_plugins: Final = remove_plugins - @property # type: ignore[misc] - def _hookimpls(self) -> list[HookImpl]: - return [ - impl - for impl in self._orig._hookimpls - if impl.plugin not in self._remove_plugins - ] + @property + def name(self) -> str: + return self._orig.name @property - def spec(self) -> HookSpec | None: # type: ignore[override] + def spec(self) -> HookSpec | None: return self._orig.spec - @property - def _call_history(self) -> _CallHistory | None: # type: ignore[override] - return self._orig._call_history + def has_spec(self) -> bool: + return self._orig.has_spec() + + def is_historic(self) -> bool: + return self._orig.is_historic() + + def _get_filtered(self, hookimpls: Sequence[_T_HookImpl]) -> list[_T_HookImpl]: + """Filter out hook implementations from removed plugins.""" + return [impl for impl in hookimpls if impl.plugin not in self._remove_plugins] + + def get_hookimpls(self) -> list[HookImpl]: + """Get filtered hook implementations for this hook.""" + return self._get_filtered(self._orig.get_hookimpls()) + + def set_specification( + self, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration | Mapping[str, Any], + ) -> None: + """SubsetHookCaller is a read-only proxy - specs cannot be set.""" + raise RuntimeError( + f"Cannot set specification on SubsetHookCaller {self.name!r} - " + "it is a read-only proxy to another hook caller" + ) + + def __call__(self, **kwargs: object) -> Any: + """Call the hook with filtered implementations.""" + if self.is_historic(): + raise AssertionError( + "Cannot directly call a historic hook - use call_historic instead." + ) + orig = self._orig + assert isinstance(orig, NormalHookCaller) + if orig.spec: + orig.spec.verify_all_args_are_provided(kwargs) + firstresult = orig.spec.config.firstresult if orig.spec else False + return orig._hookexec( + self.name, + self._get_filtered(orig._normal_hookimpls), + self._get_filtered(orig._wrapper_hookimpls), + kwargs, + firstresult, + ) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook with given ``kwargs`` for all registered plugins and + for all plugins which will be registered afterwards, see + :ref:`historic`. + """ + orig = self._orig + assert isinstance(orig, HistoricHookCaller), ( + f"Hook {self.name!r} is not historic - cannot call call_historic" + ) + kwargs = kwargs or {} + orig.spec.verify_all_args_are_provided(kwargs) + # History is shared with the original caller. + orig._call_history.append((kwargs, result_callback)) + res = orig._hookexec( + self.name, self._get_filtered(orig._hookimpls), [], kwargs, False + ) + if result_callback is None: + return + if isinstance(res, list): + for x in res: + result_callback(x) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating + methods using the specified ``kwargs`` as call parameters, see + :ref:`call_extra`.""" + orig = self._orig + if self.is_historic(): + raise AssertionError( + "Cannot directly call a historic hook - use call_historic instead." + ) + assert isinstance(orig, NormalHookCaller) + if orig.spec: + orig.spec.verify_all_args_are_provided(kwargs) + config = HookimplConfiguration() + normal_impls = self._get_filtered(orig._normal_hookimpls) + for method in methods: + hookimpl = config.create_hookimpl(None, "", method) + assert isinstance(hookimpl, NormalImpl) + _insert_hookimpl_into_list(hookimpl, normal_impls) + firstresult = orig.spec.config.firstresult if orig.spec else False + return orig._hookexec( + self.name, + normal_impls, + self._get_filtered(orig._wrapper_hookimpls), + kwargs, + firstresult, + ) def __repr__(self) -> str: - return f"<_SubsetHookCaller {self.name!r}>" + return f"" + + +# Historical name, kept for backward compatibility. +_SubsetHookCaller = SubsetHookCaller + + +if TYPE_CHECKING: + # Verify the concrete callers satisfy the HookCaller protocol. + _: list[HookCaller] = [ + cast(NormalHookCaller, None), + cast(HistoricHookCaller, None), + cast(SubsetHookCaller, None), + ] diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index 6c15df46..a241290a 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -361,3 +361,22 @@ def opts(self) -> HookspecConfiguration: Use :attr:`config` instead. """ return self.config + + def verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: + """Warn if a hook call does not provide all declared arguments.""" + # This is written to avoid expensive operations when not needed. + for argname in self.argnames: + if argname not in kwargs: + notincall = ", ".join( + repr(argname) + for argname in self.argnames + # Avoid self.argnames - kwargs.keys() + # it doesn't preserve order. + if argname not in kwargs.keys() + ) + warnings.warn( + f"Argument(s) {notincall} which are declared in the hookspec " + "cannot be found in this hook call", + stacklevel=3, + ) + break diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index ef644941..100b1fba 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -13,7 +13,9 @@ from typing import TypeAlias import warnings -from ._implementation import HookImpl +from ._implementation import CompletionHook +from ._implementation import NormalImpl +from ._implementation import WrapperImpl from ._result import Result from ._warnings import PluggyTeardownRaisedWarning @@ -24,7 +26,7 @@ def run_old_style_hookwrapper( - hook_impl: HookImpl, hook_name: str, args: Sequence[object] + hook_impl: WrapperImpl, hook_name: str, args: Sequence[object] ) -> Teardown: """ backward compatibility wrapper to run a old style hookwrapper as a wrapper @@ -67,7 +69,7 @@ def _raise_wrapfail( def _warn_teardown_exception( - hook_name: str, hook_impl: HookImpl, e: BaseException + hook_name: str, hook_impl: WrapperImpl, e: BaseException ) -> None: msg = ( f"A plugin raised an exception during an old-style hookwrapper teardown.\n" @@ -75,12 +77,13 @@ def _warn_teardown_exception( f"{type(e).__name__}: {e}\n" f"For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning" # noqa: E501 ) - warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=6) + warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=7) def _multicall( hook_name: str, - hook_impls: Sequence[HookImpl], + normal_impls: Sequence[NormalImpl], + wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, ) -> object | list[object]: @@ -88,79 +91,48 @@ def _multicall( result(s). ``caller_kwargs`` comes from HookCaller.__call__(). + + Wrappers own their setup/teardown via + :meth:`~pluggy._implementation.WrapperImpl.setup_and_get_completion_hook`; + this function only orchestrates the phases: + + 1. Set up wrappers, collecting their completion hooks. + 2. Run normal implementations. + 3. Run completion hooks LIFO, each may replace ``(result, exception)``. + 4. Raise or return. """ __tracebackhide__ = True results: list[object] = [] - exception = None - teardowns: list[Teardown] = [] - try: # run impl and wrapper setup functions in a loop - for hook_impl in reversed(hook_impls): - args = hook_impl._get_call_args(caller_kwargs) - - if hook_impl.hookwrapper: - function_gen = run_old_style_hookwrapper(hook_impl, hook_name, args) - - next(function_gen) # first yield - teardowns.append(function_gen) - - elif hook_impl.wrapper: - res = hook_impl.function(*args) - # If this cast is not valid, a type error is raised below, - # which is the desired response. - if TYPE_CHECKING: - function_gen = cast(Generator[None, object, object], res) - else: - function_gen = res - try: - next(function_gen) # first yield - except StopIteration: - _raise_wrapfail(function_gen, "did not yield") - teardowns.append(function_gen) - else: - res = hook_impl.function(*args) - if res is not None: - results.append(res) - if firstresult: # halt further impl calls - break + exception: BaseException | None = None + completion_hooks: list[CompletionHook] = [] + try: + # Set up all wrappers and collect their completion hooks. + for wrapper_impl in reversed(wrapper_impls): + completion_hooks.append( + wrapper_impl.setup_and_get_completion_hook(hook_name, caller_kwargs) + ) + + # Run normal implementations. + for normal_impl in reversed(normal_impls): + args = normal_impl._get_call_args(caller_kwargs) + res = normal_impl.function(*args) + if res is not None: + results.append(res) + if firstresult: # halt further impl calls + break except BaseException as exc: exception = exc - finally: - if firstresult: # first result hooks return a single value - result = results[0] if results else None - else: - result = results - - # run all wrapper post-yield blocks - for teardown in reversed(teardowns): - try: - if exception is not None: - try: - teardown.throw(exception) - except RuntimeError as re: - # StopIteration from generator causes RuntimeError - # even for coroutine usage - see #544 - if ( - isinstance(exception, StopIteration) - and re.__cause__ is exception - ): - teardown.close() - continue - else: - raise - else: - teardown.send(result) - # Following is unreachable for a well behaved hook wrapper. - # Try to force finalizers otherwise postponed till GC action. - # Note: close() may raise if generator handles GeneratorExit. - teardown.close() - except StopIteration as si: - result = si.value - exception = None - continue - except BaseException as e: - exception = e - continue - _raise_wrapfail(teardown, "has second yield") + + result: object | list[object] | None + if firstresult: # first result hooks return a single value + result = results[0] if results else None + else: + result = results + + # Run completion hooks in reverse order (LIFO); each may replace the + # current (result, exception) outcome. + for completion_hook in reversed(completion_hooks): + result, exception = completion_hook(result, exception) if exception is not None: raise exception diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 4b717ae0..7197d7e4 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -11,8 +11,11 @@ from ._caller import _HookExec from ._caller import _HookRelay from ._caller import _SubsetHookCaller +from ._caller import HistoricHookCaller from ._caller import HookCaller from ._caller import HookRelay +from ._caller import NormalHookCaller +from ._caller import SubsetHookCaller from ._config import HookimplConfiguration from ._config import HookspecConfiguration from ._decorators import _Namespace @@ -36,6 +39,9 @@ "HookSpec", "varnames", "HookCaller", + "NormalHookCaller", + "HistoricHookCaller", + "SubsetHookCaller", "HookRelay", "_HookCaller", "_HookRelay", diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 440bc06c..bfe5669e 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -24,10 +24,14 @@ from ._hooks import _HookImplFunction from ._hooks import _Namespace from ._hooks import _Plugin -from ._hooks import _SubsetHookCaller +from ._hooks import HistoricHookCaller from ._hooks import HookCaller from ._hooks import HookImpl from ._hooks import HookRelay +from ._hooks import NormalHookCaller +from ._hooks import NormalImpl +from ._hooks import SubsetHookCaller +from ._hooks import WrapperImpl from ._pytest_compat import HookimplOpts from ._pytest_compat import HookspecOpts from ._result import Result @@ -104,13 +108,16 @@ def __init__(self, project_name: str) -> None: def _hookexec( self, hook_name: str, - methods: Sequence[HookImpl], - kwargs: Mapping[str, object], + normal_impls: Sequence[NormalImpl], + wrapper_impls: Sequence[WrapperImpl], + caller_kwargs: Mapping[str, object], firstresult: bool, ) -> object | list[object]: # called from all hookcaller instances. # enable_tracing will set its own wrapping function at self._inner_hookexec - return self._inner_hookexec(hook_name, methods, kwargs, firstresult) + return self._inner_hookexec( + hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + ) def register(self, plugin: _Plugin, name: str | None = None) -> str | None: """Register a plugin and return its name. @@ -151,11 +158,13 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: if hookimpl_config is not None: method: _HookImplFunction[object] = getattr(plugin, name) hookimpl = hookimpl_config.create_hookimpl(plugin, plugin_name, method) - name = hookimpl_config.specname or name - hook: HookCaller | None = getattr(self.hook, name, None) + hook_name = hookimpl_config.specname or name + hook: NormalHookCaller | HistoricHookCaller | None = getattr( + self.hook, hook_name, None + ) if hook is None: - hook = HookCaller(name, self._hookexec) - setattr(self.hook, name, hook) + hook = NormalHookCaller(hook_name, self._hookexec) + setattr(self.hook, hook_name, hook) elif hook.has_spec(): self._verify_hook(hook, hookimpl) hook._maybe_apply_history(hookimpl) @@ -241,6 +250,7 @@ def unregister( hookcallers = self.get_hookcallers(plugin) if hookcallers: for hookcaller in hookcallers: + assert isinstance(hookcaller, (NormalHookCaller, HistoricHookCaller)) hookcaller._remove_plugin(plugin) # if self._name2plugin[name] == None registration was blocked: ignore @@ -279,10 +289,36 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: for name in dir(module_or_class): spec_config = self._discover_hookspec_configuration(module_or_class, name) if spec_config is not None: - hc: HookCaller | None = getattr(self.hook, name, None) + hc: NormalHookCaller | HistoricHookCaller | None = getattr( + self.hook, name, None + ) if hc is None: - hc = HookCaller(name, self._hookexec, module_or_class, spec_config) + if spec_config.historic: + hc = HistoricHookCaller( + name, self._hookexec, module_or_class, spec_config + ) + else: + hc = NormalHookCaller( + name, self._hookexec, module_or_class, spec_config + ) setattr(self.hook, name, hc) + elif spec_config.historic and not hc.is_historic(): + # Plugins registered this hook before the historic spec was + # known - hand the implementations over to a + # HistoricHookCaller. + assert isinstance(hc, NormalHookCaller) + if hc.has_spec(): + # Let set_specification raise the usual error. + hc.set_specification(module_or_class, spec_config) + raise AssertionError("unreachable") # pragma: no cover + old_hookimpls = hc.get_hookimpls() + historic_hc = HistoricHookCaller( + name, self._hookexec, module_or_class, spec_config + ) + setattr(self.hook, name, historic_hc) + for hookimpl in old_hookimpls: + self._verify_hook(historic_hc, hookimpl) + historic_hc._add_hookimpl(hookimpl) else: # Plugins registered this hook without knowing the spec. hc.set_specification(module_or_class, spec_config) @@ -439,7 +475,7 @@ def check_pending(self) -> None: for name in self.hook.__dict__: if name[0] == "_": continue - hook: HookCaller = getattr(self.hook, name) + hook: NormalHookCaller | HistoricHookCaller = getattr(self.hook, name) if not hook.has_spec(): for hookimpl in hook.get_hookimpls(): if not hookimpl.optionalhook: @@ -540,13 +576,19 @@ def add_hookcall_monitoring( def traced_hookexec( hook_name: str, - hook_impls: Sequence[HookImpl], + normal_impls: Sequence[NormalImpl], + wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, ) -> object | list[object]: + # For backward compatibility of the before/after callback shapes, + # combine the split lists into one. + hook_impls: list[HookImpl] = [*normal_impls, *wrapper_impls] before(hook_name, hook_impls, caller_kwargs) outcome = Result.from_call( - lambda: oldcall(hook_name, hook_impls, caller_kwargs, firstresult) + lambda: oldcall( + hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + ) ) after(outcome, hook_name, hook_impls, caller_kwargs) return outcome.get_result() @@ -589,10 +631,10 @@ def subset_hook_caller( """Return a proxy :class:`~pluggy.HookCaller` instance for the named method which manages calls to all registered plugins except the ones from remove_plugins.""" - orig: HookCaller = getattr(self.hook, name) + orig: NormalHookCaller | HistoricHookCaller = getattr(self.hook, name) plugins_to_remove = {plug for plug in remove_plugins if hasattr(plug, name)} if plugins_to_remove: - return _SubsetHookCaller(orig, plugins_to_remove) + return SubsetHookCaller(orig, plugins_to_remove) return orig diff --git a/testing/benchmark.py b/testing/benchmark.py index 81823edd..27a7096e 100644 --- a/testing/benchmark.py +++ b/testing/benchmark.py @@ -11,8 +11,8 @@ from pluggy import HookimplMarker from pluggy import HookspecMarker from pluggy import PluginManager +from pluggy import WrapperImpl from pluggy._callers import _multicall -from pluggy._hooks import HookImpl from pluggy._hooks import varnames @@ -102,13 +102,17 @@ def wrappers(request: Any) -> list[object]: def test_hook_and_wrappers_speed(benchmark, hooks, wrappers) -> None: def setup(): hook_name = "foo" - hook_impls = [] + normal_impls = [] + wrapper_impls = [] for method in hooks + wrappers: - f = HookImpl(None, "", method, method.example_impl) - hook_impls.append(f) + f = method.example_impl.create_hookimpl(None, "", method) + if isinstance(f, WrapperImpl): + wrapper_impls.append(f) + else: + normal_impls.append(f) caller_kwargs = {"arg1": 1, "arg2": 2, "arg3": 3} firstresult = False - return (hook_name, hook_impls, caller_kwargs, firstresult), {} + return (hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult), {} benchmark.pedantic(_multicall, setup=setup, rounds=10) diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index 5db79718..3df40d9d 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -5,11 +5,14 @@ import pytest +from pluggy import HistoricHookCaller +from pluggy import HookCaller from pluggy import HookimplMarker +from pluggy import HookspecConfiguration from pluggy import HookspecMarker +from pluggy import NormalHookCaller from pluggy import PluginManager from pluggy import PluginValidationError -from pluggy._hooks import HookCaller from pluggy._hooks import HookImpl @@ -18,21 +21,23 @@ @pytest.fixture -def hc(pm: PluginManager) -> HookCaller: +def hc(pm: PluginManager) -> NormalHookCaller: class Hooks: @hookspec def he_method1(self, arg: object) -> None: pass pm.add_hookspecs(Hooks) - return pm.hook.he_method1 + hc = pm.hook.he_method1 + assert isinstance(hc, NormalHookCaller) + return hc FuncT = TypeVar("FuncT", bound=Callable[..., object]) class AddMeth: - def __init__(self, hc: HookCaller) -> None: + def __init__(self, hc: NormalHookCaller) -> None: self.hc = hc def __call__( @@ -49,16 +54,15 @@ def wrap(func: FuncT) -> FuncT: hookwrapper=hookwrapper, wrapper=wrapper, )(func) - self.hc._add_hookimpl( - HookImpl(None, "", func, func.example_impl), # type: ignore[attr-defined] - ) + config = func.example_impl # type: ignore[attr-defined] + self.hc._add_hookimpl(config.create_hookimpl(None, "", func)) return func return wrap @pytest.fixture -def addmeth(hc: HookCaller) -> AddMeth: +def addmeth(hc: NormalHookCaller) -> AddMeth: return AddMeth(hc) @@ -66,7 +70,7 @@ def funcs(hookmethods: Sequence[HookImpl]) -> list[Callable[..., object]]: return [hookmethod.function for hookmethod in hookmethods] -def test_adding_nonwrappers(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1() -> None: pass @@ -82,7 +86,7 @@ def he_method3() -> None: assert funcs(hc.get_hookimpls()) == [he_method1, he_method2, he_method3] -def test_adding_nonwrappers_trylast(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_trylast(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1_middle() -> None: pass @@ -98,7 +102,7 @@ def he_method1_b() -> None: assert funcs(hc.get_hookimpls()) == [he_method1, he_method1_middle, he_method1_b] -def test_adding_nonwrappers_trylast3(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_trylast3(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1_a() -> None: pass @@ -123,7 +127,7 @@ def he_method1_d() -> None: ] -def test_adding_nonwrappers_trylast2(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_trylast2(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth() def he_method1_middle() -> None: pass @@ -139,7 +143,7 @@ def he_method1() -> None: assert funcs(hc.get_hookimpls()) == [he_method1, he_method1_middle, he_method1_b] -def test_adding_nonwrappers_tryfirst(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_nonwrappers_tryfirst(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth(tryfirst=True) def he_method1() -> None: pass @@ -155,7 +159,7 @@ def he_method1_b() -> None: assert funcs(hc.get_hookimpls()) == [he_method1_middle, he_method1_b, he_method1] -def test_adding_wrappers_ordering(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_wrappers_ordering(hc: NormalHookCaller, addmeth: AddMeth) -> None: @addmeth(hookwrapper=True) def he_method1(): yield # pragma: no cover @@ -185,7 +189,9 @@ def he_method3(): ] -def test_adding_wrappers_ordering_tryfirst(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_wrappers_ordering_tryfirst( + hc: NormalHookCaller, addmeth: AddMeth +) -> None: @addmeth(hookwrapper=True, tryfirst=True) def he_method1(): yield # pragma: no cover @@ -201,7 +207,7 @@ def he_method3(): assert funcs(hc.get_hookimpls()) == [he_method2, he_method1, he_method3] -def test_adding_wrappers_complex(hc: HookCaller, addmeth: AddMeth) -> None: +def test_adding_wrappers_complex(hc: NormalHookCaller, addmeth: AddMeth) -> None: assert funcs(hc.get_hookimpls()) == [] @addmeth(hookwrapper=True, trylast=True) @@ -442,7 +448,7 @@ def conflict(self) -> None: ) -def test_call_extra_hook_order(hc: HookCaller, addmeth: AddMeth) -> None: +def test_call_extra_hook_order(hc: NormalHookCaller, addmeth: AddMeth) -> None: """Ensure that call_extra is calling hooks in the right order.""" order = [] @@ -511,3 +517,83 @@ def extra2() -> str: "2", "3", ] + + +def test_hookcaller_is_runtime_checkable_protocol(pm: PluginManager) -> None: + class Hooks: + @hookspec + def he_method1(self, arg: object) -> None: + pass + + @hookspec(historic=True) + def he_history(self, arg: object) -> None: + pass + + class Plugin: + @hookimpl + def he_method1(self, arg: object) -> object: + return arg + + pm.add_hookspecs(Hooks) + pm.register(Plugin()) + + normal = pm.hook.he_method1 + historic = pm.hook.he_history + subset = pm.subset_hook_caller("he_method1", []) + + assert isinstance(normal, NormalHookCaller) + assert isinstance(historic, HistoricHookCaller) + for caller in (normal, historic, subset): + assert isinstance(caller, HookCaller) + + assert not normal.is_historic() + assert historic.is_historic() + + +def test_historic_spec_after_registration_hands_over(pm: PluginManager) -> None: + """Impls registered before a historic spec move to a HistoricHookCaller.""" + out: list[object] = [] + + class Plugin: + @hookimpl + def he_history(self, arg: object) -> object: + out.append(arg) + return arg + + pm.register(Plugin()) + pre_spec_caller = pm.hook.he_history + assert isinstance(pre_spec_caller, NormalHookCaller) + + class Hooks: + @hookspec(historic=True) + def he_history(self, arg: object) -> None: + pass + + pm.add_hookspecs(Hooks) + hc = pm.hook.he_history + assert isinstance(hc, HistoricHookCaller) + assert len(hc.get_hookimpls()) == 1 + + hc.call_historic(kwargs=dict(arg=1)) + assert out == [1] + + +def test_historic_rejects_direct_call_and_call_extra(pm: PluginManager) -> None: + class Hooks: + @hookspec(historic=True) + def he_history(self, arg: object) -> None: + pass + + pm.add_hookspecs(Hooks) + hc = pm.hook.he_history + with pytest.raises(AssertionError, match="use call_historic"): + hc(arg=1) + with pytest.raises(AssertionError, match="use call_historic"): + hc.call_extra([], dict(arg=1)) + with pytest.raises(ValueError, match="already registered"): + hc.set_specification(Hooks, HookspecConfiguration(historic=True)) + + +def test_normal_caller_rejects_call_historic(hc: NormalHookCaller) -> None: + with pytest.raises(AssertionError, match="not historic"): + hc.call_historic(kwargs=dict(arg=1)) diff --git a/testing/test_multicall.py b/testing/test_multicall.py index 93f394c8..1c805f4c 100644 --- a/testing/test_multicall.py +++ b/testing/test_multicall.py @@ -7,8 +7,9 @@ from pluggy import HookCallError from pluggy import HookimplMarker from pluggy import HookspecMarker +from pluggy import NormalImpl +from pluggy import WrapperImpl from pluggy._callers import _multicall -from pluggy._hooks import HookImpl hookspec = HookspecMarker("example") @@ -20,12 +21,16 @@ def MC( kwargs: Mapping[str, object], firstresult: bool = False, ) -> object | list[object]: - caller = _multicall - hookfuncs = [] + normal_impls: list[NormalImpl] = [] + wrapper_impls: list[WrapperImpl] = [] for method in methods: - f = HookImpl(None, "", method, method.example_impl) # type: ignore[attr-defined] - hookfuncs.append(f) - return caller("foo", hookfuncs, kwargs, firstresult) + config = method.example_impl # type: ignore[attr-defined] + f = config.create_hookimpl(None, "", method) + if isinstance(f, WrapperImpl): + wrapper_impls.append(f) + else: + normal_impls.append(f) + return _multicall("foo", normal_impls, wrapper_impls, kwargs, firstresult) def test_keyword_args() -> None: diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 7924068a..13e277cd 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -495,7 +495,7 @@ class PluginNo: pm.hook.he_method1(arg=1) assert out == [10] - assert repr(hc) == "<_SubsetHookCaller 'he_method1'>" + assert repr(hc) == "" def test_get_hookimpls(pm: PluginManager) -> None: From d6217b7ba553e91ea9521af82850bbba0fcd38c1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 15:35:12 +0200 Subject: [PATCH 7/8] feat(project): add ProjectSpec hub for markers and PluginManager Complete design step 06: ProjectSpec bundles hookspec/hookimpl markers and plugin manager creation under a single project name, with get_hookspec_config / get_hookimpl_config helpers. Markers and PluginManager accept str | ProjectSpec (strings still work; markers and manager now expose project_name as a property delegating to the spec). Co-Authored-By: Claude Fable 5 --- changelog/709.feature.rst | 6 ++ docs/api_reference.rst | 3 + src/pluggy/__init__.py | 2 + src/pluggy/_decorators.py | 33 +++++-- src/pluggy/_manager.py | 19 +++- src/pluggy/_project.py | 86 +++++++++++++++++ testing/test_project_spec.py | 173 +++++++++++++++++++++++++++++++++++ 7 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 changelog/709.feature.rst create mode 100644 src/pluggy/_project.py create mode 100644 testing/test_project_spec.py diff --git a/changelog/709.feature.rst b/changelog/709.feature.rst new file mode 100644 index 00000000..1fe22c25 --- /dev/null +++ b/changelog/709.feature.rst @@ -0,0 +1,6 @@ +New :class:`pluggy.ProjectSpec` hub bundles a project's +:class:`~pluggy.HookspecMarker`, :class:`~pluggy.HookimplMarker` and +:meth:`~pluggy.ProjectSpec.create_plugin_manager` under one project name. +:class:`~pluggy.HookspecMarker`, :class:`~pluggy.HookimplMarker` and +:class:`~pluggy.PluginManager` now accept either a project name string or +a ``ProjectSpec`` instance; plain strings keep working unchanged. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index c4ce49ae..1029f954 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -8,6 +8,9 @@ API Reference .. autoclass:: pluggy.PluginManager :members: +.. autoclass:: pluggy.ProjectSpec + :members: + .. autoclass:: pluggy.PluginValidationError :show-inheritance: :members: diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index f352c980..8b148a93 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -2,6 +2,7 @@ "__version__", "PluginManager", "PluginValidationError", + "ProjectSpec", "HookCaller", "NormalHookCaller", "HistoricHookCaller", @@ -35,6 +36,7 @@ from ._hooks import WrapperImpl from ._manager import PluginManager from ._manager import PluginValidationError +from ._project import ProjectSpec from ._pytest_compat import HookimplOpts from ._pytest_compat import HookspecOpts from ._result import HookCallError diff --git a/src/pluggy/_decorators.py b/src/pluggy/_decorators.py index a241290a..54c33037 100644 --- a/src/pluggy/_decorators.py +++ b/src/pluggy/_decorators.py @@ -19,6 +19,7 @@ from ._config import HookimplConfiguration from ._config import HookspecConfiguration +from ._project import ProjectSpec _F = TypeVar("_F", bound=Callable[..., object]) @@ -30,15 +31,23 @@ class HookspecMarker: """Decorator for marking functions as hook specifications. - Instantiate it with a project_name to get a decorator. + Instantiate it with a project name or :class:`ProjectSpec` to get a + decorator. Calling :meth:`PluginManager.add_hookspecs` later will discover all marked functions if the :class:`PluginManager` uses the same project name. """ - __slots__ = ("project_name",) + __slots__ = ("_project_spec",) - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name + def __init__(self, project_name: str | ProjectSpec) -> None: + self._project_spec: Final = ( + ProjectSpec(project_name) if isinstance(project_name, str) else project_name + ) + + @property + def project_name(self) -> str: + """The project name from the associated :class:`ProjectSpec`.""" + return self._project_spec.project_name @overload def __call__( @@ -115,15 +124,23 @@ def setattr_hookspec_opts(func: _F) -> _F: class HookimplMarker: """Decorator for marking functions as hook implementations. - Instantiate it with a ``project_name`` to get a decorator. + Instantiate it with a project name or :class:`ProjectSpec` to get a + decorator. Calling :meth:`PluginManager.register` later will discover all marked functions if the :class:`PluginManager` uses the same project name. """ - __slots__ = ("project_name",) + __slots__ = ("_project_spec",) - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name + def __init__(self, project_name: str | ProjectSpec) -> None: + self._project_spec: Final = ( + ProjectSpec(project_name) if isinstance(project_name, str) else project_name + ) + + @property + def project_name(self) -> str: + """The project name from the associated :class:`ProjectSpec`.""" + return self._project_spec.project_name @overload def __call__( diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index bfe5669e..7de293bc 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -32,6 +32,7 @@ from ._hooks import NormalImpl from ._hooks import SubsetHookCaller from ._hooks import WrapperImpl +from ._project import ProjectSpec from ._pytest_compat import HookimplOpts from ._pytest_compat import HookspecOpts from ._result import Result @@ -86,12 +87,17 @@ class PluginManager: which will subsequently send debug information to the trace helper. :param project_name: - The short project name. Prefer snake case. Make sure it's unique! + The short project name (prefer snake case, make sure it's unique!), + or a :class:`ProjectSpec` instance. + + .. versionchanged:: 1.7 + A :class:`ProjectSpec` may be passed instead of a plain name. """ - def __init__(self, project_name: str) -> None: - #: The project name. - self.project_name: Final = project_name + def __init__(self, project_name: str | ProjectSpec) -> None: + self._project_spec: Final = ( + ProjectSpec(project_name) if isinstance(project_name, str) else project_name + ) self._name2plugin: Final[dict[str, _Plugin]] = {} self._plugin_distinfo: Final[ list[tuple[_Plugin, importlib.metadata.Distribution]] @@ -105,6 +111,11 @@ def __init__(self, project_name: str) -> None: ) self._inner_hookexec = _multicall + @property + def project_name(self) -> str: + """The project name from the associated :class:`ProjectSpec`.""" + return self._project_spec.project_name + def _hookexec( self, hook_name: str, diff --git a/src/pluggy/_project.py b/src/pluggy/_project.py new file mode 100644 index 00000000..3867d411 --- /dev/null +++ b/src/pluggy/_project.py @@ -0,0 +1,86 @@ +""" +Project configuration hub for pluggy projects. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Final +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from ._config import HookimplConfiguration + from ._config import HookspecConfiguration + from ._manager import PluginManager + + +class ProjectSpec: + """Manages hook markers and plugin manager creation for a pluggy project. + + This class provides a unified interface for creating and managing the + core components of a pluggy project: :class:`HookspecMarker`, + :class:`HookimplMarker`, and :class:`PluginManager`. + + All components share the same ``project_name``, ensuring consistent + behavior across hook specifications, implementations, and plugin + management. + + :param project_name: + The short project name. Prefer snake case. Make sure it's unique! + :param plugin_manager_cls: + Custom :class:`PluginManager` subclass to use (defaults to + :class:`PluginManager`). + + .. versionadded:: 1.7 + """ + + def __init__( + self, + project_name: str, + plugin_manager_cls: type[PluginManager] | None = None, + ) -> None: + # Local imports to avoid circular imports with the marker and + # manager modules. + from ._decorators import HookimplMarker + from ._decorators import HookspecMarker + from ._manager import PluginManager as DefaultPluginManager + + #: The project name used across all components. + self.project_name: Final = project_name + self._plugin_manager_cls: Final = plugin_manager_cls or DefaultPluginManager + + # Marker instances are stateless decorators, safe to share. + #: Hook specification marker for this project. + self.hookspec: Final = HookspecMarker(self) + #: Hook implementation marker for this project. + self.hookimpl: Final = HookimplMarker(self) + + def create_plugin_manager(self) -> PluginManager: + """Create a new :class:`PluginManager` instance for this project. + + Each call returns a fresh, independent instance configured with this + project's name. + """ + return self._plugin_manager_cls(self) + + def get_hookspec_config( + self, func: Callable[..., object] + ) -> HookspecConfiguration | None: + """Extract the hook specification configuration from a decorated + function, or ``None`` if it is not decorated with this project's + hookspec marker.""" + attr_name = self.project_name + "_spec" + return getattr(func, attr_name, None) + + def get_hookimpl_config( + self, func: Callable[..., object] + ) -> HookimplConfiguration | None: + """Extract the hook implementation configuration from a decorated + function, or ``None`` if it is not decorated with this project's + hookimpl marker.""" + attr_name = self.project_name + "_impl" + return getattr(func, attr_name, None) + + def __repr__(self) -> str: + return f"ProjectSpec(project_name={self.project_name!r})" diff --git a/testing/test_project_spec.py b/testing/test_project_spec.py new file mode 100644 index 00000000..ffa54a24 --- /dev/null +++ b/testing/test_project_spec.py @@ -0,0 +1,173 @@ +""" +Tests for ProjectSpec functionality. +""" + +from __future__ import annotations + +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import PluginManager +from pluggy import ProjectSpec + + +def test_project_spec_basic_creation() -> None: + project = ProjectSpec("testproject") + + assert project.project_name == "testproject" + assert isinstance(project.hookspec, HookspecMarker) + assert isinstance(project.hookimpl, HookimplMarker) + assert project.hookspec.project_name == "testproject" + assert project.hookimpl.project_name == "testproject" + assert repr(project) == "ProjectSpec(project_name='testproject')" + + +def test_project_spec_plugin_manager_creation() -> None: + project = ProjectSpec("testproject") + + pm1 = project.create_plugin_manager() + pm2 = project.create_plugin_manager() + + assert pm1 is not pm2 + assert pm1.project_name == "testproject" + assert pm2.project_name == "testproject" + assert isinstance(pm1, PluginManager) + assert isinstance(pm2, PluginManager) + + +def test_project_spec_custom_plugin_manager_class() -> None: + class CustomPluginManager(PluginManager): + def __init__(self, project_name: str | ProjectSpec) -> None: + super().__init__(project_name) + self.custom_attr = "custom_value" + + project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager) + pm = project.create_plugin_manager() + + assert isinstance(pm, CustomPluginManager) + assert pm.project_name == "testproject" + assert pm.custom_attr == "custom_value" + + +def test_project_spec_functional_integration() -> None: + project = ProjectSpec("testproject") + + hookspec = project.hookspec + hookimpl = project.hookimpl + + class HookSpecs: + @hookspec + def my_hook(self, arg: int) -> int: # type: ignore[empty-body] + ... + + class Plugin: + @hookimpl + def my_hook(self, arg: int) -> int: + return arg * 2 + + pm = project.create_plugin_manager() + pm.add_hookspecs(HookSpecs) + pm.register(Plugin()) + + result = pm.hook.my_hook(arg=5) + assert result == [10] + + +def test_project_spec_multiple_plugin_managers_independent() -> None: + project = ProjectSpec("testproject") + + pm1 = project.create_plugin_manager() + pm2 = project.create_plugin_manager() + + class Plugin1: + pass + + class Plugin2: + pass + + pm1.register(Plugin1(), name="plugin1") + pm2.register(Plugin2(), name="plugin2") + + assert pm1.has_plugin("plugin1") + assert not pm1.has_plugin("plugin2") + assert pm2.has_plugin("plugin2") + assert not pm2.has_plugin("plugin1") + + +def test_project_spec_hook_attribute_naming() -> None: + project = ProjectSpec("myproject") + + @project.hookspec + def test_hook() -> None: + pass + + @project.hookimpl + def test_hook_impl() -> None: + pass + + assert hasattr(test_hook, "myproject_spec") + assert hasattr(test_hook_impl, "myproject_impl") + + +def test_project_spec_get_hook_configs() -> None: + project = ProjectSpec("testproject") + + @project.hookspec(firstresult=True) + def my_hook() -> None: + pass + + @project.hookimpl(tryfirst=True, optionalhook=True) + def my_hook_impl() -> None: + pass + + spec_config = project.get_hookspec_config(my_hook) + assert spec_config is not None + assert spec_config.firstresult is True + + impl_config = project.get_hookimpl_config(my_hook_impl) + assert impl_config is not None + assert impl_config.tryfirst is True + assert impl_config.optionalhook is True + assert impl_config.wrapper is False + + def undecorated() -> None: + pass + + assert project.get_hookspec_config(undecorated) is None + assert project.get_hookimpl_config(undecorated) is None + + +def test_marker_classes_accept_project_spec() -> None: + project = ProjectSpec("testproject") + + hookspec_from_project = HookspecMarker(project) + hookimpl_from_project = HookimplMarker(project) + + assert hookspec_from_project.project_name == "testproject" + assert hookimpl_from_project.project_name == "testproject" + assert hookspec_from_project._project_spec is project + assert hookimpl_from_project._project_spec is project + + +def test_marker_classes_accept_string() -> None: + hookspec_from_string = HookspecMarker("testproject") + hookimpl_from_string = HookimplMarker("testproject") + + assert hookspec_from_string.project_name == "testproject" + assert hookimpl_from_string.project_name == "testproject" + assert hookspec_from_string._project_spec.project_name == "testproject" + assert hookimpl_from_string._project_spec.project_name == "testproject" + + +def test_plugin_manager_accepts_project_spec() -> None: + project = ProjectSpec("testproject") + pm = PluginManager(project) + + assert pm.project_name == "testproject" + assert pm._project_spec is project + + +def test_plugin_manager_accepts_string() -> None: + pm = PluginManager("testproject") + + assert pm.project_name == "testproject" + assert pm._project_spec.project_name == "testproject" From 391079141ccde77d9ffdfb92e2cfb04cafc7f103 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Jul 2026 16:24:32 +0200 Subject: [PATCH 8/8] feat(async): greenlet Submitter and PluginManager.run_async Complete design step 07: - New _async module with a persistent Submitter: maybe_submit awaits awaitable hook results while active and passes them through otherwise (await-me-maybe); require_await hard-fails outside async context; async_generator_to_sync helps wrappers consume async generators. - Submitter.run uses a sentinel so legitimate None returns work (fixes the try-claude footgun) and forwards await failures to the submission site inside the worker greenlet. - PluginManager owns the Submitter and threads it through the callers and _hookexec into _multicall - activation is purely Submitter.run, no _inner_hookexec monkeypatching. await pm.run_async(func) is the public entry point; nested runs raise. - Packaging: new pluggy[async] extra depending on greenlet; greenlet added to the testing group and types-greenlet to the mypy hook. Co-Authored-By: Claude Fable 5 --- .pre-commit-config.yaml | 2 +- changelog/710.feature.rst | 9 ++ pyproject.toml | 6 +- src/pluggy/_async.py | 163 +++++++++++++++++++++ src/pluggy/_caller.py | 40 +++++- src/pluggy/_execution.py | 10 ++ src/pluggy/_manager.py | 73 +++++++++- testing/benchmark.py | 10 +- testing/test_async.py | 288 ++++++++++++++++++++++++++++++++++++++ testing/test_multicall.py | 5 +- uv.lock | 95 +++++++++++++ 11 files changed, 686 insertions(+), 15 deletions(-) create mode 100644 changelog/710.feature.rst create mode 100644 src/pluggy/_async.py create mode 100644 testing/test_async.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8f31bfc4..8bf36bcb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,4 +49,4 @@ repos: - id: mypy files: ^(src/|testing/) args: [] - additional_dependencies: [pytest] + additional_dependencies: [pytest, types-greenlet] diff --git a/changelog/710.feature.rst b/changelog/710.feature.rst new file mode 100644 index 00000000..7f0f351f --- /dev/null +++ b/changelog/710.feature.rst @@ -0,0 +1,9 @@ +New async bridge for awaitable hook results: +``await pm.run_async(lambda: pm.hook.my_hook())`` runs the hook call in a +greenlet context where awaitable results from hook implementations are +awaited on the current event loop ("await-me-maybe"). Outside +``run_async``, awaitable results are passed through unchanged, as before. +Requires the optional ``greenlet`` dependency, installable via +``pluggy[async]``. The bridge is a persistent per-manager ``Submitter``; +async wrapper generators are not auto-awaited (a manual +``async_generator_to_sync`` helper is provided). diff --git a/pyproject.toml b/pyproject.toml index 3adc4454..209f5caa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,13 @@ readme = {file = "README.rst", content-type = "text/x-rst"} requires-python = ">=3.10" dynamic = ["version"] + +[project.optional-dependencies] +async = ["greenlet"] + [dependency-groups] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark", "coverage"] +testing = ["pytest", "pytest-benchmark", "coverage", "greenlet"] [tool.setuptools] diff --git a/src/pluggy/_async.py b/src/pluggy/_async.py new file mode 100644 index 00000000..e5a00970 --- /dev/null +++ b/src/pluggy/_async.py @@ -0,0 +1,163 @@ +""" +Async support for pluggy using greenlets. + +This module provides async functionality for pluggy, allowing hook +implementations to return awaitable objects that are automatically awaited +when running in an async context (see :meth:`PluginManager.run_async +`). +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Generator +from typing import Any +from typing import cast +from typing import Final +from typing import TYPE_CHECKING +from typing import TypeVar + + +_T = TypeVar("_T") +_Y = TypeVar("_Y") +_S = TypeVar("_S") + +if TYPE_CHECKING: + import greenlet + + +#: Sentinel distinguishing "worker never completed" from a legitimate +#: ``None`` result. +_UNSET: Final = object() + + +class Submitter: + """Bridge between synchronous hook execution and an async event loop. + + While :meth:`run` is active, awaitables passed to :meth:`maybe_submit` or + :meth:`require_await` are switched to the awaiting parent greenlet, which + awaits them and switches the result back. When inactive, + :meth:`maybe_submit` passes awaitables through unchanged + ("await-me-maybe"). + """ + + _active_submitter: greenlet.greenlet | None + + def __init__(self) -> None: + self._active_submitter = None + + def __repr__(self) -> str: + return f"" + + def maybe_submit(self, coro: Awaitable[_T]) -> _T | Awaitable[_T]: + """Await an awaitable if active, else return it unchanged. + + This enables backward compatibility for datasette + and https://simonwillison.net/2020/Sep/2/await-me-maybe/ + """ + active = self._active_submitter + if active is not None: + # We're in a greenlet context; switch to the parent with the + # awaitable. The parent awaits it and switches back the result. + res: _T = active.switch(coro) + return res + else: + return coro + + def require_await(self, coro: Awaitable[_T]) -> _T: + """Await an awaitable, raising an error if not in async context.""" + active = self._active_submitter + if active is not None: + res: _T = active.switch(coro) + return res + else: + raise RuntimeError("require_await called outside of async context") + + async def run(self, sync_func: Callable[[], _T]) -> _T: + """Run a synchronous function in a worker greenlet with async support. + + Awaitables submitted by hook implementations during the call are + awaited on this coroutine's event loop. + """ + try: + import greenlet + except ImportError: + raise RuntimeError("greenlet is required for async support") from None + + if self._active_submitter is not None: + raise RuntimeError("Submitter is already active") + + main_greenlet = greenlet.getcurrent() + result: object = _UNSET + exception: BaseException | None = None + + def greenlet_func() -> None: + nonlocal result, exception + try: + result = sync_func() + except BaseException as e: + exception = e + + worker_greenlet = greenlet.greenlet(greenlet_func) + # Let maybe_submit/require_await switch back to this greenlet. + self._active_submitter = main_greenlet + try: + # Run the worker; every switch back carries an awaitable to + # process, until the worker finishes (switching back None). + awaitable = worker_greenlet.switch() + while awaitable is not None: + try: + awaited_result = await awaitable + except BaseException as e: + # Raise at the submission site inside the worker. + awaitable = worker_greenlet.throw(e) + else: + awaitable = worker_greenlet.switch(awaited_result) + finally: + self._active_submitter = None + + if exception is not None: + raise exception + assert result is not _UNSET, "worker greenlet did not complete" + return cast(_T, result) + + +def async_generator_to_sync( + async_gen: AsyncGenerator[_Y, _S], submitter: Submitter +) -> Generator[_Y, _S, None]: + """Convert an async generator to a sync generator using a `Submitter`. + + This helper allows wrapper implementations to use async generators while + maintaining compatibility with the sync generator interface expected by + the hook system. The submitter must be active (i.e. the generator must be + consumed under :meth:`Submitter.run`). + """ + try: + # Start the async generator. + value = submitter.require_await(async_gen.__anext__()) + + while True: + try: + sent_value = yield value + try: + value = submitter.require_await(async_gen.asend(sent_value)) + except StopAsyncIteration: + return + + except GeneratorExit: + # Generator is being closed; close the async generator too. + submitter.require_await(cast(Awaitable[Any], async_gen.aclose())) + raise + + except BaseException as exc: + # Exception was thrown into the generator; forward it into + # the async generator. + try: + value = submitter.require_await(async_gen.athrow(exc)) + except StopAsyncIteration: + return + + except StopAsyncIteration: + return diff --git a/src/pluggy/_caller.py b/src/pluggy/_caller.py index 1b6cfe6e..d4ccf56f 100644 --- a/src/pluggy/_caller.py +++ b/src/pluggy/_caller.py @@ -19,6 +19,7 @@ from typing import TypeAlias from typing import TypeVar +from ._async import Submitter from ._config import HookimplConfiguration from ._config import hookspec_config_from_mapping from ._config import HookspecConfiguration @@ -31,7 +32,14 @@ _HookExec: TypeAlias = Callable[ - [str, Sequence[NormalImpl], Sequence[WrapperImpl], Mapping[str, object], bool], + [ + str, + Sequence[NormalImpl], + Sequence[WrapperImpl], + Mapping[str, object], + bool, + Submitter, + ], "object | list[object]", ] @@ -158,6 +166,7 @@ class NormalHookCaller: "_hookexec", "_normal_hookimpls", "_wrapper_hookimpls", + "_async_submitter", ) def __init__( @@ -166,11 +175,13 @@ def __init__( hook_execute: _HookExec, specmodule_or_class: _Namespace | None = None, spec_config: HookspecConfiguration | None = None, + async_submitter: Submitter | None = None, ) -> None: """:meta private:""" #: Name of the hook getting called. self.name: Final = name self._hookexec: Final = hook_execute + self._async_submitter: Final = async_submitter or Submitter() # Split hook implementations into two lists for simpler management: # Normal hooks: [trylast, normal, tryfirst] # Wrapper hooks: [trylast, normal, tryfirst] @@ -263,6 +274,7 @@ def __call__(self, **kwargs: object) -> Any: self._wrapper_hookimpls.copy(), kwargs, firstresult, + self._async_submitter, ) def call_historic( @@ -296,6 +308,7 @@ def call_extra( self._wrapper_hookimpls.copy(), kwargs, firstresult, + self._async_submitter, ) def _maybe_apply_history(self, method: HookImpl) -> None: @@ -320,6 +333,7 @@ class HistoricHookCaller: "_hookexec", "_hookimpls", "_call_history", + "_async_submitter", ) spec: HookSpec @@ -330,12 +344,14 @@ def __init__( hook_execute: _HookExec, specmodule_or_class: _Namespace, spec_config: HookspecConfiguration, + async_submitter: Submitter | None = None, ) -> None: """:meta private:""" assert spec_config.historic, "HistoricHookCaller requires historic=True" #: Name of the hook getting called. self.name: Final = name self._hookexec: Final = hook_execute + self._async_submitter: Final = async_submitter or Submitter() # The hookimpls list for historic hooks (no wrappers supported). self._hookimpls: Final[list[NormalImpl]] = [] self._call_history: Final[_CallHistory] = [] @@ -408,7 +424,14 @@ def call_historic( # Historizing hooks don't return results. # Remember firstresult isn't compatible with historic. # Copy because plugins may register other plugins during iteration (#438). - res = self._hookexec(self.name, self._hookimpls.copy(), [], kwargs, False) + res = self._hookexec( + self.name, + self._hookimpls.copy(), + [], + kwargs, + False, + self._async_submitter, + ) if result_callback is None: return if isinstance(res, list): @@ -427,7 +450,9 @@ def _maybe_apply_history(self, method: HookImpl) -> None: """Apply call history to a new hookimpl.""" assert isinstance(method, NormalImpl) for kwargs, result_callback in self._call_history: - res = self._hookexec(self.name, [method], [], kwargs, False) + res = self._hookexec( + self.name, [method], [], kwargs, False, self._async_submitter + ) if res and result_callback is not None: # XXX: remember firstresult isn't compat with historic assert isinstance(res, list) @@ -507,6 +532,7 @@ def __call__(self, **kwargs: object) -> Any: self._get_filtered(orig._wrapper_hookimpls), kwargs, firstresult, + orig._async_submitter, ) def call_historic( @@ -527,7 +553,12 @@ def call_historic( # History is shared with the original caller. orig._call_history.append((kwargs, result_callback)) res = orig._hookexec( - self.name, self._get_filtered(orig._hookimpls), [], kwargs, False + self.name, + self._get_filtered(orig._hookimpls), + [], + kwargs, + False, + orig._async_submitter, ) if result_callback is None: return @@ -562,6 +593,7 @@ def call_extra( self._get_filtered(orig._wrapper_hookimpls), kwargs, firstresult, + orig._async_submitter, ) def __repr__(self) -> str: diff --git a/src/pluggy/_execution.py b/src/pluggy/_execution.py index 100b1fba..0be5ff50 100644 --- a/src/pluggy/_execution.py +++ b/src/pluggy/_execution.py @@ -4,6 +4,7 @@ from __future__ import annotations +from collections.abc import Awaitable from collections.abc import Generator from collections.abc import Mapping from collections.abc import Sequence @@ -20,6 +21,10 @@ from ._warnings import PluggyTeardownRaisedWarning +if TYPE_CHECKING: + from ._async import Submitter + + # Need to distinguish between old- and new-style hook wrappers. # Wrapping with a tuple is the fastest type-safe way I found to do it. Teardown: TypeAlias = Generator[None, object, object] @@ -86,6 +91,7 @@ def _multicall( wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: """Execute a call into multiple python functions/methods and return the result(s). @@ -117,6 +123,10 @@ def _multicall( args = normal_impl._get_call_args(caller_kwargs) res = normal_impl.function(*args) if res is not None: + # Awaitable results are awaited when a Submitter is active + # (await-me-maybe), otherwise passed through unchanged. + if isinstance(res, Awaitable): + res = async_submitter.maybe_submit(res) results.append(res) if firstresult: # halt further impl calls break diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index 7de293bc..0f8360bd 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -11,9 +11,11 @@ from typing import Final from typing import TYPE_CHECKING from typing import TypeAlias +from typing import TypeVar import warnings from . import _tracing +from ._async import Submitter from ._callers import _multicall from ._config import hookimpl_config_from_mapping from ._config import hookimpl_config_to_mapping @@ -44,6 +46,8 @@ from ._compat import DistFacade +_T = TypeVar("_T") + _BeforeTrace: TypeAlias = Callable[[str, Sequence[HookImpl], Mapping[str, Any]], None] _AfterTrace: TypeAlias = Callable[ [Result[Any], str, Sequence[HookImpl], Mapping[str, Any]], None @@ -89,15 +93,24 @@ class PluginManager: :param project_name: The short project name (prefer snake case, make sure it's unique!), or a :class:`ProjectSpec` instance. + :param async_submitter: + Custom :class:`~pluggy._async.Submitter` used to await awaitable hook + results under :meth:`run_async` (defaults to a fresh one). .. versionchanged:: 1.7 A :class:`ProjectSpec` may be passed instead of a plain name. + Added ``async_submitter`` and :meth:`run_async`. """ - def __init__(self, project_name: str | ProjectSpec) -> None: + def __init__( + self, + project_name: str | ProjectSpec, + async_submitter: Submitter | None = None, + ) -> None: self._project_spec: Final = ( ProjectSpec(project_name) if isinstance(project_name, str) else project_name ) + self._async_submitter: Final = async_submitter or Submitter() self._name2plugin: Final[dict[str, _Plugin]] = {} self._plugin_distinfo: Final[ list[tuple[_Plugin, importlib.metadata.Distribution]] @@ -123,11 +136,17 @@ def _hookexec( wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: # called from all hookcaller instances. # enable_tracing will set its own wrapping function at self._inner_hookexec return self._inner_hookexec( - hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + async_submitter, ) def register(self, plugin: _Plugin, name: str | None = None) -> str | None: @@ -174,7 +193,11 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: self.hook, hook_name, None ) if hook is None: - hook = NormalHookCaller(hook_name, self._hookexec) + hook = NormalHookCaller( + hook_name, + self._hookexec, + async_submitter=self._async_submitter, + ) setattr(self.hook, hook_name, hook) elif hook.has_spec(): self._verify_hook(hook, hookimpl) @@ -306,11 +329,19 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: if hc is None: if spec_config.historic: hc = HistoricHookCaller( - name, self._hookexec, module_or_class, spec_config + name, + self._hookexec, + module_or_class, + spec_config, + async_submitter=self._async_submitter, ) else: hc = NormalHookCaller( - name, self._hookexec, module_or_class, spec_config + name, + self._hookexec, + module_or_class, + spec_config, + async_submitter=self._async_submitter, ) setattr(self.hook, name, hc) elif spec_config.historic and not hc.is_historic(): @@ -324,7 +355,11 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: raise AssertionError("unreachable") # pragma: no cover old_hookimpls = hc.get_hookimpls() historic_hc = HistoricHookCaller( - name, self._hookexec, module_or_class, spec_config + name, + self._hookexec, + module_or_class, + spec_config, + async_submitter=self._async_submitter, ) setattr(self.hook, name, historic_hc) for hookimpl in old_hookimpls: @@ -591,6 +626,7 @@ def traced_hookexec( wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: # For backward compatibility of the before/after callback shapes, # combine the split lists into one. @@ -598,7 +634,12 @@ def traced_hookexec( before(hook_name, hook_impls, caller_kwargs) outcome = Result.from_call( lambda: oldcall( - hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + async_submitter, ) ) after(outcome, hook_name, hook_impls, caller_kwargs) @@ -636,6 +677,24 @@ def after( return self.add_hookcall_monitoring(before, after) + async def run_async(self, func: Callable[[], _T]) -> _T: + """Run a synchronous function with async support for hook results. + + The function runs in a greenlet context in which awaitable hook + results are automatically awaited on the current event loop:: + + pm = PluginManager("myapp") + ... + results = await pm.run_async(lambda: pm.hook.my_hook()) + + :raises RuntimeError: + If ``greenlet`` is not installed (install ``pluggy[async]``), or + if a ``run_async`` call is already active for this manager. + + .. versionadded:: 1.7 + """ + return await self._async_submitter.run(func) + def subset_hook_caller( self, name: str, remove_plugins: Iterable[_Plugin] ) -> HookCaller: diff --git a/testing/benchmark.py b/testing/benchmark.py index 27a7096e..d3b12aab 100644 --- a/testing/benchmark.py +++ b/testing/benchmark.py @@ -12,6 +12,7 @@ from pluggy import HookspecMarker from pluggy import PluginManager from pluggy import WrapperImpl +from pluggy._async import Submitter from pluggy._callers import _multicall from pluggy._hooks import varnames @@ -112,7 +113,14 @@ def setup(): normal_impls.append(f) caller_kwargs = {"arg1": 1, "arg2": 2, "arg3": 3} firstresult = False - return (hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult), {} + return ( + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + Submitter(), + ), {} benchmark.pedantic(_multicall, setup=setup, rounds=10) diff --git a/testing/test_async.py b/testing/test_async.py new file mode 100644 index 00000000..2b8c790e --- /dev/null +++ b/testing/test_async.py @@ -0,0 +1,288 @@ +""" +Tests for the greenlet-based async Submitter support. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from collections.abc import Awaitable +from collections.abc import Coroutine +from typing import Any +from typing import cast + +import pytest + +import pluggy +from pluggy._async import async_generator_to_sync +from pluggy._async import Submitter + + +hookspec = pluggy.HookspecMarker("test") +hookimpl = pluggy.HookimplMarker("test") + + +class HookSpecs: + @hookspec + def test_hook(self): + pass + + +class AsyncPlugin: + @hookimpl + async def test_hook(self): + await asyncio.sleep(0.01) + return "async_result" + + +class SyncPlugin: + @hookimpl + def test_hook(self): + return "sync_result" + + +def make_pm(*plugins: object) -> pluggy.PluginManager: + pm = pluggy.PluginManager("test") + pm.add_hookspecs(HookSpecs) + for plugin in plugins: + pm.register(plugin) + return pm + + +def test_run_async_mixed_sync_and_async() -> None: + pytest.importorskip("greenlet") + pm = make_pm(AsyncPlugin(), SyncPlugin()) + + async def run_test() -> list[str]: + result = await pm.run_async(lambda: pm.hook.test_hook()) + return cast(list[str], result) + + result = asyncio.run(run_test()) + assert "sync_result" in result + assert "async_result" in result + + +def test_run_async_with_sync_hooks_only() -> None: + pytest.importorskip("greenlet") + pm = make_pm(SyncPlugin()) + + async def run_test() -> list[str]: + result = await pm.run_async(lambda: pm.hook.test_hook()) + return cast(list[str], result) + + assert asyncio.run(run_test()) == ["sync_result"] + + +def test_awaitable_left_in_results_outside_run_async() -> None: + """Outside run_async, awaitables are passed through (await-me-maybe).""" + pm = make_pm(AsyncPlugin(), SyncPlugin()) + + results = pm.hook.test_hook() + assert "sync_result" in results + awaitables = [res for res in results if isinstance(res, Awaitable)] + assert len(awaitables) == 1 + + # Awaiting the leftover coroutine still works. + leftover = cast("Coroutine[Any, Any, str]", awaitables[0]) + assert asyncio.run(leftover) == "async_result" + + +def test_run_async_returning_none_succeeds() -> None: + """Regression test: a legitimate None result must not be rejected.""" + pytest.importorskip("greenlet") + pm = make_pm() + + async def run_test() -> None: + return await pm.run_async(lambda: None) + + assert asyncio.run(run_test()) is None + + +def test_nested_run_async_rejected() -> None: + pytest.importorskip("greenlet") + pm = make_pm() + inner_error: list[BaseException] = [] + + def nested() -> object: + submitter = pm._async_submitter + # Submitter is already active - a nested run must hard-fail. + coro = submitter.run(lambda: 1) + try: + coro.send(None) + except RuntimeError as e: + inner_error.append(e) + finally: + coro.close() + return "done" + + async def run_test() -> object: + return await pm.run_async(nested) + + assert asyncio.run(run_test()) == "done" + assert inner_error + assert "already active" in str(inner_error[0]) + + +def test_run_async_propagates_sync_exception() -> None: + pytest.importorskip("greenlet") + pm = make_pm() + + def boom() -> None: + raise ValueError("boom") + + async def run_test() -> None: + await pm.run_async(boom) + + with pytest.raises(ValueError, match="boom"): + asyncio.run(run_test()) + + +def test_run_async_propagates_awaited_exception() -> None: + """An exception raised while awaiting surfaces at the hook call site.""" + pytest.importorskip("greenlet") + + class FailingAsyncPlugin: + @hookimpl + async def test_hook(self): + raise ValueError("async boom") + + pm = make_pm(FailingAsyncPlugin()) + + async def run_test() -> None: + await pm.run_async(lambda: pm.hook.test_hook()) + + with pytest.raises(ValueError, match="async boom"): + asyncio.run(run_test()) + + +def test_run_async_without_greenlet() -> None: + try: + import greenlet # noqa: F401 + + pytest.skip("greenlet is available, cannot test the error case") + except ImportError: # pragma: no cover + pm = make_pm() + + async def run_test() -> None: + with pytest.raises(RuntimeError, match="greenlet is required"): + await pm.run_async(lambda: "test") + + asyncio.run(run_test()) + + +def test_require_await_outside_context() -> None: + submitter = Submitter() + + async def coro() -> None: + pass # pragma: no cover + + awaitable = coro() + with pytest.raises(RuntimeError, match="outside of async context"): + submitter.require_await(awaitable) + awaitable.close() + + +def test_maybe_submit_outside_context_returns_awaitable() -> None: + submitter = Submitter() + + async def coro() -> str: + return "value" + + awaitable = coro() + assert submitter.maybe_submit(awaitable) is awaitable + assert asyncio.run(awaitable) == "value" + + +def test_async_generator_to_sync_basic() -> None: + pytest.importorskip("greenlet") + + async def simple_async_gen() -> AsyncGenerator[str]: + yield "first" + yield "second" + + submitter = Submitter() + + def test_func() -> tuple[list[str], Any]: + sync_gen = async_generator_to_sync(simple_async_gen(), submitter) + values = [] + try: + while True: + values.append(next(sync_gen)) + except StopIteration as e: + return values, e.value + + values, final_value = asyncio.run(submitter.run(test_func)) + assert values == ["first", "second"] + assert final_value is None + + +def test_async_generator_to_sync_with_send() -> None: + pytest.importorskip("greenlet") + + async def async_gen_with_send() -> AsyncGenerator[str, str]: + sent1 = yield "initial" + sent2 = yield f"got_{sent1}" + yield f"final_{sent2}" + + submitter = Submitter() + + def test_func() -> tuple[str, str, str, Any]: + sync_gen = async_generator_to_sync(async_gen_with_send(), submitter) + value1 = next(sync_gen) + value2 = sync_gen.send("hello") + value3 = sync_gen.send("world") + try: + next(sync_gen) + raise AssertionError("should have raised StopIteration") + except StopIteration as e: + return value1, value2, value3, e.value + + value1, value2, value3, final = asyncio.run(submitter.run(test_func)) + assert value1 == "initial" + assert value2 == "got_hello" + assert value3 == "final_world" + assert final is None + + +def test_async_generator_to_sync_with_exception() -> None: + pytest.importorskip("greenlet") + + async def async_gen_with_exception() -> AsyncGenerator[str]: + try: + yield "before_exception" + yield "should_not_reach" + except ValueError as e: + yield f"caught_{e}" + + submitter = Submitter() + + def test_func() -> tuple[str, str, Any]: + sync_gen = async_generator_to_sync(async_gen_with_exception(), submitter) + value1 = next(sync_gen) + value2 = sync_gen.throw(ValueError("test_error")) + try: + next(sync_gen) + raise AssertionError("should have raised StopIteration") + except StopIteration as e: + return value1, value2, e.value + + value1, value2, final = asyncio.run(submitter.run(test_func)) + assert value1 == "before_exception" + assert value2 == "caught_test_error" + assert final is None + + +def test_async_generator_to_sync_inactive_submitter() -> None: + async def simple_async_gen() -> AsyncGenerator[str]: + yield "test" # pragma: no cover + + submitter = Submitter() + + with pytest.raises(RuntimeError, match="outside of async context"): + sync_gen = async_generator_to_sync(simple_async_gen(), submitter) + next(sync_gen) + + +def test_submitter_repr() -> None: + submitter = Submitter() + assert repr(submitter) == "" diff --git a/testing/test_multicall.py b/testing/test_multicall.py index 1c805f4c..07e9115a 100644 --- a/testing/test_multicall.py +++ b/testing/test_multicall.py @@ -9,6 +9,7 @@ from pluggy import HookspecMarker from pluggy import NormalImpl from pluggy import WrapperImpl +from pluggy._async import Submitter from pluggy._callers import _multicall @@ -30,7 +31,9 @@ def MC( wrapper_impls.append(f) else: normal_impls.append(f) - return _multicall("foo", normal_impls, wrapper_impls, kwargs, firstresult) + return _multicall( + "foo", normal_impls, wrapper_impls, kwargs, firstresult, Submitter() + ) def test_keyword_args() -> None: diff --git a/uv.lock b/uv.lock index 239b6258..71498735 100644 --- a/uv.lock +++ b/uv.lock @@ -172,6 +172,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072, upload-time = "2026-07-22T11:38:14.299Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393, upload-time = "2026-07-22T12:26:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750, upload-time = "2026-07-22T12:28:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5c/53d6b94742a6f1ee1877c7ff76262c909e137f3f7383ce96a8ab78e1ae31/greenlet-3.5.4-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52", size = 629659, upload-time = "2026-07-22T12:43:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920, upload-time = "2026-07-22T11:51:06.83Z" }, + { url = "https://files.pythonhosted.org/packages/f2/34/957fc5577180ef2f57be82580ee1f59fdefad4f6c623c7d5e1b6980a76fb/greenlet-3.5.4-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c", size = 425580, upload-time = "2026-07-22T12:39:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262, upload-time = "2026-07-22T12:25:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906, upload-time = "2026-07-22T11:51:08.627Z" }, + { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036, upload-time = "2026-07-22T11:38:30.073Z" }, + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -221,6 +307,11 @@ wheels = [ name = "pluggy" source = { editable = "." } +[package.optional-dependencies] +async = [ + { name = "greenlet" }, +] + [package.dev-dependencies] dev = [ { name = "pre-commit" }, @@ -228,11 +319,14 @@ dev = [ ] testing = [ { name = "coverage" }, + { name = "greenlet" }, { name = "pytest" }, { name = "pytest-benchmark" }, ] [package.metadata] +requires-dist = [{ name = "greenlet", marker = "extra == 'async'" }] +provides-extras = ["async"] [package.metadata.requires-dev] dev = [ @@ -241,6 +335,7 @@ dev = [ ] testing = [ { name = "coverage" }, + { name = "greenlet" }, { name = "pytest" }, { name = "pytest-benchmark" }, ]