Guide d'embedding

Ce guide construit un runtime Catnip minimal, puis ajoute les contrôles nécessaires à un usage en production.

Pour la référence complète de Context, @pass_context et des extensions, voir Étendre le contexte.

Quand embedder Catnip

L'embedding convient quand une application Python garde le contrôle du processus et délègue une partie configurable :

  • règles métier ;
  • validation ;
  • transformations ETL ;
  • workflows ;
  • scripts fournis par les utilisateurs.

Le host définit les données disponibles, les opérations autorisées, la durée de vie du contexte et le traitement des erreurs.

Runtime minimal

from catnip import Catnip

cat = Catnip()
cat.parse('x = 10; y = x * 2; y')
result = cat.execute()
# ⇒ 20

parse() prépare le script. execute() l'exécute dans le contexte courant.

Options de construction

Catnip() n'accepte que les arguments nommés qu'il lit, et refuse les autres — une faute de frappe lève au lieu de s'exécuter avec les valeurs par défaut :

Argument Rôle
context, context_class contexte d'exécution fourni ou classe à instancier
optimize niveau 0 à 3 ; 3 ajoute le tier inter-blocs
tco optimisation des appels terminaux
jit compilation des chemins chauds
vm_mode 'on' (VM, défaut) ou 'off' (interpréteur AST)
cache, enable_cache cache de compilation
auto, module_policy modules chargés d'office, politique d'import
use_pragmas prise en compte des pragma du fichier
registry_class, executor_class points d'extension internes

optimize et tco sont des overrides appelant : un pragma du fichier ne les renverse pas. jit n'en est pas un, donc un pragma("jit", ...) reprend la main sur lui.

Échanger des valeurs

cat.context.globals['price'] = 100
cat.context.globals['rate'] = 0.2

cat.parse('total = price * (1 + rate)')
result = cat.execute()
# ⇒ 120.0

total = cat.context.globals['total']

Les globals persistent tant que l'instance Catnip et son contexte sont réutilisés.

Exposer une API métier

Construire un Context avec seulement les valeurs nécessaires :

from catnip import Catnip, Context, pass_context

class ValidationState:
    def __init__(self):
        self.errors = []

state = ValidationState()

@pass_context
def reject(ctx, field, message):
    ctx.globals['state'].errors.append(dict(
        field=field,
        message=message,
    ))

ctx = Context(globals=dict(
    state=state,
    reject=reject,
))
cat = Catnip(context=ctx)

Le script ne voit ni le contexte ni les autres objets de l'application :

cat.parse('''
    if user_age < 18 {
        reject("age", "Accès réservé aux adultes")
    }
''')

Avant l'exécution, le host injecte les données de la requête :

cat.context.globals['user_age'] = 16
cat.execute()

@pass_context est réservé aux fonctions qui doivent lire ou modifier le contexte. Une opération sans état reste une fonction Python ordinaire.

Durée de vie du contexte

Choisir explicitement l'un de ces modèles :

Modèle Propriété
Une instance par requête Pas d'état partagé entre requêtes
Une instance par session Variables persistantes pendant la session
Pool d'instances Réutilisation contrôlée avec réinitialisation complète

Une instance partagée conserve ses globals, les modules chargés et les effets des scripts précédents. Pour du multi-tenant, utiliser un contexte distinct par tenant ou par requête.

def new_runtime(user_id):
    ctx = Context(globals=dict(user_id=user_id))
    return Catnip(context=ctx)

Contrôles de production

Surface exposée

N'injecter que les valeurs nécessaires. Éviter notamment les fonctions Python qui donnent un accès indirect au système de fichiers, au réseau, aux imports ou à l'introspection du processus.

Un contexte restreint réduit les capacités accidentelles. Il ne remplace pas une isolation de processus face à un attaquant déterminé.

Modules

Les imports doivent être soumis à une policy :

from catnip import Catnip, Context
from catnip._rs import ModulePolicy

policy = ModulePolicy(
    'deny',
    allow=['math', 'json'],
    deny=['os', 'subprocess', 'sys', 'importlib'],
)

ctx = Context()
ctx.module_policy = policy
cat = Catnip(context=ctx)

Les patterns, policies nommées et limites de cette protection sont documentés dans Module Policy.

Entrées

Valider avant parse() :

  • taille du script ;
  • taille et forme des données injectées ;
  • types et bornes des arguments reçus par les fonctions métier ;
  • nombre de scripts ou d'opérations autorisés par requête.
MAX_SCRIPT_BYTES = 10_000

def parse_user_script(cat, source):
    if len(source.encode('utf-8')) > MAX_SCRIPT_BYTES:
        raise ValueError("Script trop long")
    cat.parse(source)

Les fonctions exposées valident à nouveau leurs propres arguments. Le script peut construire des valeurs qui ne proviennent pas directement des données initiales.

Temps et mémoire

La CLI et le runtime disposent d'une garde mémoire sous Linux. Pour un service, appliquer aussi les limites au niveau du processus ou du conteneur.

Un timeout du host doit pouvoir interrompre ou remplacer le worker exécutant le script. Un simple compteur placé dans les fonctions métier ne couvre pas les boucles qui n'appellent aucune de ces fonctions.

Erreurs

Définir une frontière unique autour de parse() et execute() :

def execute_script(cat, source):
    try:
        cat.parse(source)
        return dict(ok=True, result=cat.execute())
    except Exception as error:
        return dict(ok=False, error=str(error))

En production, journaliser séparément :

  • l'identifiant du script ;
  • sa version ou son hash ;
  • la durée ;
  • le résultat de la policy ;
  • le type d'erreur, sans exposer de données sensibles au client.

Cache et performances

Catnip met en cache le parsing et le bytecode. Ne pas ajouter un cache de pickle autour d'objets internes du parser : leur représentation appartient au runtime et peut changer.

Avant de réutiliser ou de pooler des instances :

  1. mesurer le coût réel de création ;
  2. inventorier tout l'état persistant du contexte ;
  3. définir une remise à zéro vérifiable ;
  4. tester qu'une requête ne voit aucune donnée de la précédente.

Le JIT vise les boucles et fonctions chaudes. Il n'améliore pas nécessairement un script court exécuté une seule fois. Les options et pragmas sont décrits dans PRAGMAS.

Tests à prévoir

Tester au minimum :

  • script valide et résultat attendu ;
  • erreur de syntaxe ;
  • exception dans une fonction exposée ;
  • module autorisé et module refusé ;
  • script et données aux limites de taille ;
  • boucle ou calcul long ;
  • dépassement mémoire ;
  • absence de fuite entre deux contextes ;
  • redémarrage après une erreur.

Les tests doivent exercer la même configuration, la même policy et le même mode d'exécution que le service.

Checklist de déploiement

  • [ ] Un contexte distinct existe pour chaque unité d'isolation.
  • [ ] Les globals exposés sont inventoriés.
  • [ ] Une policy limite les modules.
  • [ ] La taille des scripts et des données est bornée.
  • [ ] Les fonctions métier valident types et bornes.
  • [ ] Temps CPU et mémoire sont limités hors du script.
  • [ ] Les erreurs sont transformées en réponse contrôlée.
  • [ ] Les logs ne contiennent pas de données sensibles.
  • [ ] Les contextes réutilisés sont remis à zéro et testés.
  • [ ] Les scripts adverses font partie de la suite de tests.

Exemples complets

Les exemples exécutables sont regroupés sous docs/examples/embedding :

  • dataframe et ETL ;
  • DSL de configuration ;
  • moteur de règles ;
  • génération de rapports et workflows ;
  • sandbox Flask ;
  • intégrations Jupyter et Streamlit ;
  • sérialisation.

Le host dessine la frontière. Le script découvre ensuite qu'une frontière est une API vue de l'intérieur.