Your first plugin
inject, effects, and watching unload be total
A plugin is a function
That is the whole contract. It takes a context, it does things, and it returns a disposer if it has anything to undo.
def hello(ctx, config=None):
print("hello")Mount it:
import asyncio
from plugkit import Context
async def main():
root = Context()
await root.plugin(hello)
asyncio.run(main())Context() is the root — the top of the tree. root.plugin(fn) mounts your function and returns a fiber, the object representing that mounted plugin’s lifetime.
inject decides when it runs
Real plugins need things. Say what, and the kernel handles the rest:
def greeter(ctx, config=None):
print(ctx.database.query("SELECT 1"))
greeter.inject = ["database"]Now greeter does not run when you mount it. It runs when a service named database exists — which might be immediately, might be later, might be never.
fn.inject = [...] is the primitive spelling, used here because it shows the list plainly. It runs correctly and pyright rejects it — you cannot assign arbitrary attributes to a function. Chapter 2 introduces @plugin, which carries the list and can derive it from a Protocol, and provide(), which derives it from needs. Both are what you use in real code.
async def main():
root = Context()
await root.plugin(greeter) # nothing printed: no database yet
await root.plugin(provide(Database, "database"))
# greeter runs hereReading a name absent from inject raises, even when the service exists:
def sneaky(ctx, config=None):
ctx.database.query("...") # AttributeError: cannot get property
# "database" without injectThis is not pedantry. The kernel decides when to reload your plugin by tracking the identity of the fibers providing your injected services. A dependency it cannot see is a dependency whose replacement will not reload you — so you would be left holding a stale object with no way to find out. Refusing the read is what makes the guarantee trustworthy.
Ordering does not matter. Mount greeter first or Database first; the result is the same. There is no boot sequence to get right, because activation is driven by availability rather than by position.
Effects: registration with an undo
Anything your plugin sets up, it should be able to tear down. Rather than remembering to, you register it as an effect:
def watcher(ctx, config=None):
def start():
handle = open_file_watcher("/tmp")
return handle.stop # <- the undo
ctx.effect(start, "file-watcher")ctx.effect(fn, label) calls fn immediately. Whatever fn returns is kept by your fiber as the undo, and is called on unload.
Disposers are started in reverse registration order, and then run concurrently (fiber.py uses asyncio.gather). With synchronous disposers that produces exact reverse order, because each finishes before the next begins. An async disposer yields at its first await, so a later one can finish first: completion order is decided by how long each takes, not by registration order.
If two teardown steps must happen in sequence, put both in one disposer and await them there. Do not rely on ordering between separate effects.
For a single teardown you can skip the ceremony and just return it:
def watcher(ctx, config=None):
handle = open_file_watcher("/tmp")
return handle.stopListening to events
ctx.on registers a listener. It is an effect, so it comes with its own undo:
def logger_plugin(ctx, config=None):
ctx.on("user/created", lambda user: print(f"new user: {user}"))You do not need to unregister it. When logger_plugin unloads, the listener goes with it. Not “should go” — goes, because the fiber holds the disposer and calls it.
A listener’s parameters are exactly the event’s arguments. If you need to know who dispatched an event, this_() gives you the carrier:
from plugkit import this_
def audit(ctx, config=None):
ctx.on("internal/status", lambda fiber, old: print(fiber.name, old, this_()))Watch unload be total
Run this one. The behaviour is easier to believe from output than from prose:
import asyncio
from plugkit import Context, provide
log = []
class Database:
def close(self):
log.append("database closed")
def feature(ctx, config=None):
log.append("feature started")
def acquire(n):
def start():
log.append(f"acquired {n}")
return lambda: log.append(f"released {n}")
return start
ctx.effect(acquire(1))
ctx.effect(acquire(2))
ctx.on("ping", lambda: log.append("pong"))
return lambda: log.append("feature stopped")
feature.inject = ["database"]
async def main():
root = Context()
fiber = await root.plugin(feature)
print(log) # [] <- gated on the database
db = await root.plugin(provide(Database, "database"))
await fiber # wait for `feature` itself to finish loading
print(log) # ['feature started', 'acquired 1', 'acquired 2']
root.emit("ping")
print(log[-1]) # 'pong'
log.clear()
await db.dispose() # take the database away
print(log) # ['feature stopped', 'released 2', 'released 1',
# 'database closed']
root.emit("ping")
print(log[-1]) # still 'database closed' — the listener is gone
asyncio.run(main())The await fiber on line 6 is necessary. await root.plugin(...) waits for the plugin it mounted, not for other plugins that mount unblocked. When the database mount returns, feature is still LOADING. Awaiting its own fiber waits for it.
await db.dispose() needs no equivalent, because dispose waits for the unload it cascades to dependents.
Three things happened that nobody wrote code for:
- Disposing the database unloaded the feature, because the feature declared it.
- The feature’s effects released in reverse order — 2 before 1. Both are synchronous here, so reverse order is exact.
- The feature’s event listener stopped receiving events, without an unregister call anywhere.
The six states
A fiber is always in one of these. You rarely look, but knowing they exist makes the behaviour above stop feeling like magic:
| State | Meaning |
|---|---|
PENDING |
mounted, waiting for an injected service |
LOADING |
the plugin function is running |
ACTIVE |
loaded, its services available to others |
FAILED |
the plugin function raised |
UNLOADING |
disposers are running |
DISPOSED |
gone for good, cannot restart |
Read the current state from the fiber:
from plugkit import FiberState
print(fiber.state is FiberState.ACTIVE)await root.plugin(p) re-raises a startup error
If your plugin function raises, awaiting the mount re-raises it. To observe a failure rather than propagate it, don’t await — check the state:
fiber = root.plugin(might_fail)
try:
await fiber
except RuntimeError:
pass
assert fiber.state is FiberState.FAILEDAwaiting waits for the load transition and then re-raises, so catching the exception leaves you with a settled fiber. Do not substitute await asyncio.sleep(0) — a failed load takes three ticks to reach FAILED, going through UNLOADING on the way, and a test that counts ticks breaks when that changes.
FAILED is also what SupervisorService watches. Mounting it gives failed fibers a restart strategy — one_for_one, one_for_all or rest_for_one, with backoff — built on this state and nothing else.
Next
Plain components — how to keep your own classes free of all of this.