Lightweight async-friendly hook/plug-in manager with tags, priorities, and reducers.
Project description
Hookedin
Lightweight, async‑friendly hook and plug‑in manager with tags, priorities, and reducer‑based result merging. Designed for clean composition of middleware, event pipelines, and extension points.
Batteries included: decorators and programmatic registration, tag filtering, stable priority ordering, async concurrency, looped handlers, reducer‑based dict merges, metrics sink, and easy introspection.
Installation
pip install hookedin
For development:
pip install -e .[dev]
pytest -v
Python 3.9 or newer.
Quick start
from hookedin import get_hook_manager, Behavior, get_reducer_manager
h = get_hook_manager() # get a manager instance
# Register with a decorator
@h.on("message", tags=["audit"], priority=10)
def audit(ctx=None):
# ctx is the dict payload for dict inputs
return {"seen": True, "shared": 1}
# Register async handler
@h.on("message", priority=0)
async def do_work(ctx=None):
# this one runs before audit due to priority=0
return {"ok": True, "shared": 2}
# Trigger in parallel and merge dicts using a reducer
result = await h.trigger(
"message",
payload={"start": True, "shared": 0},
parallel=True,
reducer="last_wins", # or "first_wins", "sum_numbers", or a custom reducer
)
print(result) # {'start': True, 'seen': True, 'ok': True, 'shared': 2}
Prefer
trigger()when you want a final dict. Usegather()to get a list of detailed results. Usefire()for fire‑and‑forget semantics.
Core concepts
Hooks and handlers
- A hook is a named event channel like
"on_connect"or"message". - A handler is any sync or async callable you register to a hook.
- Register with a decorator or programmatically.
# decorator
@h.on("reg", tags=["red"], priority=1)
def decorated(payload=None):
return "ok"
# programmatic
async def async_handler(ctx=None):
return {"mark": "async"}
tok = h.add(async_handler, "reg", priority=0)
Each registration returns a token you can use to manage the entry later.
Priority and order
- Lower numeric priority runs earlier.
- Equal priorities are stable by registration sequence.
order = []
@h.on("prio", priority=0)
def a(payload=None): order.append("a")
@h.on("prio", priority=0)
def b(payload=None): order.append("b")
await h.fire("prio")
assert order == ["a", "b"]
# Raise b to the front
h.change_priority(h.token_of(b), -10)
order.clear(); await h.fire("prio")
assert order == ["b", "a"]
Tags and filtering
- Handlers can have zero or more string tags.
- When firing, you can filter by tags and include or exclude untagged handlers.
@h.on("t", tags=["red", "fast"])
@h.on("t", tags=["red"])
@h.on("t") # untagged
async def _(...): ...
# Only tag‑matched
await h.gather("t", tags={"red"}, include_untagged=False)
# Tag‑matched plus untagged
await h.gather("t", tags={"red"}, include_untagged=True)
# Update tags later
tok = h.token_of(_)
h.add_tags(tok, ["red"]) # now matches
h.remove_tags(tok, ["red"]) # no longer matches
Execution modes
fire()– run handlers without collecting values. Errors bubble only ifstrict=True.gather()– run handlers and collectHookResultobjects with.value,.ok,.error,.elapsed_ms, and.entry.trigger()– run handlers and merge the dict outputs into a single dict using a reducer. This is ideal for middleware‑style edits.
# Strict error propagation
@h.on("boom")
def boom(payload=None):
raise RuntimeError("boom")
with pytest.raises(RuntimeError):
await h.fire("boom", strict=True)
Common keyword arguments for execution methods
payload– The data passed to handlers. If it’s adict, handlers receive it asctx. Otherwise it is passed aspayload.tags– A set of tags to filter which handlers run.include_untagged– Whether to include untagged handlers when filtering by tags (defaultTrue).parallel– IfTrue, handlers run concurrently instead of sequentially.strict– IfTrue, exceptions in handlers are re‑raised immediately; otherwise errors are captured in the result objects.unique_inputs– IfTrue(only valid withparallel=True), each handler receives its own copy of the payload, preventing shared mutation.reducer– Only fortrigger(). Chooses how multiple dict results are merged (last_wins,first_wins,sum_numbers, or custom).**extra– Any additional keyword arguments are forwarded to handlers as named arguments, making it easy to inject context likeuser_id=123.
This flexibility makes fire, gather, and trigger suitable for a wide range of use cases, from simple event dispatch to complex middleware pipelines.
Parallelism and reducers
-
Set
parallel=Trueto run handlers concurrently. -
Choose how dict outputs merge:
"last_wins"– later handlers override earlier ones"first_wins"– first value wins"sum_numbers"– numeric values are summed, others use last wins
-
Provide a custom reducer as a name you registered or as a callable.
mgr = get_reducer_manager()
def my_merge(base: dict, edits: list[dict]) -> dict:
out = base.copy()
out["sum_b"] = sum(d.get("b", 0) for d in edits)
return out
mgr.register_reducer("my_merge", my_merge, overwrite=True)
merged = await h.trigger("red", payload={"b": 0}, parallel=True, reducer="my_merge")
Payload passing
- If the payload is a dict, it is given to handlers as
ctxto encourage structured edits. - If the payload is not a dict, it is passed as
payload. - Set
unique_inputs=Truewithparallel=Trueto give each handler its own deep copy so the caller’s input is not mutated by handlers.
def uses_ctx(ctx=None): # receives dict
ctx["mutated"] = True
# sequential allows mutation of the original dict
original = {"k": 1}
await h.trigger("u", payload=original, parallel=False)
assert "mutated" in original
# parallel unique inputs preserve the caller’s dict
original2 = {"k": 2}
await h.trigger("u2", payload=original2, parallel=True, unique_inputs=True)
assert "mutated" not in original2
Looped handlers
- Handlers can run in a loop with
behavior=Behavior.LOOPand anintervalin seconds. - Start and stop loops across the manager. You can toggle or reschedule a loop by token.
@h.on("heartbeat", behavior=Behavior.LOOP, interval=0.50)
def tick(payload=None):
print("tick")
await h.start_loops()
...
# pause then resume a specific loop entry
h.toggle(h.token_of(tick), toggle_amounts=1); h.toggle(h.token_of(tick), toggle_amounts=1)
# change its interval
h.reschedule(h.token_of(tick), 0.25)
...
await h.stop_loops()
Introspection and management
token_of(fn)– get the token of a decorated handler.has(token)– check presence.remove(token)orremove_by_callback(fn)– remove handlers.list_entries(name)– get entries registered to a hook.find_tokens(name)– get tokens under a hook.count(name, tags={...})– count entries by hook and optional tags.info(token, debug=False)– view a dict of entry properties.
Metrics
Provide a sink to observe each handler’s timings and outcome. Great for logging and dashboards.
stats = []
def sink(m):
stats.append({
"hook_name": m.hook_name,
"ok": m.ok,
"elapsed_ms": m.elapsed_ms,
"callback": getattr(m.entry.callback, "__name__", "anon"),
"error": type(m.error).__name__ if m.error else None,
})
h.set_metrics_sink(sink)
await h.fire("myhook")
The metrics object includes at least: hook_name, entry, ok, error, elapsed_ms.
API summary
Signatures are shown in a friendly form. Types may be more specific in code.
Registration
on(hook_name, *, tags=None, priority=0, behavior=Behavior.DEFAULT, interval=None, on_fail=None)– decoratoradd(callback, hook_name, *, tags=None, priority=0, behavior=Behavior.DEFAULT, interval=None, on_fail=None) -> token
Execution
fire(name, *, payload=None, tags=None, include_untagged=True, parallel=False, strict=False, **extra)gather(name, *, payload=None, tags=None, include_untagged=True, parallel=False, strict=False, unique_inputs=None, **extra) -> list[HookResult]trigger(name, *, payload: dict, tags=None, include_untagged=True, parallel=False, reducer="last_wins", unique_inputs=None, **extra) -> dict
Reducers
- Built‑ins:
"last_wins","first_wins","sum_numbers" get_reducer_manager().register_reducer(name, func, overwrite=False)
Looping
start_loops()– start all loop entriesstop_loops()– stop themtoggle(token, toggle_amounts=1)– enable or disable a specific entryreschedule(token, interval)– change loop interval
Introspection and edit
token_of(callback) -> token | Nonehas(token) -> boolremove(token) -> boolremove_by_callback(callback) -> boolchange_priority(token, new_priority) -> booladd_tags(token, tags: Iterable[str]) -> boolremove_tags(token, tags: Iterable[str]) -> boollist_entries(hook_name) -> list[Entry]find_tokens(hook_name) -> list[token]count(hook_name, tags=None) -> intinfo(token, debug=False) -> dict
Metrics
set_metrics_sink(callable)– receive per‑handler timing and status
Factory and module exports
get_hook_manager()– create or return a manager instanceBehavior– behaviors:DEFAULT,ONESHOT,LOOPget_reducer_manager()– reducer registry for merges
Custom reducers
Reducers take (base: dict, edits: list[dict]) -> dict and return a new dict.
from hookedin import get_reducer_manager
mgr = get_reducer_manager()
def only_truthy(base, edits):
out = base.copy()
for d in edits:
for k, v in d.items():
if v:
out[k] = v
return out
mgr.register_reducer("only_truthy", only_truthy, overwrite=True)
Use by name in trigger(..., reducer="only_truthy") or pass the function.
Error handling
- By default errors are captured in
HookResult.errorand do not stop other handlers. - Set
strict=Trueinfire()orgather()to re‑raise the first error. - You can also provide per‑entry
on_failcallbacks when registering if you prefer local handling.
Patterns
Middleware style edits
Group ordered steps that progressively transform a dict, then reduce the outputs into a final view.
Feature flags and tags
Tag handlers with features or environments, then filter at call time.
Background ticks
Use Behavior.LOOP for lightweight heartbeats. Example: emit periodic metrics or refresh caches.
Versioning and stability
- Follows semver. Breaking changes increase the major version.
- No runtime dependencies.
Contributing
Issues and pull requests are welcome. Please include tests where possible.
Contact
Created by Kevin d'Anunciacao.
Feel free to reach out via email or open an issue on GitHub.
License
MIT License. See LICENSE for details.
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 hookedin-1.0.0.tar.gz.
File metadata
- Download URL: hookedin-1.0.0.tar.gz
- Upload date:
- Size: 21.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
78ee22af5b4cf985ed31e14f1683e7aed24a5f1ea22ef5022ec383fb2b6e4a64
|
|
| MD5 |
9a1e578400f7b73165a5c495873b7ec5
|
|
| BLAKE2b-256 |
75c44b7257c2559688275318f8ce0d260ec8447013a32c01ea281f642fe8429a
|
File details
Details for the file hookedin-1.0.0-py3-none-any.whl.
File metadata
- Download URL: hookedin-1.0.0-py3-none-any.whl
- Upload date:
- Size: 19.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
decc4cb1b7c3d7af37a77ecc86d0e5b0ad71b26133682cf2d81dae6ccea713a9
|
|
| MD5 |
9c316243cd825e848827d81fe961ce37
|
|
| BLAKE2b-256 |
b2b33713d780e7b66fdca548170d7f7caf42154e05f81aea0f2a9081cfbdd9d8
|