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.

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 Controller est 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 de Books, 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.json

La 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 Controller est montée à la racine / : son index__ 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é : Books ré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 .ini

Un 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 500 et non 404 : l'erreur 404 passe par le même traitement que les exceptions. Un format_exception qui reconnaît HTTPException (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.request garde 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.