jfastframework
Docs Español PyPI GitHub

Writing a plugin

A plugin is a class with metadata and up to five optional hooks. Nothing else.

Minimal plugin

from jfastframework import HealthReport, Plugin, PluginMeta


class GreetingPlugin(Plugin):
    meta = PluginMeta(name="greeting", description="Adds /hello.")

    def register(self, ctx):
        @ctx.app.get("/hello")
        async def hello():
            return {"hello": ctx.settings.app_name}

Load it without installing anything:

app = create_app(plugins=[GreetingPlugin])

PluginMeta

Field Meaning
name Unique identifier. What goes in jfast.toml.
version Plugin version, independent of the framework.
description One line, shown by jfast plugins list.
requires Hard dependencies. Pulled in automatically; a cycle is an error.
after Soft ordering. Ordered after these if present, no error if absent.
provides Context keys published. Two plugins claiming one key fails at build time.
default_enabled Loaded when the service pins no explicit allow-list.
extra Install extra needed for the plugin to import.

requires vs after: rag requires database (it cannot function without an engine). metrics comes after observability (nicer log ordering, but it works alone).

Settings

Each plugin owns its config block, with its own env prefix.

from pydantic import SecretStr
from pydantic_settings import SettingsConfigDict
from jfastframework import PluginSettings


class StorageSettings(PluginSettings):
    model_config = SettingsConfigDict(
        env_prefix="JFAST_STORAGE_", env_file=".env", extra="ignore"
    )
    bucket: str = "uploads"
    access_key: SecretStr | None = None

Values come from [plugin.storage] in jfast.toml, overridden by JFAST_STORAGE_* environment variables. Secrets are SecretStr and never appear in describe() or /info.

Hooks

def register(self, ctx: AppContext) -> None

Build time, before the loop serves. Mount routers, add middleware, publish providers. No I/O. A blocking call here stalls startup.

async def startup(self, ctx: AppContext) -> None

Open pools and connections. Runs in dependency order.

async def shutdown(self, ctx: AppContext) -> None

Close what startup opened. Runs in reverse. A failure here is logged and does not stop the other plugins from cleaning up.

async def health(self, ctx: AppContext) -> HealthReport

Probe the backing resource. Feeds /ready.

HealthReport.ok("reachable", latency_ms=3)
HealthReport.fail("redis down", critical=False)   # degraded, still serving
def infra(self, ctx: AppContext | None = None) -> list[InfraService]

The containers this plugin needs. Consumed by jfast deploy compose.

InfraService(
    name="redis",
    image="redis:7-alpine",
    port_offset=3,           # host port = service base port + 3
    internal_port=6379,
    volumes=["redis_data:/data"],
    healthcheck={"test": ["CMD", "redis-cli", "ping"], "interval": "5s"},
)

port_offset must be below 10 — a service owns a block of ten ports.

Providers

def register(self, ctx):
    client = build_client(self.settings.url)
    ctx.provide("storage", client)

Consumers ask for it by key:

storage = ctx.require("storage")
storage = ctx.require("storage", expected=StorageClient)   # runtime type check

A missing provider raises ProviderNotFound naming every key that is available. Declare the key in meta.provides so conflicts are caught before either plugin runs.

Optional dependencies

Import them inside the method, never at module top level:

def register(self, ctx):
    import redis.asyncio as aioredis   # top-level would break discovery
    ...

Discovery imports every plugin class. A top-level import of a missing extra makes the plugin unimportable for everyone, not just for services that use it.

Distributing a third-party plugin

[project.entry-points."jfastframework.plugins"]
storage = "mypackage.plugin:StoragePlugin"

Install it and any JFast service can enable "storage" in jfast.toml.

Not packaging it? Point at the class directly:

[plugins.paths]
storage = "myapp.plugins.storage:StoragePlugin"

The dotted path is resolved relative to the directory the command runs in, so myapp/ sits next to jfast.toml and you run jfast from there. That is the only requirement, and it is the same one uvicorn main:app already imposes.

A path that does not import is not a crash. It is recorded as broken and reported the moment something enables the name:

$ jfast check --only plugins
  ✗ plugins  fail   critical 1
    jfast.toml  plugin-graph-unresolved: the plugin graph does not resolve:
    Unknown plugin(s): storage (failed to import: Cannot import plugin module
    'myapp.plugins.storage': No module named 'myapp')

A plugin nobody enables stays a warning, which is what lets jfast workspace compose generate a file for the other services when one service's plugin is absent from this machine.

Replacing a built-in

Disable it and claim the same provider key:

[plugins]
enabled = ["observability", "my_cache"]
disabled = ["cache"]

Because consumers depend on the key and not on the class, nothing downstream changes.

Testing

from jfastframework.testing import build_test_app, client_for


async def test_greeting_endpoint():
    app = build_test_app(plugins=["greeting"], extra_plugins=[GreetingPlugin])
    async with client_for(app) as client:
        response = await client.get("/hello")
    assert response.status_code == 200

NullPlugin records register / startup / shutdown calls — use it to assert ordering.

Checklist