Skip to content
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ repos:
hooks:
- id: blacken-docs
additional_dependencies: [black==24.2.0]
exclude: ^design/
- repo: local
hooks:
- id: rst
Expand Down
8 changes: 8 additions & 0 deletions changelog/704.removal.rst
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions changelog/706.trivial.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
``HookSpec`` now stores its :class:`pluggy.HookspecConfiguration` as
``config``; the old ``opts`` attribute remains as a deprecated alias
property.
9 changes: 9 additions & 0 deletions changelog/707.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions changelog/708.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions changelog/709.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 99 additions & 0 deletions design/01-module-reorganization.md
Original file line number Diff line number Diff line change
@@ -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.
149 changes: 149 additions & 0 deletions design/02-configuration-objects.md
Original file line number Diff line number Diff line change
@@ -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.
Loading