Lightweight before/after/around hooks for Python callables.
Project description
pyHooky
Lightweight before/after/around hooks for Python callables.
Install
pip install pyHooky
pip install pyhooky[typed] # pydantic-typed hook points
or
uv add pyhooky # core only — no runtime deps
uv add 'pyhooky[typed]' # adds pydantic-typed hook points
Usage
As decorators
The target can be a string name, or omitted entirely — in which case it's derived from the wrapped function's module.qualname. The wrapped function exposes its target via __hook_target__, so other hooks can reference it directly:
from pyhooky import before, after, around, hook
@hook # bare decorator — target = "mymodule.checkout"
def checkout(cart):
...
@before(checkout) # reference the function itself, no string needed
def log_start(cart):
print("starting checkout for", cart)
@after(checkout)
def log_done(result, cart):
print("checkout done:", result)
@around(checkout)
def retry(inner, cart):
for _ in range(3):
try:
return inner(cart)
except TransientError:
continue
raise
After hooks receive (result, *original_args, **original_kwargs) — result first, then everything the wrapped function was called with.
AFTER does not fire on exceptions. If the wrapped body (or any
around) raises,afterhooks are skipped and the exception propagates. For "fired on success or failure" semantics, useon_errorfor observability oraroundwithtry/finallyif you need to mutate the outcome.
kwargsis passed by reference into BEFORE hooks, so a BEFORE hook can rewrite the kwargs dict and the body will see the change. Positionalargsis a tuple — to rewrite positional arguments, use anaroundhook. (This asymmetry is intentional; if you need symmetric rewriting, always usearound.)
Late-bound targets: if the function isn't wrapped yet, pass the target name (a string) — the function-reference form (
@before(checkout)) requires the target to already exist with__hook_target__. String targets work even when the wrapping@hookhasn't been imported yet.
Explicit names still work when you want them (cross-module hooks, stable wire names, etc.):
@hook("checkout")
def checkout(cart): ...
@before("checkout")
def log_start(cart): ...
Without decorators
Every decorator also works as a direct call — pass the function as the second argument:
from pyhooky import HookRegistry, before, hook
# module-level helpers
before("checkout", log_start)
checkout = hook("checkout", checkout)
# or via an explicit registry
registry = HookRegistry()
registry.add_before("checkout", log_start)
registry.add_after("checkout", log_done)
registry.add_around("checkout", retry)
checkout = registry.wrap("checkout", checkout)
registry.remove("checkout", log_start) # detach a single hook
registry.clear("checkout") # detach all
Hook points inside a function
Use on to register a listener and trigger to fire it from inside a function body — the classic event-emit pattern:
from pyhooky import on, trigger
@on("checkout:step")
def audit(step, **ctx):
print("step:", step, ctx)
def checkout(cart):
trigger("checkout:step", "validate", cart=cart)
validate(cart)
trigger("checkout:step", "charge", cart=cart)
charge(cart)
trigger("checkout:step", "ship", cart=cart)
# trigger returns each listener's return value, in priority order
results = trigger("checkout:step", "validate", cart=cart)
trigger only fires LISTENER-kind hooks; before/after/around/on_error remain tied to wrapped callables.
Error hooks
Use on_error to observe exceptions from the wrapped function or its around chain. The signature mirrors before: (exc, *original_args, **original_kwargs). Error hooks cannot swallow the exception — they're observation only — so after they all fire, the original exception re-raises. To swallow or transform an exception, use an around with try/except.
from pyhooky import hook, on_error
@on_error("checkout")
def report(exc, cart):
metrics.increment("checkout.failed", tags={"err": type(exc).__name__})
@hook("checkout")
def checkout(cart):
raise ValueError("payment declined")
checkout(cart) # report() fires, then ValueError propagates
on_error fires for any exception bubbling out of the wrapped body or any around hook. If an around catches and recovers (returns a value or raises a different exception type and you handle it), on_error only fires for whatever exception ultimately escapes the chain. Typed hook points expose the same affordance via @point.on_error — the signature is (exc, model).
Listeners that raise PriorityChange reorder themselves but contribute no result to the returned list — so len(results) can be less than the number of registered listeners. If you need a 1:1 mapping, keep priority-mutating logic out of value-returning listeners.
Typed hook points (pydantic)
Bind a hook point to a pydantic schema. The payload is validated on every trigger; listeners receive the validated model. Requires the [typed] extra (uv add 'pyhooky[typed]'):
from pydantic import BaseModel
from pyhooky import HookPoint
class CheckoutStep(BaseModel):
step: str
cart_id: int
checkout_step = HookPoint("checkout:step", CheckoutStep)
@checkout_step.listen
def audit(event: CheckoutStep) -> None:
print(event.step, event.cart_id)
checkout_step.trigger(step="validate", cart_id=42) # kwargs → validated
checkout_step.trigger({"step": "charge", "cart_id": 42}) # dict → validated
checkout_step.trigger(CheckoutStep(step="ship", cart_id=42)) # model → passthrough
Invalid payloads raise pydantic.ValidationError before any listener runs.
Typed wrapping (validated before/after/around)
A HookPoint can also wrap a callable. The wrapper validates the payload, then dispatches typed before/after/around hooks that receive the validated model:
from pyhooky import HookPoint
from pydantic import BaseModel
class Order(BaseModel):
id: int
items: list[str]
place_order = HookPoint("place_order", Order)
@place_order.wrap
def place_order_impl(order: Order) -> int:
return order.id
@place_order.before
def validate(order: Order) -> None:
assert order.id > 0
@place_order.after
def audit(result: int, order: Order) -> None:
print("placed:", result, order.items)
@place_order.around
def retry(inner, order: Order) -> int:
for _ in range(3):
try:
return inner(order)
except TransientError:
continue
raise
place_order_impl(id=1, items=["a"]) # kwargs → Order → hooks fire
place_order_impl({"id": 2, "items": ["b"]}) # dict → Order
place_order_impl(Order(id=3, items=["c"])) # passthrough
Hook signatures:
before(order: Order)— receives the validated model.after(result, order: Order)— receives the return value and the model.around(inner, order: Order)— callinner(order)to invoke the next layer.
@point.wrap auto-detects async def and returns an async wrapper; typed before/after/around can be sync or async (subject to the same compatibility rules as @hook). ValidationError raises before any hook fires. point.wrap, point.before, point.after, point.around all accept the same priority / on_priority_change / tag kwargs as the untyped registry helpers.
Dispatch matrix — a single HookPoint exposes two parallel paths that share the target name but use disjoint hook kinds:
| Entry point | Fires |
|---|---|
point.trigger(...) / atrigger |
listeners attached via @point.listen |
Calling the @point.wrap wrapper |
before / around / after attached via @point.{before,around,after} |
@point.before is not fired by point.trigger(...), and @point.listen is not fired by calling the wrapped function. Pick the path that matches what you want to dispatch.
Async
hook auto-detects async def targets and returns an async wrapper. Hooks themselves can be sync or async — the wrapper awaits whichever is awaitable.
import asyncio
from pyhooky import hook, before, after, around, on, atrigger
@hook
async def checkout(cart):
await asyncio.sleep(0)
return charge(cart)
@before(checkout) # sync hook on async target — fine
def log(cart): ...
@before(checkout)
async def audit(cart): # async hook on async target — awaited
await emit("audit", cart)
@around(checkout) # MUST be async on async target
async def retry(inner, cart):
for _ in range(3):
try:
return await inner(cart)
except TransientError:
continue
raise
result = await checkout(my_cart)
For hook points (the on/trigger pair), use atrigger from async code — it awaits async listeners and calls sync ones directly:
@on("step")
async def listener(name): ...
await atrigger("step", "validate")
HookPoint.atrigger(...) works the same way for pydantic-typed points.
Compatibility rules (enforced at call time with a clear RuntimeError):
| Target | Sync before/after/on_error | Async before/after/on_error | Sync around | Async around |
|---|---|---|---|---|
def (sync) |
✓ | ✗ | ✓ | ✗ |
async def |
✓ | ✓ | ✗ | ✓ |
trigger (sync) raises if any registered listener is async — use atrigger instead.
Duplicate registrations
Registering the same (kind, fn) twice on the same target raises ValueError. This catches accidental double-decoration and the silent dispatch-time dedup that earlier versions did. To re-register with a different priority, call remove() first.
Priority control
Higher priority fires first — for before, after, listeners, and around (the highest-priority around becomes the outermost wrapper).
A hook can raise a PriorityChange exception to adjust its own priority. The registry catches it, applies the change to the bucket, and continues. The reorder takes effect on the next dispatch by default.
from pyhooky import (
before, hook, PriorityBoost, PriorityDemote,
SetPriority, RunBefore, RunAfter,
)
@before("checkout")
def validate(cart):
if needs_higher_priority(cart):
raise PriorityBoost(by=10) # bump self by 10 for future dispatches
@before("checkout")
def audit(cart):
raise RunBefore(validate) # ensure I fire before `validate`
Exceptions:
| Exception | Effect |
|---|---|
PriorityBoost(by=1) |
priority += by |
PriorityDemote(by=1) |
priority -= by |
SetPriority(value) |
priority = value |
RunBefore(other) |
priority = max(current, other.priority + 1) — ensure-semantics |
RunAfter(other) |
priority = min(current, other.priority - 1) |
RunBefore / RunAfter resolve other against the same target's bucket; an unknown other raises RuntimeError from the firing dispatch, regardless of the configured on_priority_change behavior.
Dispatch behavior — what happens to the current dispatch when the exception is raised. Configurable per-registry (default) or per-hook (override):
HookRegistry(on_priority_change="continue") # registry default
before(target, fn, on_priority_change="resort") # per-hook override
"continue"(default) — catch, update priority, keep iterating in the original order. Reorder visible on next dispatch."resort"— catch, update priority, re-sort the remaining hooks for this phase. Already-fired hooks don't fire again."propagate"— update priority, then re-raise.
Applies to before / after / on (listener) hooks. around hooks are composed before dispatch starts, so a PriorityChange raised from around simply propagates — keep state-mutating logic in before if you want dynamic ordering.
Snapshots per phase
Each dispatch takes a fresh snapshot per phase — once before BEFORE fires, once before AROUND composition, once before AFTER fires. So a BEFORE hook that registers a new AFTER hook (or around hook) sees its addition picked up later in the same call. registry.get(target) and registry.all_hooks() return point-in-time snapshots — priorities can change underneath via PriorityChange, but already-handed-out Hook instances stay immutable.
Plugin systems — tags, introspection, and scoping
Tag your registrations with tag="plugin-name" and tear them all down with one call:
from pyhooky import before, on, get_default_registry
@before("checkout", tag="audit-plugin")
def audit(cart): ...
@on("checkout:step", tag="audit-plugin")
def step_listener(step, **ctx): ...
# later — unload the plugin
removed = get_default_registry().clear_tag("audit-plugin")
print(f"unloaded {len(removed)} hooks") # list of (target, Hook) pairs
Auto-tagging via tag_scope — wrap a plugin's setup in tag_scope(name) and every registration inside picks up the tag automatically. No more "I forgot to tag one of the 30 hooks":
from pyhooky import tag_scope, before, after, on, on_error
with tag_scope("audit-plugin"):
@before("checkout")
def audit_before(cart): ...
@after("checkout")
def audit_after(result, cart): ...
@on("checkout:step")
def audit_step(step, **ctx): ...
@on_error("checkout")
def audit_error(exc, cart): ...
# All four are tagged "audit-plugin" — one call clears the whole plugin.
get_default_registry().clear_tag("audit-plugin")
Registrations that pass an explicit tag= keep their explicit value; the scope only fills in missing tags. Nested scopes use the innermost tag. Contextvar-based, so it propagates across asyncio tasks and threads.
Tags can also be hidden temporarily without unloading — useful for "disable this plugin for the next call" without losing its registrations:
from pyhooky import disabled
with disabled("audit-plugin"):
checkout(cart) # audit-plugin hooks don't fire here
checkout(cart) # ... but do fire here again
disabled is contextvar-scoped, so it propagates correctly across asyncio tasks and threads.
Introspection helpers:
reg = get_default_registry()
reg.targets() # ["checkout", "checkout:step", ...]
reg.tags() # ["audit-plugin", ...] — every non-None tag in use
reg.all_hooks() # [(target, Hook), ...]
reg.hooks_by_tag("audit-plugin")
reg.dump_target("checkout") # hooks in firing order (debugging)
repr(reg) # HookRegistry(name='default', targets=2, hooks=3)
To isolate a host or plugin to its own registry, either pass registry=... to every call, swap the process-wide default, or scope a context:
from pyhooky import HookRegistry, set_default_registry, use_registry
plugin_registry = HookRegistry(name="plugin-host")
# Process-wide swap
previous = set_default_registry(plugin_registry)
# ... load plugins ...
set_default_registry(previous) # restore
# Or scope it (contextvar-based, thread/asyncio-safe)
with use_registry(plugin_registry):
load_plugin("auth")
Lazy
@hookbinding. A wrapper produced by@hook(withoutregistry=) starts unbound — it follows the active registry on every call. The first time any hook is attached to it (e.g. via@before(fn)), it locks to the registry that registration landed in. Registration and dispatch then stay consistent even if the caller later switches contexts. If you need different registries for the same function, declare separate wrappers (or passregistry=explicitly at decoration time).
Hook and HookKind are public — registry.get(target), registry.all_hooks(), and registry.hooks_by_tag(...) all return immutable Hook snapshots whose fields (kind, fn, priority, on_priority_change, tag) you can read for diagnostics.
For plugin coordination by priority, registry.priority_of(target, fn) returns a hook's current priority so a later plugin can register at priority_of(target, other_fn) + 1 for guaranteed ordering.
Sandboxing hook failures — on_hook_error
By default, an exception raised by a before / after / on_error / listener hook propagates out of the dispatch, aborting the call. For plugin-host scenarios where one buggy plugin shouldn't take down the whole call, set the registry's on_hook_error policy:
reg = HookRegistry(on_hook_error="log")
@before("checkout", registry=reg)
def buggy(cart):
raise RuntimeError("plugin bug")
# Subsequent hooks and the wrapped body still run; the error is logged
# via logging.getLogger("pyhooky").
Policies:
| Value | Behavior |
|---|---|
"raise" (default) |
Hook errors propagate. Backwards compatible. |
"log" |
Hook errors are logged via logging.getLogger("pyhooky") and dispatch continues with the next hook. |
PriorityChange always flows through normal priority-change handling regardless of policy. around hooks are not covered — their exceptions are the wrapped function's outcome (and trigger on_error).
Threads
The registry uses a re-entrant lock around register/remove/clear and the per-call snapshot, so concurrent registration and dispatch are safe. Hook bodies run outside the lock — user code is free to block, re-enter, or call back into the registry.
Multiprocessing
Not supported. Each process has its own registry; runtime registrations don't cross the boundary. With fork start method on POSIX, child processes inherit the parent's registry state; with spawn (the Windows default and Python's default from 3.14 onward), workers re-import your code and re-run any module-level @hook / @on registrations — runtime-only registrations don't survive.
Use a private HookRegistry() instead of the module default when you need isolation (tests, plugins, multi-tenant code).
Development
uv sync
uv run pytest
uv run ruff check .
uv run pyrefly check
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyhooky-0.1.0.tar.gz.
File metadata
- Download URL: pyhooky-0.1.0.tar.gz
- Upload date:
- Size: 22.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
277d34f719920f001dec0b64ee9f8a3ffa37a3c5fa93081cb9e3906dc20daac4
|
|
| MD5 |
a682539182e276d70d96bced31399618
|
|
| BLAKE2b-256 |
1e9a5e958acd82e03d217e7a9489b6cc537305af29c2ca10ea67df1b0fad9ed4
|
Provenance
The following attestation bundles were made for pyhooky-0.1.0.tar.gz:
Publisher:
release.yml on KilianSen/pyHooky
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyhooky-0.1.0.tar.gz -
Subject digest:
277d34f719920f001dec0b64ee9f8a3ffa37a3c5fa93081cb9e3906dc20daac4 - Sigstore transparency entry: 1615230714
- Sigstore integration time:
-
Permalink:
KilianSen/pyHooky@d7571a369043ccb70c36f06cbaa3094c8094e80e -
Branch / Tag:
refs/heads/master - Owner: https://github.com/KilianSen
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d7571a369043ccb70c36f06cbaa3094c8094e80e -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyhooky-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pyhooky-0.1.0-py3-none-any.whl
- Upload date:
- Size: 24.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
af458bf85b26cb052471937c516d63303d8e232508c7db04ab716252e45fb94e
|
|
| MD5 |
4f7d03743db9a9bdf8d515c4e48b26be
|
|
| BLAKE2b-256 |
7c397d6bf415bb88044a95ec70eefca91f34b2b2476bc96dfa46e61eb7c48d8f
|
Provenance
The following attestation bundles were made for pyhooky-0.1.0-py3-none-any.whl:
Publisher:
release.yml on KilianSen/pyHooky
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyhooky-0.1.0-py3-none-any.whl -
Subject digest:
af458bf85b26cb052471937c516d63303d8e232508c7db04ab716252e45fb94e - Sigstore transparency entry: 1615230775
- Sigstore integration time:
-
Permalink:
KilianSen/pyHooky@d7571a369043ccb70c36f06cbaa3094c8094e80e -
Branch / Tag:
refs/heads/master - Owner: https://github.com/KilianSen
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d7571a369043ccb70c36f06cbaa3094c8094e80e -
Trigger Event:
push
-
Statement type: