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.

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 Controller is 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 of Books, 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.json

The 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 Controller is mounted at the root /: its index__ answers on /, its methods on /<method>;
  • a class that inherits from one of those (or further down) is a named controller: Books answers 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 file

A 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.frameworks group).
  • An unknown route answers 500 instead of 404: the 404 error goes through the same handling as exceptions. A format_exception that recognizes HTTPException (example above) restores the right code.
  • The configuration file goes through configparser interpolation: a literal % is written %%.
  • In versioning = prefix mode, version 0 cannot be reached: start at first_version = 1.
  • The controller does not see the Flask request directly: going through encadre.request keeps 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.