Module loading

Catnip charge des modules Catnip, Python et Rust dans des namespaces explicites.

CLI : -m

catnip -m math script.cat
catnip -m math -m random script.cat

Deux suffixes contrôlent le nom exposé :

Suffixe Exemple Effet
:alias -m math:m Namespace renommé : m.sqrt()
:! -m io:! Exports injectés directement dans les globals
catnip -m math:m -c "m.sqrt(16)"
# ⇒ 4.0

catnip -m io:! -c "print('BORN TO SEGFAULT')"
# ⇒ BORN TO SEGFAULT

Sans suffixe, le namespace porte le dernier segment du nom du module.

Langage : import()

import() prend un nom de module et retourne un namespace.

Binding automatique

En position statement, un appel avec un seul argument lie automatiquement le module :

import('math')
math.sqrt(144)
# → 12.0

Le nom lié vient du dernier segment. Les noms contenant des segments internes, comme os.path, utilisent la forme expression :

p = import('os.path')
p.join("a", "b")
# → "a/b"

Binding explicite

m = import('math')
m.sqrt(144)
# → 12.0

Depuis un callback ND parallèle

Le chargeur d'imports est lié au thread principal. Un callback ~>/~~ exécuté en mode thread — ou en mode process retombé sur thread — ne peut donc pas importer, et le refus est explicite :

RuntimeError: import() is not supported inside a thread-parallel ND callback: the import loader is bound to the main
thread. Use pragma('nd_mode', 'sequential'), or hoist the import out of the callback.

Le refus porte sur l'appel, pas sur l'écriture du nom : il vaut donc aussi quand l'import est atteint à travers une fonction que le callback appelle, ou par un nom résolu à l'exécution. Les deux remèdes sont ceux du message — sortir l'import du callback, ce qui suffit dans la plupart des cas puisque le module reste visible par capture, ou renoncer au parallélisme pour ce broadcast.

Ordre de résolution

Pour import('name'), le loader cherche d'abord les fichiers et packages, puis les modules stdlib Rust, puis importlib pour la stdlib Python et les packages installés. Avant d'exécuter le module résolu, il consulte la clé de cache adaptée au backend.

Les répertoires de recherche sont parcourus dans cet ordre canonique :

  1. caller_dir : répertoire du fichier appelant, déterminé par META.file ;
  2. CWD : répertoire courant du processus ;
  3. CATNIP_PATH : répertoires supplémentaires, dans l'ordre de la variable.
export CATNIP_PATH="/opt/catnip-libs:$HOME/.catnip/modules"
catnip script.cat

En REPL et avec -c, META.file n'existe pas : la recherche commence donc au CWD, puis continue dans CATNIP_PATH.

Un fichier trouvé avant la stdlib peut masquer un module du même nom. Le kwarg protocol force un backend précis quand ce masquage n'est pas souhaité.

La liste de recherche est fixée au lancement. Aucun module ne peut modifier le futur des imports trois frames plus haut.

Protocoles

protocol accepte trois valeurs :

Valeur Backend recherché
"cat" Module .cat
"py" Fichier .py ou module Python via importlib
"rs" Extension native
host = import('host', protocol='py')
tools = import('tools', protocol='cat')
ext = import('myext', protocol='rs')

Si utils.cat et utils.py coexistent, le fichier Catnip gagne sans protocole explicite :

import('utils')
import('utils', protocol='py')
import('utils', protocol='cat')

protocol="cat" bloque le fallback importlib. Une valeur inconnue lève CatnipRuntimeError.

Modules stdlib

Les modules natifs exposent PROTOCOL == "rust" :

Chacun existe en deux exemplaires : une extension Python, servie au CLI et à l'API, et un plugin natif, servi au moteur pur — celui du serveur MCP et des embarqueurs sans Python. Les deux répondent la même chose au même code, ce qu'un test de parité vérifie. Le plugin natif est cherché dans CATNIP_STDLIB_PATH, puis à côté de l'exécutable et dans son lib/, où make install-bins le dépose. Il se construit pour Linux et macOS, les deux plateformes que le projet distribue : il lit et écrit les descripteurs du processus directement. io.input() y lit le descripteur 0 sans tampon qui lui survive — ce qu'un programme ne lit pas est perdu avec lui, et n'attend pas l'exécution suivante dans le même processus.

Un module en deux exemplaires reste un module tant que personne ne les compare.

Module Exports principaux
io print, write, writeln, eprint, input, open
sys argv, environ, executable, version, platform, exit
http get, post, put, delete, request, Server, serve, auth HTTP

Le descripteur rendu par io.open() porte read, readline, write, close, les attributs name/mode/closed, et le protocole de gestion de contexte — with f = io.open("data.txt") { f.read() } ferme le fichier à la sortie du bloc, y compris quand celui-ci lève.

sys.exit() suit la convention Python : sans argument ou avec None il sort en succès, avec un entier il sort avec sa valeur, et avec n'importe quoi d'autre il imprime l'objet sur la sortie d'erreur et sort en échec.

Les fonctions de module prennent les arguments nommés de leurs équivalents Python (sep, end, flush, code, et les huit paramètres de open), par position comme par nom : la page des modules donne la liste et les limites qui subsistent entre les deux implémentations.

Pour charger l'homonyme Python d'un module natif :

python_http = import('http', protocol='py')

Noms dotted

Le point sépare les segments de package :

m = import('mylib.utils')
# cherche mylib/utils.cat, mylib/utils.py ou un package compatible

p = import('PIL.Image')
# fallback importlib si aucun fichier local n'est trouvé

Imports relatifs

Les points initiaux résolvent le nom depuis le fichier appelant :

Syntaxe Résolution
.utils caller_dir/utils.cat
..utils caller_dir/../utils.cat
...utils caller_dir/../../utils.cat
..lib.utils caller_dir/../lib/utils.cat
project/
  main.cat
  lib/
    core.cat
    helpers.cat
  shared/
    utils.cat
# lib/core.cat
helpers = import('.helpers')
utils = import('..shared.utils')

Contraintes :

  • META.file est requis ; les imports relatifs ne fonctionnent pas en REPL ni avec -c ;

  • aucun fallback vers le CWD, CATNIP_PATH ou importlib ;

  • protocol continue de filtrer le backend ;

  • "." et ".." seuls sont invalides ;

  • "./foo" reste un chemin et n'est pas accepté comme nom de module ;

  • depuis un package, la remontée s'arrête à sa racine. Un point de trop lève en nommant cette racine, au lieu de résoudre vers un fichier situé au-dessus :

text relative import '...dehors' escapes its package package root: /chemin/vers/pkg

Hors package, la remontée n'a pas de borne — rien ne définit de racine pour des .cat en vrac, et elle sature au répertoire racine du système de fichiers. Un fichier destiné à être importé par des tiers gagne donc un lib.toml, qui lui donne une frontière en même temps qu'un nom.

Packages lib.toml

Un répertoire contenant lib.toml est un package. Il a priorité sur un fichier homonyme comme mylib.cat.

[lib]
name = "mylib"
version = "0.1.0"
entry = "main.cat"

[lib.exports]
include = ["fn_a", "fn_b"]
mylib/
  lib.toml
  main.cat
  helpers.cat
m = import('mylib')
h = import('mylib.helpers')

entry vaut main.cat par défaut. lib.exports.include restreint le namespace ; sans cette clé, les règles d'export standard s'appliquent.

Un répertoire sans lib.toml n'est pas chargé comme package.

Auto-import

Sans configuration, io:! est chargé en CLI et en REPL. L'embedding ne charge rien automatiquement — print, input et open y restent disponibles quand même, comme builtins du runtime ; write, writeln et eprint, non.

[modules]
auto = ["io"]

[modules.repl]
auto = ["io", "math"]

[modules.cli]
auto = ["io"]

[modules.dsl]
auto = []

La section du mode courant remplace [modules].auto. Les modules -m s'ajoutent ensuite, après déduplication.

Depuis Python :

from catnip import Catnip

cat = Catnip(auto=['io', 'math'])
cat.parse('math.sqrt(16)')
result = cat.execute()
# ⇒ 4.0

Un auto-import introuvable n'empêche pas le chargement des modules restants.

Wild import et import sélectif

Wild import

wild=True injecte tous les exports publics dans les globals et retourne None :

import('utils', wild=True)
double(5)

META et les noms préfixés par _ ne sont jamais injectés.

Import sélectif

Les arguments qui suivent le spec sélectionnent les exports. name:alias les renomme :

import('math', 'sqrt', 'pi:p')
sqrt(144)
# → 12.0
p
# → 3.141592653589793

L'import sélectif retourne aussi None. Il ne peut pas être combiné avec wild=True.

Modules Catnip et META

Chaque module et script reçoit un objet META.

Exports

Le loader choisit les exports dans cet ordre :

  1. META.exports ;
  2. __all__ ;
  3. tous les noms sauf META et ceux préfixés par _.
add = (a, b) => { a + b }
sub = (a, b) => { a - b }
_helper = (x) => { x }

META.exports = list("add", "sub")

Métadonnées

Avant l'exécution, le loader renseigne :

  • META.file : chemin absolu du fichier ;
  • META.main : True pour un script exécuté directement ;
  • META.protocol : "cat" pour un module Catnip.

Le code peut ajouter d'autres attributs à META.

Cache

Un module n'est exécuté qu'une fois par contexte. Les imports suivants retournent le même namespace.

La clé dépend du backend :

  • fichiers .cat, .py et extensions natives : chemin absolu résolu ;
  • modules importlib et stdlib : nom du module et protocole.

Deux fichiers homonymes situés dans des répertoires différents restent donc distincts, et import('sys') ne répond pas pour import('sys', protocol='py') : ce sont deux modules, ils ont deux entrées.

Module Policy

Une policy contrôle les noms importables :

[modules]
policy = "deny"
allow = ["math", "json", "random", "numpy.*"]
deny = ["os", "subprocess", "sys", "importlib"]

L'évaluation applique :

  1. deny ;
  2. allow ;
  3. la valeur de fallback policy.

deny gagne toujours. Le matching respecte les segments :

  • "os" matche os et os.path ;
  • "os.*" matche les sous-modules, pas os ;
  • "oslo" ne matche pas "os".

Une policy filtre des noms, pas des effets. Autoriser un module l'autorise à faire ce qu'un module fait au chargement — et un module qui déclare __catnip_extension__ reçoit le contexte et écrit dans ses globals (voir Écrire un module host), donc il peut y remplacer import ou open. C'est la définition d'une extension et non un trou de la policy, mais la conséquence se lit rarement dans l'autre sens : la liste allow est une liste de code auquel on fait confiance, pas une liste de noms inertes. Pour une frontière qui ne repose pas sur cette confiance, il faut une frontière de processus.

Policies nommées

[modules.policies.sandbox]
policy = "deny"
allow = ["math", "json", "io"]

[modules.policies.admin]
policy = "allow"
deny = ["subprocess", "os"]
catnip --policy sandbox script.cat
catnip module list-profiles
catnip module check sandbox os math json

API Python

from catnip import Catnip
from catnip._rs import ModulePolicy

policy = ModulePolicy('deny', allow=['math', 'json'], deny=['os'])
cat = Catnip(module_policy=policy)

La policy est héritée par les modules Catnip importés. Les imports relatifs sont contrôlés sous leur nom qualifié complet.

Les modules déjà en cache ne sont pas revérifiés quand une policy est installée après leur chargement.

Une policy réduit la surface d'import. Elle ne transforme pas un processus Python en frontière de sécurité.

Écrire un module host

Un module Python expose ses fonctions, classes et constantes publiques :

def double(value):
    return value * 2

class Counter:
    def __init__(self):
        self.value = 0

def _private_helper():
    return 'private'

double et Counter sont visibles ; _private_helper ne l'est pas.

Pour injecter des fonctions directement dans un Context ou distribuer une extension Catnip, voir EXTENDING_CONTEXT.

REPL avec modules

catnip -m math

La présence de -m sélectionne la REPL Python afin que les namespaces chargés soient accessibles. Voir REPL.