English version: encadre in English.
encadre est un petit framework web pour Python : on écrit des classes, leurs méthodes deviennent des routes HTTP. Il s'appuie aujourd'hui sur Flask, mais ne s'y attache pas : le moteur web est un greffon choisi dans la configuration.
- Code : git.schurger.org/encadre —
git clone https://git.schurger.org/encadre.git - Paquet : pypi.org/project/encadre
- Licence : GPLv3. Python 3.13 ou plus récent.
Il sert de base à une API en production. Cette page décrit ce qu'il fait réellement, version 0.3.2 ; ses exemples ont été vérifiés.
L'idée
- Une classe dérivée de
Controllerest un contrôleur ; chacune de ses méthodes publiques est une route :Books.show(self, book_id)répond sur/books/show/<book_id>. - Le verbe HTTP est un suffixe du nom :
save_POST,item_GET_POST. - Les versions d'une API sont des sous-classes :
Books_1(Books)est la version 1 deBooks, qui hérite de tout ce qu'elle ne redéfinit pas. - Les contrôleurs sont découverts par entry points : une application est un paquet, ses contrôleurs peuvent vivre dans d'autres paquets.
- Les tests d'un contrôleur sont ses méthodes
test_*; une méthode sans test fait échouer la suite.
Installation
pip install encadre
Les dépendances suivent : Flask, flask-cors, flask-jwt-extended, fastjsonschema, decorator, cryptography.
Une application minimale
Une application doit être un paquet installé : c'est par ses métadonnées que encadre trouve ses contrôleurs. Arborescence :
librairie/
├── pyproject.toml
├── config.ini
├── librairie/__init__.py # l'application
└── librairie_api/__init__.py # les contrôleurs
pyproject.toml
Le groupe d'entry points <application>.controllers liste les
modules de contrôleurs ; <application> est le nom de la classe de
l'application en minuscules.
[project]
name = "librairie"
version = "0.1.0"
dependencies = ["encadre"]
[project.scripts]
librairie-serve = "librairie:serve"
librairie-routes = "librairie:routes"
[project.entry-points."librairie.controllers"]
api = "librairie_api"
[tool.setuptools]
packages = ["librairie", "librairie_api"]L'application (librairie/__init__.py)
import sys
from encadre import Encadre
class Librairie(Encadre):
pass
def serve():
Librairie(sys.argv[1]).serve()
def routes():
Librairie(sys.argv[1]).dump_routes()Les contrôleurs (librairie_api/__init__.py)
from encadre import Controller
class Api(Controller):
"""Racine de l'API."""
def index__(self):
return "librairie"
class Books(Api):
"""Les livres."""
def index__(self):
return "liste des livres"
def show(self, book_id, fmt="json"):
return f"livre {book_id} en {fmt}"
def add_POST(self):
from encadre import request
return request.jsonLa configuration (config.ini)
La section qui porte le nom de l'application choisit le moteur ; les
sections [loggers] et suivantes
configurent la journalisation (sans elles, encadre affiche un
avertissement au démarrage) :
[librairie]
framework = flask
[flask]
PORT = 5001
HOST = 127.0.0.1
DEBUG = true
[loggers]
keys = root
[logger_root]
level = INFO
handlers = console
[handlers]
keys = console
[handler_console]
class = StreamHandler
args = (sys.stderr,)
formatter = simple
[formatters]
keys = simple
[formatter_simple]
format = {asctime} {levelname} {name}: {message}
style = {Le format utilise le style {} : encadre
relit le fichier avec l'interpolation de configparser, qui refuse les %(asctime)s habituels.
Lancer et interroger
pip install -e .
librairie-serve config.ini
curl http://127.0.0.1:5001/ # librairie
curl http://127.0.0.1:5001/books/ # liste des livres
curl http://127.0.0.1:5001/books/show/7 # livre 7 en json
curl http://127.0.0.1:5001/books/show/7/pdf # livre 7 en pdf
curl -X POST -H 'Content-Type: application/json' \
-d '{"titre": "Vendredi"}' http://127.0.0.1:5001/books/add
librairie-routes config.ini affiche
toutes les routes, par contrôleur et par version, avec les
docstrings.
Contrôleurs et routes
Racine et contrôleurs nommés
C'est la règle la plus surprenante (l'ancien README se trompait là-dessus) :
- une classe qui hérite directement de
Controllerest montée à la racine/: sonindex__répond sur/, ses méthodes sur/<méthode>; - une classe qui hérite d'une de celles-ci (ou plus bas) est un
contrôleur nommé :
Booksrépond sous/books/.
L'usage est donc d'écrire une classe de base de l'application (Api ci-dessus), puis les contrôleurs qui en
héritent. Une classe dont le nom commence par _ n'est pas montée, mais ses sous-classes le
sont.
Méthodes
| Méthode | Route |
|---|---|
index__(self) |
/books/ |
show(self, book_id) |
/books/show/<book_id> |
show(self, book_id, fmt="json") |
idem, et /books/show/<book_id>/<fmt> |
foo_(self) |
/books/foo_ |
_aide(self) |
aucune (privée) |
test_show(self) |
aucune (test, voir plus bas) |
Les arguments deviennent des segments du chemin ; un argument qui a
une valeur par défaut ajoute la variante sans lui. Les paramètres de
requête (?q=…) passent par request.args.
Verbes HTTP
Sans suffixe, une méthode répond à GET.
Le suffixe donne les verbes : bar_POST,
bar_GET_POST, item_PUT, etc. Deux méthodes bar_GET et bar_POST partagent l'URL /books/bar ; un verbe non déclaré reçoit 405.
Versions
Le nom d'une classe qui finit par _<nombre> déclare une version :
class Books(Api): # version 0 (ou first_version)
def index__(self):
return "liste v0"
class Books_1(Books): # version 1 : hérite de show(), add_POST()…
def index__(self):
return "liste v1"Le client choisit la version par un en-tête X-<application>-Version :
curl http://127.0.0.1:5001/books/ # liste v1
curl -H 'X-librairie-Version: 0' http://127.0.0.1:5001/books/ # liste v0
- sans en-tête, c'est la dernière version qui répond ;
- une version inconnue retombe sur la dernière, une version non
numérique reçoit
405; first_version = 1(section de l'application) numérote autrement la classe sans suffixe.
Avec versioning = prefix, la version
est le premier segment de l'URL : /1/books/. Dans ce mode, commencez à first_version = 1 : la version 0 est montée sans
préfixe et ne répond pas (voir les limites).
La requête
Pendant un appel, encadre expose la
requête du moteur sous une forme indépendante de Flask :
from encadre import request, cookies, config
request.json # corps JSON décodé
request.args # paramètres de l'URL
request.form # formulaire
request.files # fichiers envoyés
request.headers # en-têtes
request.environ # environnement WSGI
cookies.get("session")
cookies.set("session", "abc", httponly=True) # arguments de Flask set_cookie
config["librairie"]["framework"] # toutes les sections du fichier .iniUn cookie posé avec cookies.set part
avec la réponse de la requête en cours, et seulement elle (même avec un
serveur à plusieurs fils).
Envoyer un fichier :
def export(self):
return self.framework.send_bytes(
open("export.csv", "rb"), as_attachment=True,
download_name="export.csv", mimetype="text/csv")Les autres valeurs de retour sont celles de Flask : une chaîne, un
dict (rendu en JSON), un tuple (corps, code), etc.
Validation
Le décorateur validate passe son
argument (en pratique un schéma JSON) à la méthode validate_query de l'application ; encadre
n'impose pas le validateur. Avec fastjsonschema :
import fastjsonschema
from encadre import Encadre, Controller, validate
class Librairie(Encadre):
def validate_query(self, schema):
from encadre import request
fastjsonschema.compile(schema)(request.json) # lève si invalide
class Books(Api):
@validate({"type": "object",
"properties": {"titre": {"type": "string"}},
"required": ["titre"]})
def add_POST(self):
...Une requête invalide lève une exception, traitée comme toutes les
autres : la section suivante montre comment en faire une réponse 400.
Points d'accroche de l'application
La classe de l'application peut définir :
| Méthode | Rôle |
|---|---|
before_call(self) |
avant chaque requête (authentification, journal…) |
after_call(self) |
après chaque requête |
validate_query(self, s) |
ce que fait @validate(s) |
format_exception(self, e) |
transforme une exception en réponse : renvoie (corps, code) |
Sans format_exception, l'option on_exception de la section de l'application
décide : traceback (la trace, code 500),
str (le message, code 500) ; sinon
l'exception remonte au moteur.
Une API JSON définira typiquement :
import fastjsonschema
from werkzeug.exceptions import HTTPException
class Librairie(Encadre):
def format_exception(self, e):
if isinstance(e, HTTPException): # 404… de Flask
return {"error": e.description}, e.code
if isinstance(e, fastjsonschema.JsonSchemaException):
return {"error": e.message}, 400 # @validate
code = e.args[0] if e.args and isinstance(e.args[0], int) else 500
message = e.args[1] if len(e.args) > 1 else str(e)
return {"error": message}, code
# dans un contrôleur :
raise Exception(404, "livre inconnu") # → 404 {"error": "livre inconnu"}Configuration
Un seul fichier .ini, lu au démarrage
et accessible par encadre.config.
| Section | Clé | Effet |
|---|---|---|
[<application>] |
framework |
moteur (flask) — obligatoire |
versioning |
headers (défaut) ou prefix |
|
first_version |
numéro de la version sans suffixe (défaut 0) | |
on_exception |
traceback, str ou rien |
|
[flask] |
toute clé | copiée (en majuscules) dans la configuration Flask ; true, false et les
entiers sont convertis |
PORT, HOST, DEBUG, THREADED |
pour serve() (défauts 5001, 0.0.0.0,
vrai, faux) |
|
JWT_SECRET_KEY |
active flask-jwt-extended | |
external_cors |
ne pas gérer CORS (sinon : CORS avec credentials) | |
[loggers] et suivantes |
configuration logging standard (fileConfig), format en style {} |
serve() passe en HTTPS si SSL_CERT_FILE et SSL_KEY_FILE sont définies dans
l'environnement.
En production
serve() lance le serveur de
développement de Flask. En production, servir l'application WSGI :
# librairie_wsgi.py
from librairie import Librairie
application = Librairie("config.ini").get_wsgi_app()gunicorn -b 127.0.0.1:5001 librairie_wsgi:application
# ou
uwsgi --http 127.0.0.1:5001 --wsgi-file librairie_wsgi.py
Un objet contrôleur est créé à chaque requête : rien n'est partagé entre deux appels, sauf ce que vous mettez vous-même dans la classe ou le module.
Tests
Les tests d'un contrôleur sont ses méthodes test_<méthode> ; get_controllers_tsts en fait des cas unittest et ajoute un test qui échoue (« Missing
test ») pour chaque méthode qui n'en a pas.
class Books(Api):
def add_POST(self):
from encadre import request
return request.json
def test_add_POST(self):
from encadre import request
request.json = {"titre": "Vendredi"} # la requête se simule
self.assertEqual(self.add_POST()["titre"], "Vendredi")# test/__init__.py
from librairie import Librairie
from encadre.controllers import get_controllers_tsts
locals().update(get_controllers_tsts(Librairie("config.ini")))python -m unittest test
Les tests tournent sur un moteur factice : request.json, request.args, request.headers et les cookies s'y règlent à la
main. Un attribut de classe testcase_cls
remplace la classe de base des cas de test.
Limites connues
- Un seul moteur existe : Flask (les moteurs sont des greffons du
groupe
encadre.frameworks). - Une route inconnue répond
500et non404: l'erreur 404 passe par le même traitement que les exceptions. Unformat_exceptionqui reconnaîtHTTPException(exemple plus haut) rétablit le bon code. - Le fichier de configuration passe par l'interpolation de
configparser: un%littéral s'écrit%%. - En mode
versioning = prefix, la version 0 n'est pas joignable : commencer àfirst_version = 1. - Le contrôleur ne voit pas la requête Flask directement : passer par
encadre.requestgarde le code testable (et indépendant du moteur).
Historique
Le dépôt public commence en décembre 2024. Le code a quitté GitHub en octobre 2026 pour git.schurger.org, qui en garde tout l'historique.