11  WSGI, Flask, Django

HTTP

First, HTTP Review.

WSGI

Python offers a common interface to interact with HTTP known as WSGI.

Web Server Gateway Interface, a common interface allowing a Python program to treat an HTTP request as a call to a Python function, which in turn returns a

  • status code
  • headers
  • body

What we think of as a complete HTTP Response.

Example 01: simplest WSGI app

from typing import Callable, Iterable
from wsgiref.simple_server import make_server

def application(environ: dict[str, str], start_response: Callable[[str, dict[str, str]]]) -> Iterable[bytes]:
    status = '200 OK'
    headers = [('Content-Type', 'text/plain')]
    start_response(status, headers)
    return [b'Hello, World!']

server = make_server('localhost', 8000, application)
server.serve_forever()

Specification: https://peps.python.org/pep-0333/

Example 02: path routing and URL parameters

from wsgiref.simple_server import make_server
from urllib.parse import parse_qs


def application(environ, start_response):
    match environ["PATH_INFO"]:
        case "/":
            status, body = "200 OK", """
            <h1>02wsgi.py</h1>
            <form action="/hello" method="GET">
                <input name="name" type="text" placeholder+"name" />
                <input type="submit">
            </form>
            """.encode()
        case "/hello":
            params = parse_qs(environ.get("QUERY_STRING", ""))
            name = params.get("name", ["World"])[0]
            status, body = "200 OK", f"<h1>Hello, {name}!</h1>".encode()
        case _:
            status, body = "404 Not Found", b"<h1>404</h1>"

    start_response(status, [("Content-Type", "text/html")])
    return [body]


server = make_server("localhost", 8000, application)
server.serve_forever()

Example 03: Streaming

from wsgiref.simple_server import make_server
import time


def app(environ, start_response):
    start_response("200 OK", [("Content-Type", "text/plain")])
    while True:
        yield f"{time.strftime('%H:%M:%S')}\n".encode()
        time.sleep(1)


server = make_server("localhost", 8000, app)
server.serve_forever()

Flask

Flask is a fairly thin wrapper around this idea. It offers a handful of convenience classes and methods for common cases:

Example 04: Flask

Our WSGI app converted to Flask:

from flask import Flask, request

app = Flask(__name__)

@app.route('/')
def index():
    return '''
    <h1>Flask version</h1>
    <form action="/hello" method="GET">
        <input name="name" type="text" placeholder="name" />
        <input type="submit">
    </form>
    '''

@app.route('/hello')
def hello():
    name = request.args.get('name', 'World')
    return f'<h1>Hello, {name}!</h1>'

if __name__ == "__main__":
    app.run(debug=True)

Example 05: Writing a MiniFlask

Flask isn’t particularly complicated, we can replicate the core functionality in a few lines:

from urllib.parse import parse_qs
from wsgiref.simple_server import make_server


class MiniFlask:
    def __init__(self):
        self.routes = {}

    def route(self, path):
        """
        app.route(...) returns a decorator that adds the route under
        the assigned path
        """
        def decorator(fn):
            self.routes[path] = fn
            return fn

        return decorator

    def __call__(self, environ, start_response):
        """
        Notice the signature matches the WSGI interface.

        __call__ invoked when we run `app = MiniFlask(); app(...)`

        It allows us to treat a class instance like a function
        which we use here so that we can store `self.routes` on the
        app "function" call for WSGI.
        """
        handler = self.routes.get(environ["PATH_INFO"])
        if handler:
            # will extract querystring and parse into our function
            # as 'args'
            #
            # Flask handles this with the global `request` object
            # set before the function is called.
            #
            # Django (and others) do something similar to what
            # we do here, but with more than the querystring
            # gathered into a *Request* object.
            args = parse_qs(environ.get("QUERY_STRING", ""))
            status, body = "200 OK", handler(args).encode()
        else:
            status, body = "404 Not Found", b"<h1>404</h1>"
        start_response(status, [("Content-Type", "text/html")])
        return [body]


app = MiniFlask()


@app.route("/")
def index(args):
    return """
    <form action="/hello" method="GET">
        <input name="name" type="text" placeholder="name" />
        <input type="submit">
    </form>
    """


@app.route("/hello")
def hello(args):
    name = args.get("name", ["World"])[0]
    return f"<h1>Hello, {name}!</h1>"


# app is callable via __call__
server = make_server("localhost", 8000, app)
server.serve_forever()

Middleware

We often find that there are things we want to do to every request:

  • add/verify security headers
  • augment/convert responses
  • log requests, trace performance, etc.

The simplicity of the WSGI interface:

def application(environ: dict[str, str], start_response: Callable[[str, dict[str, str]]]) -> Iterable[bytes]:

provides us a single point where we have the opportunity to capture/modify incoming requests (environ), or to intercept outgoing responses.

def app(environ, start_response):
    environ = preprocess(environ)        # preprocess incoming request
    view_handler = pick_handler(environ) # dispatch based on URL
    status, headers, raw_response = view_handler(environ)  # call view

    # postprocess response
    new_response, new_status, new_headers = postprocess(
       status, headers, raw_response, environ
    )

    start_response(new_status, new_headers)  # reply to web server
    return new_response

We’ve seen this pattern before when discussing decorators.

In practice when we’re wrapping every request, we can decorate the app itself and not the individual views.

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

Django

Django is a more full-featured web application framework.

It encourages you to segment your logic into:

  • models.py - handle your relationship to your database and other storage
  • views.py - functions to handle individual URL routes
  • urls.py - mapping of URLs to views (separation allows views to more easily serve different routes)

It also has a built-in template engine, allowing designers to write HTML that accesses selected functionality exposed by the backend.

In addition to these core features replicating Jinja functionality it provides:

  • middlewares
  • signals - ways for models/views to send messages to other objects when certain things occur (user added -> auto create profile; new message -> trigger email in queue)
  • built-in applications, pre-written/bundled models/views/templates used for common tasks (auth, database-backed CRUD, etc.)
  • a comprehensive test framework that provides mocks

Example Django App

uv add django
uv run django-admin startproject notes
cd notes
uv run ./manage.py startapp core
notes/
  manage.py
  notes/
    settings.py
    urls.py
  core/
    views.py
    urls.py
    templates/
      index.html

views.py

import json
import random
from django.http import JsonResponse
from django.shortcuts import render
from .models import Note

COLORS = ["khaki", "lightblue", "lightgreen", "lightsalmon", "plum"]
notes = []


def index(request):
    return render(request, "index.html")


def api_notes(request):
    if request.method == "GET":
        return JsonResponse(notes, safe=False)
    elif request.method == "POST":
        data = json.loads(request.body)
        notes.append({"text": data["text"], "color": random.choice(COLORS)})
        return JsonResponse(notes, safe=False, status=201)


def api_notes_persist(request):
    if request.method == "GET":
        notes = list(Note.objects.values("text", "color"))
        return JsonResponse(notes, safe=False)
    elif request.method == "POST":
        data = json.loads(request.body)
        Note.objects.create(text=data["text"], color=random.choice(COLORS))
        notes = list(Note.objects.values("text", "color"))
        return JsonResponse(notes, safe=False, status=201)

With Models

models.py

from django.db import models


class Note(models.Model):
    text = models.TextField()
    color = models.CharField(max_length=20)

views.py

import json
import random
from django.http import JsonResponse
from django.shortcuts import render
from .models import Note

COLORS = ["khaki", "lightblue", "lightgreen", "lightsalmon", "plum"]
notes = []


def index(request):
    return render(request, "index.html")


def api_notes(request):
    if request.method == "GET":
        return JsonResponse(notes, safe=False)
    elif request.method == "POST":
        data = json.loads(request.body)
        notes.append({"text": data["text"], "color": random.choice(COLORS)})
        return JsonResponse(notes, safe=False, status=201)


def api_notes_persist(request):
    if request.method == "GET":
        notes = list(Note.objects.values("text", "color"))
        return JsonResponse(notes, safe=False)
    elif request.method == "POST":
        data = json.loads(request.body)
        Note.objects.create(text=data["text"], color=random.choice(COLORS))
        notes = list(Note.objects.values("text", "color"))
        return JsonResponse(notes, safe=False, status=201)

Making Choices

Flask

  • Simple request/response mapping.
  • Can be a single file, or scale to several (Flask blueprints)
  • Good ecosystem for common tasks, can
  • Lets you make your own choices for models/templates/etc.
    • SQLAlchemy
    • Jinja2
  • Built in debugger, aimed at getting productive quickly!

Small API, can learn in an afternoon: https://flask.palletsprojects.com/en/stable/

Django

  • django.contrib.gis
  • django.contrib.admin
  • django.contrib.auth
  • Better security posture out of the box.
  • MVT enforces more separation of code.
  • Huge “reusable apps” ecosystem.
  • Developing first-class async support, still somewhat in-progress.

Great documentation, but a lot to learn: https://www.djangoproject.com/start/