Composition from a file
An application as a YAML list
Everything so far mounted plugins in Python. For an application whose shape differs per deployment, the list of plugins is itself configuration.
A plugin as a module
A module is a plugin when it has apply(ctx, config). It may also carry name and inject:
# myapp/database.py
name = "database"
class Database:
def __init__(self, dsn: str) -> None:
self.dsn = dsn
def apply(ctx, config=None):
ctx.provide("database", Database((config or {}).get("dsn", "sqlite://")))# myapp/greeter.py
name = "greeter"
inject = ["database"]
def apply(ctx, config=None):
prefix = (config or {}).get("prefix", "hello")
ctx.provide("greeter", lambda who: f"{prefix} {who} via {ctx.database.dsn}")The file
# app.yml
- id: db
name: myapp.database
config:
dsn: postgres://prod
- id: greet
name: myapp.greeter
config:
prefix: hiname is an importable module path. id labels the entry. config becomes the plugin’s second argument.
Loading it
from plugkit import Context, load_app
root = Context()
await load_app(root, "app.yml")
root.greeter("world") # 'hi world via postgres://prod'Order in the file means nothing
Put the greeter first, before the database it needs:
- id: greet
name: myapp.greeter
- id: db
name: myapp.databaseThe result is identical. inject decides activation, so the greeter waits until the database exists. There is no boot sequence to get right, which is the point of listing plugins rather than calling them in order.
A missing dependency is not an error
Delete the database entry entirely:
- id: greet
name: myapp.greeterNothing raises. greeter stays PENDING and is not available. That is the correct behaviour for an optional feature whose backing service is not deployed in this environment.
await load_app(root, "app.yml")
"greeter" in root # FalseNaming an attribute
A module holding several plugins can name one directly:
- name: myapp.plugins:databaseThat imports myapp.plugins and takes database off it.
Substituting a plugin
FileLoader.register resolves a name without importing:
from plugkit import FileLoader
await root.plugin(FileLoader)
root.loader.register("myapp.database", fake_database_module)
await root.loader.create({"name": "myapp.database"})register() applies to entries created through loader.create(). Entries listed in a YAML file resolve through the include’s own tree and fall through to the import path, so registering myapp.database will not substitute the myapp.database line in app.yml.
To substitute a plugin the file names, give a module that name and put it on the import path ahead of the real one.
What this is under the hood
load_app is three steps:
await ctx.plugin(FileLoader) # provides ctx.loader
await settle() # let its child fibers register listeners
loader.register("__include__", Include)
await loader.create({"name": "__include__", "config": {"path": path}})The settle() is required. The loader mounts child fibers that register loader/entry-init, and creating an entry before those exist raises KeyError('_isolate') — entry-init is what puts the isolate map on the entry’s context. load_app handles it; the note is here because a hand-rolled version hits it.
Next
Testing — what components being plain objects buys you.