How to wire PluginForge into a FastAPI application from scratch. Target audience: an application developer integrating PluginForge for the first time. Code blocks are runnable; copy them into your own project and adapt.
This guide tracks v0.9.0. For the design rationale behind individual
features, see the design documents under docs/design/.
-
Python 3.11 or later.
-
A FastAPI application (optional but covered here, as it is the common case).
-
PluginForge installed:
pip install pluginforge
FastAPI is not a required runtime dependency. Install it separately if you want plugin route mounting.
The smallest useful integration: construct a PluginManager,
discover plugins, mount their routes onto a FastAPI app.
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pluginforge import PluginManager
@asynccontextmanager
async def lifespan(app: FastAPI):
manager = PluginManager(
config_path="config/app.yaml",
app_id="my-app",
app_version="1.0.0",
)
result = manager.discover_plugins()
manager.mount_routes(app, prefix="/api")
app.state.plugin_manager = manager
app.state.discovery_result = result
try:
yield
finally:
manager.deactivate_all()
app = FastAPI(lifespan=lifespan)Five things to notice:
- Lifespan ownership. The
PluginManageris created insidelifespanand explicitly deactivated on shutdown. Keeping the construction inside the lifespan keeps the lifecycle tied to the app instance. app_idandapp_versionare the v0.6.0 / v0.7.0 gating inputs. Pass them so plugins targeting your application activate and incompatible plugins filter out.config_pathpoints at your app.yaml. Missing file means empty config (no error).mount_routesis opt-in. Call it once per app instance after discovery completes. It is idempotent in v0.8.0+: calling it again with the same plugin list is a no-op.app.state.plugin_manageris the conventional handle so endpoints downstream can reach the manager. Optional but useful.
The config/app.yaml PluginForge reads at construction:
app:
name: "My App"
version: "1.0.0"
default_language: "en"
plugins:
entry_point_group: "myapp.plugins"
enabled: ["export", "kdp", "comics"]
disabled: []app.default_languageseeds the i18n layer.plugins.entry_point_groupis the entry-point group PluginForge iterates during discovery. Choose a unique name for your app.plugins.enabledis a positive list. Empty / missing means "all discovered plugins activate."plugins.disabledis a deny-list and takes precedence overenabled.
Per-plugin configuration lives in
config/plugins/{plugin_name}.yaml. The plugin's config_schema
class attribute (if defined) validates the structure at
initialization time.
PluginManager.get_last_discovery_result() returns the most recent
DiscoveryResult. The shape has everything a settings UI needs:
@app.get("/api/admin/plugins")
def list_plugins() -> dict:
manager = app.state.plugin_manager
result = manager.get_last_discovery_result()
if result is None:
return {"plugins": []}
return {
"plugins": [
{
"name": name,
"activated": state.activated,
"filter_reason": state.filter_reason,
}
for name, state in result.states.items()
],
"errors": [
{
"plugin": err.name,
"phase": err.phase,
"severity": err.severity,
"message": err.user_facing_message,
}
for err in result.errors
],
}For the v0.7.0 application-identity contract, the v0.7.0 deprecation
warning has been retired in v0.9.0. Plugins that have not declared
target_application now filter at activation when the host has
adopted app_id (filter_reason == "missing_target_application",
severity == "error"). The user-facing message references the host's
app_id and tells the plugin author what to declare.
Warning-severity entries from other phases (e.g. api_version
mismatch with default api_version_severity="warning") continue to
appear. Filter by both severity and phase when distinguishing.
Threading get_plugin / get_last_discovery_result / get_plugin_hooks
/ plugin.health() / plugin.config was the v0.6.x pattern. v0.9.0
adds inspect_plugin(name) -> PluginInspection | None for a single-call
snapshot:
@app.get("/api/admin/plugins/{name}")
def plugin_detail(name: str) -> dict | None:
manager = app.state.plugin_manager
inspection = manager.inspect_plugin(name)
if inspection is None:
return None
return {
"name": inspection.name,
"version": inspection.version,
"target_application": inspection.target_application,
"min_app_version": inspection.min_app_version,
"activated": inspection.state.activated,
"activated_at": inspection.state.activated_at.isoformat()
if inspection.state.activated_at else None,
"last_config_change": inspection.state.last_config_change.isoformat()
if inspection.state.last_config_change else None,
"source": inspection.state.source,
"filter_reason": inspection.state.filter_reason,
"config": inspection.config,
"health": inspection.health,
"hooks": inspection.hooks,
"routes": inspection.routes,
"dependencies": inspection.dependencies,
}For plugins filtered before initialization (e.g. wrong host), the
plugin-instance fields carry safe defaults; the state field always
reflects the real outcome.
PluginManager exposes three registration-based event hooks. Each
returns an idempotent deregistration closure. Callbacks fire after the
underlying lifecycle step completes successfully; failures inside a
callback are logged at WARNING and swallowed.
def on_startup(manager: PluginManager) -> None:
# Emit metrics on every successful activation.
manager.on_plugin_activated(
lambda name: metrics.increment("plugin.activated", tags={"name": name})
)
# Invalidate a cached settings UI on every config refresh.
def invalidate_settings(name: str, old: dict, new: dict) -> None:
cache.delete("settings_ui_snapshot")
manager.on_config_refreshed(invalidate_settings)Callbacks registered during another callback's execution are deferred
to the next event firing (subscriber list snapshot at dispatch start).
on_plugin_deactivated fires in LIFO order during deactivate_all.
PluginForge ships reload_plugin(name) for entry-point-installed
plugins:
@app.post("/api/admin/plugins/{name}/reload")
def reload(name: str) -> dict:
manager = app.state.plugin_manager
ok = manager.reload_plugin(name)
return {"reloaded": ok}reload_plugin deactivates the plugin, re-imports its module, then
re-initialises and re-activates. Useful during development; in
production it is more common to rediscover the entire plugin set:
@app.post("/api/admin/rediscover")
def rediscover() -> dict:
manager = app.state.plugin_manager
diff = manager.rediscover()
manager.mount_routes(app, prefix="/api") # idempotent
return {
"added": diff.added,
"removed": diff.removed,
"unchanged": diff.unchanged,
}Calling mount_routes after rediscover is safe: the idempotency
fix in v0.8.0 means only newly added plugins get their routes
mounted on this call.
PluginManager.health_check() aggregates plugin.health() across
all active plugins:
@app.get("/api/health")
def health() -> dict:
manager = app.state.plugin_manager
return {
"app": "ok",
"plugins": manager.health_check(),
}Each plugin can override BasePlugin.health() to return its own
dict; the default is {"status": "ok"}. If a plugin's health()
raises, the aggregator catches it and substitutes
{"status": "error", "error": "<exception text>"} so a single bad
plugin does not break the endpoint.
Use the pluginforge.testing namespace (new in v0.8.0) to keep
tests isolated. The key fixture is IsolatedPluginManager:
import pytest
from pluginforge.testing import IsolatedPluginManager, MockPlugin
@pytest.fixture
def manager():
with IsolatedPluginManager(app_id="my-app") as manager:
yield manager
def test_my_endpoint(manager) -> None:
mock = (
MockPlugin.builder()
.name("test_plugin")
.target_application("my-app")
.routes([("GET", "/ping")])
.build()
)
manager.register_plugin(mock)
# Now exercise endpoints that depend on the plugin being active.IsolatedPluginManager clears the module-level
pluginforge.fastapi_ext._mounted_plugins tracking on entry and
exit. This is what keeps tests independent in long pytest sweeps
that mount routes on a shared FastAPI app. The Bibliogon-reported
P0 recursion cascade was a symptom of this state leaking between
tests in PluginForge v0.7.0.
MockPlugin synthesizes lightweight BasePlugin subclasses without
making you write a full subclass per test.
- Idempotent
mount_routes. No behavior change for first calls. Subsequent calls with the same plugin list are now no-ops. If your app deliberately mounted the same plugin twice for any reason (unlikely), surface the use case. pluginforge.testingis new. If you have a homegrown isolated-manager helper, consider adoptingIsolatedPluginManagerto share the mount-state-cleanup discipline.- Multi-router warning. If you have plugins returning more than
one router from
get_routes(), you will see aDeprecationWarningin v0.8.0 through v0.10.0. Earliest error transition is v0.11.0. See the plugin-author guide for the recommended migration pattern. py.typed. Downstream type-checkers (mypy, pyright) now consume PluginForge type annotations. If you have# type: ignorecomments suppressing PluginForge imports, you can drop them.
- Hard-filter transition. Plugins that have not declared
target_applicationare now filtered at activation when your host hasapp_idset, withfilter_reason == "missing_target_application". This is the long-promised v0.7.0 contract. If you ship plugins, addtarget_applicationto yourBasePluginsubclasses now. If you host plugins and have downstream plugin authors who haven't migrated yet, surface the filter to them via the settings UI. - v0.7.0 deprecation warning is gone. Code that filtered
result.errorsbyseverity == "warning" and phase == "identity_check"will see no such entries in v0.9.0. Either the plugin filtered out (nowseverity == "error") or both sides remained undeclared and the plugin activated silently. inspect_plugin(name)is the new aggregator. Prefer it to threading multiple accessors. ReturnsNonefor unknown names.- Event hooks are opt-in. A manager with no registered callbacks
has zero overhead. If you want to react to activation /
deactivation / config refresh, register callbacks via
on_plugin_activated/on_plugin_deactivated/on_config_refreshedand keep the returned deregistration closure for cleanup. - New
PluginStatefields.activated_at,last_config_change,sourceare additive (defaultNone). Existing code readingPluginStateis unaffected; settings UIs that want timestamps and the discovery route can opt in.
- PluginManager construction: one site, pass all 7 params explicitly (config_path, pre_activate, api_version, app_version, api_version_severity, app_version_severity, app_id)
- mount_plugin_routes: call once per app instance, idempotent since v0.8.0
- Common mistakes: forgetting app_id, not filtering result.errors by severity, calling mount_plugin_routes before discover_plugins
- Key files: pluginforge/manager.py (core), pluginforge/fastapi_ext.py (FastAPI integration), pluginforge/state.py (types)
- Public API:
pluginforge/__init__.py__all__. - Architecture:
docs/ARCHITECTURE.md. - Per-release design rationale:
docs/design/. - CHANGELOG:
CHANGELOG.md. - Plugin-author guide: docs/guides/plugin-author.md.