Version française : encadre en français.
encadre is a small web framework for Python: you write classes, their methods become HTTP routes. It runs on Flask today but is not tied to it: the web engine is a plugin chosen in the configuration.
- Code: git.schurger.org/encadre —
git clone https://git.schurger.org/encadre.git - Package: pypi.org/project/encadre
- License: GPLv3. Python 3.13 or newer.
It powers an API in production. This page describes what it actually does, version 0.3.2; its examples have been checked.
The idea
- A class derived from
Controlleris a controller; each of its public methods is a route:Books.show(self, book_id)answers on/books/show/<book_id>. - The HTTP verb is a suffix of the name:
save_POST,item_GET_POST. - API versions are subclasses:
Books_1(Books)is version 1 ofBooks, and inherits everything it does not redefine. - Controllers are discovered through entry points: an application is a package, and its controllers can live in other packages.
- A controller's tests are its
test_*methods; a method without a test fails the suite.
Installation
pip install encadre
Dependencies come along: Flask, flask-cors, flask-jwt-extended, fastjsonschema, decorator, cryptography.
A minimal application
An application must be an installed package: encadre finds its controllers through the package metadata. Layout:
bookshop/
├── pyproject.toml
├── config.ini
├── bookshop/__init__.py # the application
└── bookshop_api/__init__.py # the controllers
pyproject.toml
The <application>.controllers
entry point group lists the controller modules; <application> is the application class
name in lowercase.
[project]
name = "bookshop"
version = "0.1.0"
dependencies = ["encadre"]
[project.scripts]
bookshop-serve = "bookshop:serve"
bookshop-routes = "bookshop:routes"
[project.entry-points."bookshop.controllers"]
api = "bookshop_api"
[tool.setuptools]
packages = ["bookshop", "bookshop_api"]The application (bookshop/__init__.py)
import sys
from encadre import Encadre
class Bookshop(Encadre):
pass
def serve():
Bookshop(sys.argv[1]).serve()
def routes():
Bookshop(sys.argv[1]).dump_routes()The controllers (bookshop_api/__init__.py)
from encadre import Controller
class Api(Controller):
"""API root."""
def index__(self):
return "bookshop"
class Books(Api):
"""Books."""
def index__(self):
return "list of books"
def show(self, book_id, fmt="json"):
return f"book {book_id} as {fmt}"
def add_POST(self):
from encadre import request
return request.jsonThe configuration (config.ini)
The section named after the application chooses the engine; the [loggers] sections and the following ones
configure logging (without them, encadre prints a warning at
startup):
[bookshop]
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 = {The format uses the {} style: encadre
reads the file again with configparser
interpolation, which rejects the usual %(asctime)s.
Run and query
pip install -e .
bookshop-serve config.ini
curl http://127.0.0.1:5001/ # bookshop
curl http://127.0.0.1:5001/books/ # list of books
curl http://127.0.0.1:5001/books/show/7 # book 7 as json
curl http://127.0.0.1:5001/books/show/7/pdf # book 7 as pdf
curl -X POST -H 'Content-Type: application/json' \
-d '{"title": "Friday"}' http://127.0.0.1:5001/books/add
bookshop-routes config.ini prints every
route, per controller and per version, with the docstrings.
Controllers and routes
Root and named controllers
This is the most surprising rule (the old README got it wrong):
- a class that inherits directly from
Controlleris mounted at the root/: itsindex__answers on/, its methods on/<method>; - a class that inherits from one of those (or further down) is a named
controller:
Booksanswers under/books/.
So the usual pattern is an application base class (Api above), then the controllers inheriting from
it. A class whose name starts with _ is
not mounted, but its subclasses are.
Methods
| Method | Route |
|---|---|
index__(self) |
/books/ |
show(self, book_id) |
/books/show/<book_id> |
show(self, book_id, fmt="json") |
same, and /books/show/<book_id>/<fmt> |
foo_(self) |
/books/foo_ |
_helper(self) |
none (private) |
test_show(self) |
none (test, see below) |
Arguments become path segments; an argument with a default value adds
the variant without it. Query parameters (?q=…) go through request.args.
HTTP verbs
Without a suffix, a method answers GET.
The suffix gives the verbs: bar_POST,
bar_GET_POST, item_PUT, etc. Two methods bar_GET and bar_POST share the /books/bar URL; an undeclared verb gets 405.
Versions
A class name ending with _<number> declares a version:
class Books(Api): # version 0 (or first_version)
def index__(self):
return "list v0"
class Books_1(Books): # version 1: inherits show(), add_POST()…
def index__(self):
return "list v1"The client picks the version with an X-<application>-Version header:
curl http://127.0.0.1:5001/books/ # list v1
curl -H 'X-bookshop-Version: 0' http://127.0.0.1:5001/books/ # list v0
- without the header, the latest version answers;
- an unknown version falls back to the latest, a non-numeric one gets
405; first_version = 1(application section) numbers the unsuffixed class differently.
With versioning = prefix, the version
is the first URL segment: /1/books/. In
that mode, start at first_version = 1:
version 0 is mounted without a prefix and does not answer (see the
limitations).
The request
During a call, encadre exposes the
engine's request in a Flask-independent way:
from encadre import request, cookies, config
request.json # decoded JSON body
request.args # URL parameters
request.form # form
request.files # uploaded files
request.headers # headers
request.environ # WSGI environment
cookies.get("session")
cookies.set("session", "abc", httponly=True) # Flask set_cookie arguments
config["bookshop"]["framework"] # every section of the .ini fileA cookie set with cookies.set goes out
with the response of the current request, and only that one (even with a
multi-threaded server).
Sending a file:
def export(self):
return self.framework.send_bytes(
open("export.csv", "rb"), as_attachment=True,
download_name="export.csv", mimetype="text/csv")Other return values are Flask's: a string, a dict (rendered as JSON), a (body, code) tuple, etc.
Validation
The validate decorator hands its
argument (in practice a JSON schema) to the application's validate_query method; encadre does not impose
the validator. With fastjsonschema:
import fastjsonschema
from encadre import Encadre, Controller, validate
class Bookshop(Encadre):
def validate_query(self, schema):
from encadre import request
fastjsonschema.compile(schema)(request.json) # raises if invalid
class Books(Api):
@validate({"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"]})
def add_POST(self):
...An invalid request raises an exception, handled like any other: the
next section shows how to turn it into a 400 response.
Application hooks
The application class can define:
| Method | Role |
|---|---|
before_call(self) |
before each request (authentication, logging…) |
after_call(self) |
after each request |
validate_query(self, s) |
what @validate(s) does |
format_exception(self, e) |
turns an exception into a response: returns (body, code) |
Without format_exception, the on_exception option of the application section
decides: traceback (the traceback, code
500), str (the message, code 500);
otherwise the exception goes up to the engine.
A JSON API will typically define:
import fastjsonschema
from werkzeug.exceptions import HTTPException
class Bookshop(Encadre):
def format_exception(self, e):
if isinstance(e, HTTPException): # Flask's 404…
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
# in a controller:
raise Exception(404, "unknown book") # → 404 {"error": "unknown book"}Configuration
A single .ini file, read at startup and
available as encadre.config.
| Section | Key | Effect |
|---|---|---|
[<application>] |
framework |
engine (flask) — required |
versioning |
headers (default) or prefix |
|
first_version |
number of the unsuffixed version (default 0) | |
on_exception |
traceback, str or nothing |
|
[flask] |
any key | copied (uppercased) into Flask's config; true, false and
integers are converted |
PORT, HOST, DEBUG, THREADED |
for serve() (defaults 5001, 0.0.0.0,
true, false) |
|
JWT_SECRET_KEY |
enables flask-jwt-extended | |
external_cors |
do not handle CORS (otherwise: CORS with credentials) | |
[loggers] and next |
standard logging configuration (fileConfig), {}
style format |
serve() switches to HTTPS when SSL_CERT_FILE and SSL_KEY_FILE are set in the environment.
In production
serve() starts Flask's development
server. In production, serve the WSGI application:
# bookshop_wsgi.py
from bookshop import Bookshop
application = Bookshop("config.ini").get_wsgi_app()gunicorn -b 127.0.0.1:5001 bookshop_wsgi:application
# or
uwsgi --http 127.0.0.1:5001 --wsgi-file bookshop_wsgi.py
A controller object is created for each request: nothing is shared between two calls, except what you put yourself in the class or the module.
Tests
A controller's tests are its test_<method> methods; get_controllers_tsts turns them into unittest cases and adds a failing test ("Missing
test") for every method that has none.
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 = {"title": "Friday"} # the request is simulated
self.assertEqual(self.add_POST()["title"], "Friday")# test/__init__.py
from bookshop import Bookshop
from encadre.controllers import get_controllers_tsts
locals().update(get_controllers_tsts(Bookshop("config.ini")))python -m unittest test
Tests run on a fake engine: request.json, request.args, request.headers and the cookies are set by hand.
A testcase_cls class attribute replaces
the base class of the test cases.
Known limitations
- Only one engine exists: Flask (engines are plugins of the
encadre.frameworksgroup). - An unknown route answers
500instead of404: the 404 error goes through the same handling as exceptions. Aformat_exceptionthat recognizesHTTPException(example above) restores the right code. - The configuration file goes through
configparserinterpolation: a literal%is written%%. - In
versioning = prefixmode, version 0 cannot be reached: start atfirst_version = 1. - The controller does not see the Flask request directly: going
through
encadre.requestkeeps the code testable (and engine-independent).
History
The public repository starts in December 2024. The code left GitHub in October 2026 for git.schurger.org, which keeps its whole history.