Aller au contenu principal

Plugins

Le moteur d'étape de job, le validateur de mot de passe et quelques autres points d'extension du framework acceptent des références d'appel de la forme "module.path:function". La fonction est du Python ordinaire ; le framework l'importe paresseusement à la première utilisation et l'appelle avec le contexte d'exécution sous forme d'arguments nommés.

C'est le seul endroit où une installation client exécute du code personnalisé — le reste de la configuration est purement déclaratif. Utilisez les plugins pour ce que le TOML ne peut pas exprimer : transformations sur mesure, particularités spécifiques à un ERP, intégrations avec des systèmes externes.


Vue d'ensemble

OÙ ILS SE TROUVENT
liberty-apps/plugins/<package>/ — ajouté à sys.path au démarrage.
RÉFÉRENCÉS DEPUIS
TOML — callable = "billing.invoicing:run"
SIGNATURE
def run(**ctx) -> dict | None — synchrone ou async def.
POINTS D'EXTENSION
Étape python · validateur de mot de passe · hooks de dispatch

Arborescence

Un plugin est un package Python ordinaire sous liberty-apps/plugins/ :

liberty-apps/
└── plugins/
└── billing/
├── __init__.py ← marqueur de package
├── invoicing.py ← appels d'étape
├── adjustments.py
└── README.md

Le framework ajoute liberty-apps/plugins/ à sys.path au démarrage (quand LIBERTY_APPS_DIR est défini ; sans LIBERTY_APPS_DIR, c'est liberty-next/plugins/). Le package est alors importable sous le nom billing.invoicing, comme un module Python normal.

Package ou fichier unique

plugins/
├── billing/ ← package — multi-module, recommandé
│ ├── __init__.py
│ ├── invoicing.py
│ └── adjustments.py
└── ad_sync.py ← module unique — pratique pour un utilitaire ponctuel

Les deux fonctionnent. Les packages passent mieux à l'échelle au fur et à mesure que les fonctionnalités s'ajoutent ; les modules uniques sont pratiques quand une fonction tient dans un fichier.


Écrire un appel

La forme de référence est :

# plugins/billing/invoicing.py
from __future__ import annotations

import logging
from typing import Any

log = logging.getLogger(__name__)


def run(*, period: str, dry_run: bool = False, **ctx: Any) -> dict:
"""Re-issue every draft invoice for the given period.

Args:
period: YYYY-MM the job is running for.
dry_run: if True, count but don't write.
ctx: every other key the framework injects (see below).

Returns:
Step result dict — surfaced in the run history.
"""
connectors = ctx["connectors"] # type: ConnectorRegistry
billing = connectors.sql("billing")

drafts = billing.run("drafts-for-period", period=period)
log.info("billing.invoicing.run period=%s drafts=%d dry_run=%s", period, len(drafts), dry_run)

if dry_run:
return {"rows_affected": 0, "matched": len(drafts)}

inserted = 0
for draft in drafts:
billing.run("issue-invoice:write", id=draft["id"])
inserted += 1

return {"rows_affected": inserted}
ÉlémentNotes
* puis **ctxLe framework passe toujours le contexte sous forme d'arguments nommés. Le * initial force les paramètres explicites (period, dry_run) à être passés par nom — l'appel depuis le TOML utilise des arguments nommés.
Type de retourUn dict avec au moins rows_affected (int) est la convention. Le dict est enregistré sur l'enregistrement de l'exécution. None est autorisé et signifie « aucun compteur ».
LoggingUtiliser logging.getLogger(__name__) — le gestionnaire de log du framework route les messages vers le flux de log d'exécution visible dans l'interface.
ExceptionsLes lever normalement. Le runner de job intercepte, enregistre l'exception dans l'historique d'exécution et applique la politique de relance / backoff configurée.

Appels asynchrones

async def est pris en charge et automatiquement attendu par le framework — utile quand le travail se diffuse vers des appels réseau :

import httpx

async def push_to_crm(*, deal_id: int, **ctx) -> dict:
async with httpx.AsyncClient() as client:
r = await client.post(f"https://crm.example.com/api/deals/{deal_id}/sync")
r.raise_for_status()
return {"rows_affected": 1}

Le framework détecte la coroutine via inspect.iscoroutinefunction et l'attend sur la boucle d'événements.


Le contexte d'exécution

Chaque appel reçoit un contexte de base composé de clés que le framework fournit. L'ensemble exact dépend du point d'extension :

Étape python de job

CléTypeDescription
connectorsConnectorRegistryAccès à chaque connecteur par son nom — connectors.sql("billing").run("query-name", **params).
poolsPoolRegistryAccès plus bas niveau aux pools bruts quand une transaction sur plusieurs connecteurs est nécessaire.
job_idstrL'identifiant du job courant.
run_idstrL'identifiant d'exécution — utile pour les recoupements avec les lignes de log.
step_namestrLe nom de l'étape dans le job.
paramsdictLe bloc params du job (depuis jobs.toml).
step_kwargsdictLe bloc kwargs propre à l'étape.
previous_stepdict | NoneLe résultat de l'étape précédente (en chaîne).
loggerlogging.LoggerLogger préconfiguré qui route vers le flux de log d'exécution.
session_userstr"system" quand le job a été planifié, ou l'identifiant de l'utilisateur en cas de déclenchement manuel.

Plus chaque clé déclarée dans le bloc kwargs de l'étape (TOML — voir Types d'étape).

Validateur de mot de passe

CléTypeDescription
usernamestrL'utilisateur pour lequel le mot de passe est défini.
passwordstrLe mot de passe candidat (en clair).
existing_userdict | NoneEnregistrement utilisateur s'il existe déjà. None sur create-user.

Retourner None pour accepter ; lever ValueError("reason") pour rejeter.

Hooks de dispatch (rares)

Une poignée d'événements internes (screen.before_save, screen.after_save, connector.before_query) acceptent des hooks optionnels via le bloc [hooks] dans app.toml. La signature reflète la charge utile de l'événement ; voir les sources sous liberty/hooks/ pour la liste complète.


Référencer un appel depuis le TOML

Le format de référence est "<module.path>:<function>" :

# plugins/billing/jobs.toml
[[jobs]]
name = "reissue-monthly-drafts"
schedule = "0 2 1 * *" # 02:00 le 1er de chaque mois

[[jobs.steps]]
name = "reissue-drafts"
type = "python"
callable = "billing.invoicing:run"
kwargs = { period = "${month.previous}", dry_run = false }

Le framework importe billing.invoicing paresseusement à la première utilisation, recherche run, valide la signature contre le contexte d'exécution plus les kwargs de l'étape, et met en cache l'appel résolu pour les exécutions suivantes.

Un module ou une fonction inexistante fait échouer le chargement du job (erreur au moment du rechargement), pas la première exécution — les plugins cassés sont détectés quand Enregistrer et recharger est déclenché.


Appeler des connecteurs depuis un plugin

La clé connectors du contexte donne accès au registre que le framework utilise en interne. Trois formes d'appel :

# Connecteur SQL
rows = ctx["connectors"].sql("billing").run("monthly-totals", month="2026-05")

# Connecteur HTTP / API
resp = await ctx["connectors"].api("crm").call("get-customer", id=42)

# Pool brut — pour des transactions sur plusieurs requêtes
async with ctx["pools"].get("billing").begin() as conn:
await conn.execute(text("UPDATE invoices SET status = 'issued' WHERE batch_id = :b"), {"b": batch})
await conn.execute(text("INSERT INTO audit (...) VALUES (...)"), {...})

Utiliser le haut niveau connectors.sql(...) / connectors.api(...) quand l'opération correspond à une requête / un endpoint nommé — ils respectent les contrôles de permission et le journal d'audit. Descendre à pools.get(...) uniquement quand une transaction qui traverse plusieurs connecteurs est nécessaire.


Distribuer un plugin entre environnements

Deux schémas fonctionnent :

Schéma 1 — versionné dans liberty-apps

Le code source du plugin se trouve dans liberty-apps/plugins/<package>/ et est livré avec le reste de la configuration. Le versionnement, le déploiement et le retour arrière suivent le dépôt liberty-apps.

AvantageInconvénient
Historique git unique pour la configuration + le code personnalisé.Mélange du code Python et de la configuration TOML.
La revue de code passe par la même PR que le changement de configuration.Un plus gros plugin (plusieurs milliers de lignes) alourdit le dépôt de configuration.

Schéma 2 — publié sous forme de package Python

Construire le plugin comme un package Python ordinaire, le publier sur un PyPI privé (ou simplement l'installer depuis git), l'installer via pip dans l'environnement virtuel de liberty-next :

cd liberty-next
.venv/bin/pip install git+https://github.com/acme/liberty-billing-plugin@v1.4.2

La référence TOML est la même — callable = "liberty_billing.invoicing:run" — parce que le package est désormais importable via les site-packages de l'environnement virtuel, pas via liberty-apps/plugins/.

AvantageInconvénient
Le code du plugin vit dans son propre dépôt avec ses propres tests / CI.Deux pipelines de livraison à gérer.
Plusieurs installations peuvent partager une version unique du plugin.Le rechargement à chaud ne capte pas une mise à jour pip — un redémarrage est nécessaire.

Choisir le schéma 1 pour les petits plugins (quelques centaines de lignes, spécifiques à une installation) ; choisir le schéma 2 pour les plugins partagés entre de nombreuses installations ou maintenus par une équipe distincte.


Tester un plugin

Écrire les tests dans le dossier du plugin :

plugins/billing/
├── invoicing.py
└── tests/
└── test_invoicing.py

Un test compatible pytest qui simule le contexte :

# plugins/billing/tests/test_invoicing.py
from unittest.mock import MagicMock
from billing.invoicing import run


def test_dry_run_returns_count_only(monkeypatch):
billing = MagicMock()
billing.run.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
ctx = {"connectors": MagicMock(sql=MagicMock(return_value=billing))}

result = run(period="2026-05", dry_run=True, **ctx)

assert result == {"rows_affected": 0, "matched": 3}
billing.run.assert_called_once_with("drafts-for-period", period="2026-05")

Exécuter avec cd liberty-next && PYTHONPATH=../liberty-apps/plugins .venv/bin/pytest ../liberty-apps/plugins/billing.


Conseils et bonnes pratiques

  • Garder les appels courts. Une fonction de plugin est difficile à déboguer à distance ; une fonction par étape est plus facile à raisonner qu'une classe d'orchestration de 500 lignes.
  • Toujours typer les kwargs explicites. def run(*, period: str, ...) détecte une coquille TOML (peroid = "2026-05") au moment de l'import et non au moment de l'exécution.
  • Logger avec __name__. La queue de log Nomaflow filtre par logger — utiliser le chemin du module rend les lignes faciles à rechercher.
  • Ne pas attraper d'exceptions larges dans une étape. Laisser le runner enregistrer la trace ; un try / except: pass produit une exécution verte qui n'a rien fait en silence.
  • Ne jamais aller chercher dans les internes du framework au-delà du contexte. Les clés ctx["connectors"] / ctx["pools"] sont le contrat stable ; tout le reste peut changer entre versions du framework.
  • Traiter les plugins comme du code. Ils sont commités, relus, testés. La configuration TOML est de la donnée ; les plugins sont du code.

Pour aller plus loin

  • Apps — où le dossier plugin s'inscrit dans la structure d'une app.
  • i18n — ajout de packs de langue (vivent aussi sous plugins/).
  • Jobs → Types d'étape — l'étape python qui appelle les plugins.