Étendre le contexte
Sommaire
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,varsetdirdisparaissent 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
- Guide d'embedding : démarrage rapide et checklist de production ;
- Exemples d'embedding : configuration, ETL, règles, Flask et intégrations ;
- Module loading : résolution, policies et modules host.