Escribir un plugin
Un plugin es una clase con metadata y hasta cinco hooks opcionales. Nada más.
Plugin mínimo
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}
Cárgalo sin instalar nada:
app = create_app(plugins=[GreetingPlugin])
PluginMeta
| Campo | Significado |
|---|---|
name |
Identificador único. Lo que va en jfast.toml. |
version |
Versión del plugin, independiente de la del framework. |
description |
Una línea, la que muestra jfast plugins list. |
requires |
Dependencias duras. Se cargan solas; un ciclo es un error. |
after |
Orden suave. Va después de estos si están, sin error si no. |
provides |
Claves de contexto publicadas. Dos plugins reclamando una misma clave falla en build time. |
default_enabled |
Se carga cuando el servicio no fija una allow-list explícita. |
extra |
Install extra que el plugin necesita para importar. |
requires vs after: rag requires database (no puede funcionar sin un
engine). metrics va after observability (ordena mejor los logs, pero
funciona solo).
Settings
Cada plugin es dueño de su bloque de config, con su propio prefijo de env.
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
Los valores salen de [plugin.storage] en jfast.toml, pisados por las
variables de entorno JFAST_STORAGE_*. Los secretos son SecretStr y nunca
aparecen en describe() ni en /info.
Hooks
def register(self, ctx: AppContext) -> None
Build time, antes de que el loop atienda. Monta routers, agrega middleware, publica providers. Sin I/O. Una llamada bloqueante aquí frena el arranque.
async def startup(self, ctx: AppContext) -> None
Abre pools y conexiones. Corre en orden de dependencias.
async def shutdown(self, ctx: AppContext) -> None
Cierra lo que abrió startup. Corre en orden inverso. Una falla aquí se
loguea y no impide que los otros plugins limpien lo suyo.
async def health(self, ctx: AppContext) -> HealthReport
Sondea el recurso de atrás. Alimenta /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]
Los contenedores que este plugin necesita. Los consume 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 tiene que ser menor a 10 — un servicio es dueño de un bloque de
diez puertos.
Providers
def register(self, ctx):
client = build_client(self.settings.url)
ctx.provide("storage", client)
Los consumidores lo piden por clave:
storage = ctx.require("storage")
storage = ctx.require("storage", expected=StorageClient) # runtime type check
Un provider que falta levanta ProviderNotFound nombrando todas las claves que
sí están disponibles. Declara la clave en meta.provides para que los
conflictos se detecten antes de que corra cualquiera de los dos plugins.
Dependencias opcionales
Impórtalas adentro del método, nunca en el top level del módulo:
def register(self, ctx):
import redis.asyncio as aioredis # top-level would break discovery
...
El discovery importa todas las clases de plugin. Un import de un extra que falta, en el top level, deja el plugin imposible de importar para todos, no solo para los servicios que lo usan.
Distribuir un plugin de terceros
[project.entry-points."jfastframework.plugins"]
storage = "mypackage.plugin:StoragePlugin"
Instálalo y cualquier servicio JFast puede habilitar "storage" en
jfast.toml.
¿No lo vas a empaquetar? Apunta directo a la clase:
[plugins.paths]
storage = "myapp.plugins.storage:StoragePlugin"
La ruta con puntos se resuelve contra el directorio donde corre el comando: por
eso myapp/ va al lado de jfast.toml y ejecutas jfast desde ahí. Es el
único requisito, y es el mismo que uvicorn main:app ya impone.
Una ruta que no importa no revienta el comando. Queda registrada como rota y se reporta en cuanto algo habilita ese nombre:
$ 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')
Un plugin que nadie habilita se queda en warning, que es lo que permite a
jfast workspace compose generar el archivo para los demás servicios cuando el
plugin de uno de ellos no está en esta máquina.
Reemplazar uno de los que vienen incluidos
Deshabilítalo y reclama la misma clave de provider:
[plugins]
enabled = ["observability", "my_cache"]
disabled = ["cache"]
Como los consumidores dependen de la clave y no de la clase, nada aguas abajo cambia.
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 registra las llamadas a register / startup / shutdown —
úsalo para verificar el orden.
Checklist
- [ ]
meta.provideslista todas las claves que publica el plugin - [ ] Los imports opcionales están adentro de los métodos
- [ ]
registerno hace I/O - [ ]
shutdowncierra todo lo que abrióstartup - [ ]
healthdistingue crítico de degradado - [ ] Secretos tipados
SecretStr - [ ] Entry point registrado y el paquete reinstalado
- [ ] Test que cubra orden y health