Étendre le contexte

Context est l'interface entre une application Python et le runtime Catnip.

Catnip n'expose pas directement le réseau. print, input et open sont en revanche disponibles par défaut, dans les deux exécuteurs. Le host choisit les autres valeurs et opérations accessibles — voir Isolation pour ce que cela retire réellement. En CLI et en REPL, le module io remplace ces trois-là par ses propres versions.

Contrat du Context

Un contexte transporte :

  • globals : valeurs accessibles depuis le code Catnip ;
  • locals : scopes locaux gérés par le runtime ;
  • result : dernier résultat exécuté ;
  • logger : adaptateur de journalisation ;
  • module_policy : règles de chargement des modules.

Une instance Catnip conserve son contexte entre les appels à parse() et execute().

Injecter des valeurs

Le constructeur accepte un dictionnaire de globals :

from catnip import Catnip, Context

def add_tax(price, rate=0.2):
    return price * (1 + rate)

ctx = Context(globals=dict(
    add_tax=add_tax,
    TAX_RATE=0.2,
))

cat = Catnip(context=ctx)
cat.parse('add_tax(100)')
result = cat.execute()
# ⇒ 120.0

Le dictionnaire reste modifiable :

ctx.globals['discount'] = lambda price, rate: price * (1 - rate)
ctx.globals['settings'] = dict(currency='EUR')

Ces valeurs deviennent visibles lors de l'exécution suivante.

Fonctions avec accès au contexte

@pass_context injecte le contexte comme premier argument Python sans l'exposer dans la signature Catnip :

from catnip import Catnip, Context, pass_context

@pass_context
def store(ctx, key, value):
    ctx.globals[key] = value
    return value

@pass_context
def recall(ctx, key, default=None):
    return ctx.globals.get(key, default)

ctx = Context(globals=dict(
    store=store,
    recall=recall,
))
cat = Catnip(context=ctx)

cat.parse('store("score", 42)')
cat.execute()

cat.parse('recall("score") + 8')
result = cat.execute()
# ⇒ 50

Utiliser @pass_context quand une fonction doit lire ou modifier l'état d'exécution. Une fonction pure qui ne dépend que de ses arguments reste une fonction Python ordinaire.

Exposer objets, classes et modules

Tout objet Python placé dans globals est accessible selon ses attributs publics :

from catnip import Catnip, Context
import math

class Store:
    def __init__(self):
        self.data = {}

    def set(self, key, value):
        self.data[key] = value
        return value

    def get(self, key, default=None):
        return self.data.get(key, default)

ctx = Context(globals=dict(
    db=Store(),
    math=math,
))
cat = Catnip(context=ctx)

cat.parse('db.set("name", "Alice"); math.sqrt(16)')
result = cat.execute()
# ⇒ 4.0

Pour charger des modules par nom, appliquer des policies ou utiliser les imports relatifs, voir MODULE_LOADING.

globals et locals

Le host écrit normalement dans globals. Les locals appartiennent aux fonctions et blocs en cours d'exécution ; le runtime les crée et les détruit avec leurs scopes.

ctx.globals['TAX_RATE'] = 0.2

cat.parse('''
    total = (price) => {
        tax = price * TAX_RATE
        price + tax
    }
    total(100)
''')
result = cat.execute()
# ⇒ 120.0

Ici, TAX_RATE est global ; price et tax sont locaux à l'appel.

Isolation

Créer un contexte par unité d'isolation :

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

alice = new_user_runtime('alice')
bob = new_user_runtime('bob')

Ne pas partager un même contexte entre tenants si leurs globals ou leur état doivent rester séparés.

Limiter les globals réduit la surface disponible, mais ne remplace pas une frontière de processus pour du code hostile. Les imports doivent aussi être contrôlés par une Module Policy.

Reprendre un nom après coup le reprend pour de bon, y compris entre deux exécutions d'une même instance :

cat = Catnip()
del cat.context.globals['import']   # vaut pour open, print, les 69 autres
cat.parse('import("os")')
cat.execute()                        # CatnipNameError, dans les deux exécuteurs

C'est la forme qu'a la fermeture d'une capacité pour un hôte qui réutilise une instance entre deux requêtes. Seuls les noms que le runtime a semés sont repris : ce qu'un programme a défini lui reste acquis.

Passer ses propres globals remplace l'environnement, il ne s'y ajoute pas : les builtins ne sont plus là, dans les deux exécuteurs.

cat = Catnip(context=Context(globals=dict(x=1)))
cat.parse('open("/etc/hostname")')
cat.execute()   # CatnipNameError: Name 'open' is not defined

Ce que le contexte ne déclare pas disparaît, import compris. Restent joignables les mécanismes que le bytecode résout pour son compte — les littéraux True, False, None et les helpers d'expansion des littéraux de collection — qui ne donnent accès à rien.

Pour shadower un nom sans perdre le reste, partir du contexte par défaut :

ctx = Context()
ctx.globals['abs'] = my_abs

Un contexte fourni ne reçoit plus non plus logger ni debug, que Catnip ajoutait d'office. Les deux sont des objets Python ordinaires, donc chacun était une sortie par introspection ; les demander explicitement les rend.

Deux points à connaître avant de compter dessus :

  • La restriction porte sur les noms, pas sur les objets. Une valeur déclarée reste un objet Python entier : ses attributs et leur fermeture transitive sont joignables. Le refus des attributs spéciaux coupe la route générique — celle qui remonte d'un littéral à __builtins__ — mais un objet exposé reste ce que ses méthodes publiques en font.
  • Pour du code hostile, la frontière est le processus, et les imports se contrôlent en plus par une Module Policy. getattr, vars et dir disparaissent avec les autres builtins d'un contexte restreint, ce qui ferme le contournement par nom construit.

Un bac à sable qu'on n'a pas essayé de percer est une déclaration d'intention.

Extension distribuable

Le câblage direct convient quand le host contrôle le runtime. Pour distribuer le même câblage dans un package Python, un module peut déclarer __catnip_extension__.

Une extension se déclare elle-même. Le host garde le dernier mot en décidant de l'importer.

Descripteur

Le descripteur est un dictionnaire :

Clé Type Rôle
name str Identifiant requis
version str Version requise
description str Description optionnelle
register callable Hook optionnel recevant un proxy vers le Context
exports dict Valeurs optionnelles injectées dans les globals
def _greet(name):
    return f"Hello, {name}!"

def _register(context):
    context.globals['greeting_loaded'] = True
    if context.logger is not None:
        context.logger.info("greetings extension ready")

__catnip_extension__ = dict(
    name='greetings',
    version='1.0.0',
    description="Salutations pour Catnip",
    register=_register,
    exports=dict(
        greet=_greet,
        EXCLAIM='!',
    ),
)

Chargement

import() détecte le descripteur, appelle register, puis injecte exports :

from catnip import Catnip

cat = Catnip()
cat.parse('import("greetings_ext"); greet("world")')
result = cat.execute()
# ⇒ Hello, world!

Dans le script, les exports sont immédiatement accessibles :

import('greetings_ext')
greet("Catnip")
# → Hello, Catnip!

Les exports remplacent les globals homonymes. Dans un même contexte, le cache empêche un second appel à register et une seconde injection. Une nouvelle instance Catnip possède son propre cache.

register(context)

exports suffit pour déclarer des valeurs. register sert aux extensions qui doivent :

  • lire le logger ou une autre propriété du contexte ;
  • configurer une policy ;
  • injecter plusieurs valeurs conditionnellement ;
  • enregistrer un état propre à l'instance.

Le hook reçoit un proxy vers le vrai contexte ; ses écritures dans context.globals sont visibles en VM comme dans l'interpréteur AST.

Packaging

L'entry point catnip.extensions rend l'extension découvrable par la CLI :

[project.entry-points."catnip.extensions"]
greetings = "greetings_ext"
catnip extensions list
catnip extensions info greetings

L'entry point ne charge pas l'extension dans les scripts. Un appel à import('greetings_ext') reste nécessaire.

Contexte ou extension ?

Critère Contexte construit par le host Extension distribuée
Câblage Application hôte Module importé
Distribution Code local Package Python
Chargement Avant l'exécution import()
Découverte CLI Non Entry point catnip.extensions
Accès au contexte Direct register(context)

Les deux mécanismes peuvent être combinés.

Logger personnalisé

logger et debug() sont présents dans les globals. Un adaptateur personnalisé doit fournir les méthodes utilisées par les scripts :

from catnip import Catnip, Context

class CustomLogger:
    def debug(self, *args, sep=' '):
        my_app_logger.debug(sep.join(str(arg) for arg in args))

    def info(self, *args, sep=' '):
        my_app_logger.info(sep.join(str(arg) for arg in args))

    def warning(self, *args, sep=' '):
        my_app_logger.warning(sep.join(str(arg) for arg in args))

    def error(self, *args, sep=' '):
        my_app_logger.error(sep.join(str(arg) for arg in args))

    def critical(self, *args, sep=' '):
        my_app_logger.critical(sep.join(str(arg) for arg in args))

ctx = Context(logger=CustomLogger())
cat = Catnip(context=ctx)

Le logger reste exposé même quand Context reçoit un dictionnaire globals personnalisé.

Erreurs aux frontières

Les fonctions exposées doivent valider leurs arguments à la frontière Python :

def divide(left, right):
    if right == 0:
        raise ValueError("Division par zéro interdite")
    return left / right

Les exceptions Python deviennent des erreurs Catnip avec la position source de l'appel. Le host choisit ensuite s'il les journalise, les transforme en résultat métier ou interrompt la requête.

Aller plus loin