Plugin guide¶
Status: Phase 3. The plugin API is preview API and lives in the core package — no extra is needed.
Trust boundary¶
Plugins are trusted, in-process Python code. Adding a plugin is equivalent to importing a library: it runs with the full privileges of your process. Xyberos provides no sandbox, no permission model, and no protection from a plugin that misbehaves, and it makes no attempt to limit what a plugin can do with the objects it is handed. Only add plugins you trust.
Entry-point discovery is deliberately not implemented. Application code chooses plugins
explicitly with app.add_plugin(...), so startup stays visible and reproducible.
The contract¶
Subclass xyberos.Plugin:
from dataclasses import dataclass
from xyberos import App, Plugin
@dataclass(frozen=True)
class GreetingConfig:
greeting: str = "hello"
excited: bool = False
class GreetingPlugin(Plugin[GreetingConfig]):
name = "greeting" # required, unique, an identifier
api_version = "1" # the plugin API version this plugin targets
requires = () # plugin names that must be registered first
config_type = GreetingConfig # optional dataclass configuration schema
def register(self, app: App, config: GreetingConfig) -> None:
app.provide(Greeter, instance=Greeter(config))
app.get("/greet")(self._greet)
async def start(self) -> None: # optional
...
async def stop(self) -> None: # optional
...
app = App()
app.add_plugin(GreetingPlugin())
| Member | Required | Purpose |
|---|---|---|
name |
yes | unique plugin name; also its configuration key |
api_version |
no | plugin API version the plugin targets; must match the runtime major version |
requires |
no | other plugins that must already be registered |
config_type |
no | frozen dataclass describing the plugin's settings |
register |
yes | add providers and routes while the application is still mutable |
start |
no | prepare plugin-owned resources after app-scoped providers are constructed |
stop |
no | release plugin-owned resources before app-owned resources are closed |
Plugin is an abstract base class, so a missing register fails at import time rather than at
startup. A plugin can only be added before app.start().
Ordering¶
A plugin's requires must already be registered when it is added, so registration order is
dependency order and startup order is deterministic. A missing requirement fails immediately with
a message naming the plugin and the missing dependency; requirement cycles are impossible.
Lifecycle integration, in order:
app.add_plugin(plugin)— validate metadata, build typed configuration, runregister.app.start()— validate the provider graph, construct eager app-scoped providers, then start plugins in registration order.app.stop()— stop plugins in reverse order, then close app-owned resources in reverse order. A failingstophook is collected and reported throughShutdownError.resourcesasplugin:<name>, and every remaining hook still runs.
If a plugin fails to start, the plugins that already started are stopped in reverse order, the
application moves to FAILED, and the error names the plugin and the phase:
The original exception is preserved as the cause. If cleanup also fails, the message names the
plugins whose stop hook failed.
Configuration¶
Plugin settings use the same precedence as core settings: the explicit mapping passed to
app.add_plugin, then the environment, then the configuration file, then the dataclass defaults.
- Settings sections are matched case-insensitively and normalized to lowercase.
- Supported field types are
str,int,float,bool, and their| Noneforms.intandfloataccept numeric strings, andboolacceptstrue/false/yes/no/on/off/1/0, which is what environment variables provide. - Unknown keys, missing required settings, and invalid values raise
PluginConfigurationErrornaming the plugin and the setting location — never the configured value. - A plugin that declares no
config_typeand is given configuration is rejected, so a typo in a settings section cannot pass silently.
Plugin API compatibility¶
PLUGIN_API_VERSION is the version of the plugin contract this runtime implements. A plugin
declares the version it targets; the major component must match, so "1" and "1.4" are
compatible with a runtime at PLUGIN_API_VERSION = "1". A mismatch raises
PluginCompatibilityError during add_plugin, before register runs and before any startup
work begins.
Providers, replacements, and diagnostics¶
A plugin registers providers exactly as application code does: app.provide(...). A duplicate
registration fails, so a plugin cannot silently replace an existing provider. Replacement is
explicit — application configuration calls app.provide(..., replace=True), and tests use
app.with_overrides({...}).
Providers registered from a plugin's register hook are attributed automatically, and
app.diagnostics() reports the source of every active provider plus the plugin order:
providers:
Recorder [app] from application
Metrics [app] from plugin:metrics
Repository [app] from test override
plugins:
metrics 1 [registered] requires: -
summary 1 [registered] requires: metrics
Routes¶
Plugins contribute routes through the same App route API the application uses
(app.get, app.post, app.route). Route records are compiled by the HTTP adapter, so a plugin
that adds routes needs the http extra installed in the application, and a plugin that adds only
services needs nothing beyond core. Route registration is rejected after app.start(), so a
plugin cannot add or change routes once the application is ready.
Testing plugins¶
app.with_overrides({...})isolates provider values. A test clone copies providers and routes but not plugin registrations, so plugin lifecycle hooks do not run twice across clones.- A plugin package should be tested as a separately distributed package. The suite includes one at
tests/fixtures/metrics_plugin, andtests/integration/test_plugin_packages.pyasserts that it imports only publicxyberosnames.
The catalogue¶
There is no plugin registry service and no discovery: a plugin is a package you install and add
explicitly, and xyberos never looks for one (ADR 0007). That is a
supply-chain decision — entry-point loading means a dependency can change what your process does at
import time — but it does not mean extensions are invisible. This section is the list, maintained by
hand.
Known extensions. None are published yet. The reference implementations in this repository are:
| Package | What it shows |
|---|---|
tests/fixtures/metrics_plugin |
a separately distributed plugin, imported by the test suite as a package and asserted to use only public xyberos names |
examples/custom_plugin |
a plugin written inside an application: a service, a route, and typed configuration |
A database backend for an engine other than SQLite is a package too — see writing a database provider.
Publishing one. A plugin package needs very little:
mycompany-xyberos-metrics/
├── pyproject.toml # depends on xyberos >= the release you target
├── src/mycompany_metrics/
│ ├── __init__.py # exports the Plugin subclass
│ └── plugin.py # name, api_version, config_type, register
└── tests/
└── test_plugin.py # imports xyberos, never an internal module
Four rules make a plugin safe to depend on:
- Declare
api_versionand keep the major component honest."1"means "I only use what the version-1 plugin contract promises"; a runtime with a different major refuses to load you, beforeregisterruns. - Import only public names.
xyberos,xyberos.plugins, and the documentedxyberos.*modules — never an internal module. The fixture plugin's test is the pattern to copy: assert that every module your plugin imports is in the public surface, so an internal change surfaces as your test failing rather than your users breaking. - Validate your own configuration.
config_typeis checked by the runtime and unknown keys are rejected, so a typo fails at startup instead of being ignored. - Do not reach for the application in
start. It is notREADYyet; register an eager provider whose factory does the work, or do it inregister(existing limitation).
To be listed here, open an issue with a link to the repository and one sentence on what the plugin contributes. The only requirement is that it passes its own tests against a released version.
Deliberate limitations¶
- No plugin sandbox, permission model, or isolation of untrusted code.
- No entry-point discovery; plugins are added explicitly.
- A plugin cannot resolve application services from its
starthook, because the application is notREADYyet. Register an eager provider whose factory performs that work instead, or do it inregister. - Plugins cannot be added, removed, or replaced after
app.start().