Skip to content

Latest commit

 

History

History
281 lines (214 loc) · 10.6 KB

File metadata and controls

281 lines (214 loc) · 10.6 KB

Plugin-Author Guide

How to write a PluginForge plugin. Target audience: a developer authoring a plugin that runs against a PluginForge-based host application.

Status: Skeleton. Sections marked TODO: expand in v0.9.0 were not expanded in v0.9.0; the expansion is deferred to a later documentation pass. The Single-Router Convention section is complete because of its tie to the P0.5 deprecation warning shipped in v0.8.0.

BasePlugin contract

Every plugin is a subclass of pluginforge.BasePlugin. The host application instantiates your class, calls init(), optionally gates by identity and version, then calls activate(). On shutdown, deactivate() runs in reverse activation order.

Required class attributes:

  • name: str - unique identifier within the host's plugin set. Lowercase, no path separators, max 64 characters (pluginforge.security enforces this).
  • version: str - your plugin's version. Default "0.1.0".
  • api_version: str - PluginForge hook spec compatibility version. Default "1"; bump only when PluginForge releases a new major hook-spec version.

Recommended class attributes:

  • target_application: str | None - identifier of the host application your plugin targets (e.g. "bibliogon"). Required for v0.9.0+ when the host has adopted app_id. See Identity gating.
  • min_app_version: str | None - minimum host version your plugin supports. PluginForge filters at activation if the host's app_version is below this.
  • description: str, author: str - free text for tooling.
  • depends_on: list[str] - other plugins your plugin requires. PluginForge sorts the activation order topologically.

TODO: expand in v0.9.0 - worked example of a full plugin file with all attributes, plus an annotated walkthrough of the activation flow.

Identity gating

Declare target_application to opt into v0.7.0 identity protection:

from pluginforge import BasePlugin


class MyPlugin(BasePlugin):
    name = "myplugin"
    version = "1.0.0"
    target_application = "myapp"  # match the host's app_id
    min_app_version = "0.7.0"

When the host constructs PluginManager(app_id="myapp", ...):

  • A plugin with target_application = "myapp" activates.
  • A plugin with target_application = "otherapp" filters out with FilterReason.wrong_application.
  • A plugin without target_application filters out in v0.9.0+ with FilterReason.missing_target_application (was a deprecation warning in v0.7.0/v0.8.0; v0.9.0 shipped the hard-filter transition).

When the host has not set app_id, identity is not validated and your declaration is ignored. The host opts in by declaring its identity.

TODO: expand in v0.9.0 - migration recipe for plugin authors who shipped without target_application on a v0.6.x host.

Single-Router Convention

Rule: return exactly one router from get_routes(). Nest namespace-separated sub-routers via router.include_router(sub).

# Correct: one parent router, sub-routers nested.
from fastapi import APIRouter

panels_router = APIRouter(prefix="/panels")
panels_router.add_api_route("/", list_panels, methods=["GET"])
panels_router.add_api_route("/{panel_id}", get_panel, methods=["GET"])

bubbles_router = APIRouter(prefix="/bubbles")
bubbles_router.add_api_route("/", list_bubbles, methods=["GET"])


class ComicsPlugin(BasePlugin):
    name = "comics"

    def get_routes(self) -> list:
        parent = APIRouter(prefix="/comics")
        parent.include_router(panels_router)
        parent.include_router(bubbles_router)
        return [parent]
# Discouraged: multiple top-level routers.
class ComicsPlugin(BasePlugin):
    def get_routes(self) -> list:
        return [panels_router, bubbles_router]  # DeprecationWarning in v0.8.0+

Rationale. The discouraged shape compounds with the v0.7.0 route-mount cascade (P0): each top-level router gets its own include_router call on the host's FastAPI app. With many plugins shipping many top-level routers, route accumulation across TestClient lifespans caused RecursionError in long pytest sweeps (see the brief). v0.8.0's idempotent mount fix removes the cascade, but the convention stays in place for ecosystem coherence: a plugin's routes live under one URL namespace per plugin, mountable as one unit.

Deprecation timeline:

  • v0.7.0 and earlier: multiple top-level routers supported, silent.
  • v0.8.0: DeprecationWarning emitted on multi-router plugins.
  • v0.9.0 and v0.10.0: warning continues.
  • v0.11.0 (earliest): may become an error.

Plugin configuration

Per-plugin configuration lives in config/plugins/{your_plugin_name}.yaml on the host. PluginForge loads it during init() and passes it as the second argument.

class MyPlugin(BasePlugin):
    name = "myplugin"
    config_schema = {
        "max_items": int,
        "endpoint_url": str,
    }

    def activate(self) -> None:
        self.max_items = self.config.get("max_items", 100)
        self.endpoint = self.config.get("endpoint_url", "")

config_schema is a dict of {key: expected_type}. PluginForge validates types during init() and raises if a key has the wrong type. Missing keys are not errors; provide defaults in activate().

TODO: expand in v0.9.0 - schema-evolution patterns when adding or removing config keys across plugin versions.

Dependencies between plugins

Declare depends_on to require other plugins to activate before yours:

class ExportPlugin(BasePlugin):
    name = "export"
    depends_on = ["storage"]

PluginForge sorts plugins topologically and activates dependencies first. Cycles raise CircularDependencyError. Missing dependencies filter your plugin out at activation.

Cross-Plugin Communication

PluginForge plugins run in the same Python process and can interact through three patterns, listed in order of preference. For the reference treatment see the Cross-Plugin-Communication wiki page.

1. Hookspecs (recommended for most cases)

The host application defines hookspecs; plugins implement them via @hookimpl. The host dispatches hooks at the right lifecycle moment. Plugins don't need to know about each other. This is pluggy's native mechanism and PluginForge's primary extension model.

When to use: plugin provides a capability the host orchestrates (export formats, content transforms, AI completions).

Example: a host defines an export_execute hookspec. Any plugin can implement it. The host's export router dispatches it. A new plugin adding a PDF export format just implements the hook, no imports from other plugins needed.

Limitation: hooks are one-shot dispatch calls. If a plugin needs a multi-method service object from another plugin, hooks are too coarse.

2. Direct Python imports (acceptable for tightly coupled plugins)

Plugin B imports from Plugin A's package directly. Simple, explicit, no framework abstraction needed.

When to use: two plugins are developed together, share a release cycle, and the dependency is stable. Typically first-party plugins in a monorepo.

Constraints:

  • Declare depends_on = ["plugin-a"] so PluginForge activates A before B.
  • Handle ImportError gracefully if the dependency might not be installed (optional cross-plugin feature).
  • This pattern does NOT work safely for external/third-party plugins because the imported plugin's internal API is not guaranteed stable.

3. Service-Registry (future, not yet in PluginForge)

For cases where plugins need typed service objects from other plugins without direct imports. Not yet implemented in PluginForge. If your use case requires this, use hookspecs or direct imports for now.

The recommended path for most applications: define hookspecs in the host for every cross-cutting concern, and let plugins implement them. depends_on handles activation ordering. Direct imports are acceptable for first-party plugins under your control.

Lifecycle hooks

Hook When called Override if
init(app_config, plugin_config) Once, before activation You need both app and plugin config. The default stores them on self.
activate() After successful init, in dependency order You need to set up state, open connections, register external handles.
deactivate() On shutdown, reverse activation order You hold resources that need explicit release.
on_config_changed(old, new) When the host calls refresh_config You cache config-derived state and need to invalidate it.
health() -> dict When the host calls health_check You want to expose plugin-specific health beyond {"status": "ok"}.
get_routes() -> list Once, during mount_routes You expose FastAPI endpoints. Follow the Single-Router Convention.
get_frontend_manifest() -> dict | None On consumer request You ship frontend assets.
get_migrations_dir() -> str | None When the host calls collect_migrations You ship Alembic migrations.

TODO: expand in v0.9.0 - failure-recovery patterns. If activate() raises, PluginForge catches the exception (since v0.6.0), logs at ERROR, and continues with siblings. Your plugin lands with status error in the DiscoveryResult. Cover the recovery story for stateful plugins.

Packaging your plugin

Two installation paths. Pick the one your host supports.

Entry-point installed (pip-installable): declare your plugin class in [project.entry-points."<group>"] in pyproject.toml. The group must match the host's plugins.entry_point_group value.

[project.entry-points."myapp.plugins"]
myplugin = "myplugin.main:MyPlugin"

TODO: expand in v0.9.0 - direct-register / ZIP-install pattern for hosts that support uploaded plugins (Bibliogon-specific today).

Quick Reference for AI Agents

  • Mandatory class attributes: name, version, target_application
  • Single router convention: get_routes() returns exactly one APIRouter (multi-router emits DeprecationWarning since v0.8.0)
  • Common mistakes: multiple routers from get_routes(), missing target_application (deprecation warning in v0.7.0/v0.8.0, hard filter in v0.9.0+), not implementing on_config_changed when plugin has stateful config
  • Key files: pluginforge/base.py (BasePlugin contract), pluginforge/state.py (FilterReason, ErrorPhase enums)

Reference

  • BasePlugin source: pluginforge/base.py.
  • PluginManager source: pluginforge/manager.py.
  • Consumer-app guide: docs/guides/consumer-integration.md.
  • Architecture: docs/ARCHITECTURE.md.
  • Per-release design rationale: docs/design/.