Skip to content

Latest commit

 

History

History
367 lines (300 loc) · 12.6 KB

consumer-integration.md

File metadata and controls

367 lines (300 loc) · 12.6 KB

Consumer-App Integration Guide

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/.

Prerequisites

  • 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 minimal integration

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:

  1. Lifespan ownership. The PluginManager is created inside lifespan and explicitly deactivated on shutdown. Keeping the construction inside the lifespan keeps the lifecycle tied to the app instance.
  2. app_id and app_version are the v0.6.0 / v0.7.0 gating inputs. Pass them so plugins targeting your application activate and incompatible plugins filter out.
  3. config_path points at your app.yaml. Missing file means empty config (no error).
  4. mount_routes is 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.
  5. app.state.plugin_manager is the conventional handle so endpoints downstream can reach the manager. Optional but useful.

App configuration shape

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_language seeds the i18n layer.
  • plugins.entry_point_group is the entry-point group PluginForge iterates during discovery. Choose a unique name for your app.
  • plugins.enabled is a positive list. Empty / missing means "all discovered plugins activate."
  • plugins.disabled is a deny-list and takes precedence over enabled.

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.

Surfacing the discovery result in a settings UI

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.

Aggregating one plugin's full state (v0.9.0)

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.

Reacting to lifecycle events (v0.9.0)

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.

Hot reload

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.

Health checks

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.

Testing your integration

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.

Migration notes for v0.7.0 consumers upgrading to v0.8.0

  • 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.testing is new. If you have a homegrown isolated-manager helper, consider adopting IsolatedPluginManager to share the mount-state-cleanup discipline.
  • Multi-router warning. If you have plugins returning more than one router from get_routes(), you will see a DeprecationWarning in 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: ignore comments suppressing PluginForge imports, you can drop them.

Migration notes for v0.8.0 consumers upgrading to v0.9.0

  • Hard-filter transition. Plugins that have not declared target_application are now filtered at activation when your host has app_id set, with filter_reason == "missing_target_application". This is the long-promised v0.7.0 contract. If you ship plugins, add target_application to your BasePlugin subclasses 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.errors by severity == "warning" and phase == "identity_check" will see no such entries in v0.9.0. Either the plugin filtered out (now severity == "error") or both sides remained undeclared and the plugin activated silently.
  • inspect_plugin(name) is the new aggregator. Prefer it to threading multiple accessors. Returns None for 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_refreshed and keep the returned deregistration closure for cleanup.
  • New PluginState fields. activated_at, last_config_change, source are additive (default None). Existing code reading PluginState is unaffected; settings UIs that want timestamps and the discovery route can opt in.

Quick Reference for AI Agents

  • 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)

Reference

  • 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.