Skip to main content

Wred

Wred handles HTTP routing, requests, and JSON responses. Need CORS, sessions, authentication, or compression? Wrap it with existing ASGI middleware. No need to reinvent what ASGI already makes possible.

Your API needs to return some JSON. It probably doesn't need a goddamn cathedral.

Oh, and it's pronounced "red", like the color.

What you get

  • Async handlers and a route decorator.
  • Request headers, cached body reads, and JSON parsing.
  • JSON responses (dicts and lists) & static file serving.
  • Response tuples: (body, content_type, status[, headers]).

What you don't get

Auth, sessions, databases, validation schemas, templates, CORS, CSRF protection, rate limiting, dependency injection, generated docs, background jobs, WebSockets, streaming, or a reason to argue about folder structure.

Your application chooses those pieces. Use existing libraries without making them everyone else's dependency. Wred doesn't need to know they exist.

Use it

Python 3.11+. Install from PyPI:

Procure your goods, yeah, use uv not pip, its 2026:

$ uv init my-app
$ cd my-app
$ uv add wred

Already have a uv project? Just run uv add wred. Granian and orjson come with it.

Put this in your pipe (app.py):

from wred import App

app = App()

@app.route('/')
async def say_hello(request):
    return {'hello': 'world'}

And smoke it:

uv run granian --interface asginl app:app

asginl disables lifespan handling. Wred is HTTP-only and rejects lifespan, WebSocket, and other unsupported scopes with RuntimeError, without sending HTTP messages. This follows the ASGI protocol guidance.

The server can run a middleware wrapper around Wred as its app too. Starlette's middleware works with other ASGI applications: CORS, GZip, TrustedHost, HTTPS redirects, and cookie sessions are available without changing Wred. Install only what your app needs; Starlette isn't a Wred dependency. Choose standard ASGI wrappers rather than integrations that require another framework's application internals.

Routes

Handlers are async. The first argument is the request. methods defaults to ['GET']; path parameters arrive as strings, passed by keyword.

@app.route('/users/{user_id}')
async def get_user(request, user_id):
    return {'id': user_id}

@app.route('/users', methods=['POST'])
async def create_user(request):
    return await request.json(), 'application/json', 201

@app.route('/users/{user_id}', methods=['PUT', 'PATCH'])
async def update_user(request, user_id):
    return {'id': user_id, 'method': request.method}

Different handlers can share a path with different methods. Fixed routes win over dynamic routes for the requested method; dynamic routes are checked in registration order. Registering the same path and method again replaces its handler. Unmatched paths or methods return JSON 404. No automatic HEAD, OPTIONS, or 405.

Mounted apps resolve routes relative to the ASGI root_path: a Wred route /users mounted at /api handles /api/users. request.path and the original ASGI scope keep the full incoming path for middleware and response objects.

Requests

API What you get
request.path URL path, without the query string.
request.method HTTP method, such as GET or POST.
request.headers Dictionary of decoded headers; use lowercase names.
request.scope Raw ASGI scope: query string, client address, scheme, etc.
await request.body() Raw bytes, cached after the first read.
await request.json() Parsed JSON; an empty body becomes {}.

JSON parsing isn't validation. Arrays and scalars parse too. Check your own inputs.

from urllib.parse import parse_qs
from wred import WredRequestError

@app.route('/echo', methods=['POST'])
async def echo(request):
    data = await request.json()
    if not isinstance(data, dict):
        raise WredRequestError('Send a JSON object')
    return {'received': data}

@app.route('/search')
async def search(request):
    query = parse_qs(request.scope.get('query_string', b'').decode('utf-8'))
    return {'q': query.get('q', [''])[0]}

Read a bearer credential with request.headers.get('authorization', ''). Check it in your application or let authentication middleware populate the ASGI scope, then enforce access in your handlers. Middleware can also add session data: Starlette's SessionMiddleware exposes it through request.scope['session'], not a Wred-specific session property. Its cookies are signed, not encrypted.

Responses

Return a dict or list for JSON with HTTP 200. Return bytes for application/octet-stream. No json() helper. No ceremony.

Handlers can also return callable ASGI response objects. Wred awaits them with (scope, receive, send), letting the response handle its own headers, status, and body. Response classes from ASGI libraries can work directly; Wred doesn't need a dependency or adapter for them.

For a custom response, return (body, content_type, status) or (body, content_type, status, headers). Headers are a list of string pairs, so repeated headers such as Set-Cookie work too.

@app.route('/items')
async def items(request):
    return [{'id': 1}, {'id': 2}]

@app.route('/items', methods=['POST'])
async def create_item(request):
    return {'id': 3}, 'application/json', 201, [('Location', '/items/3')]

@app.route('/old')
async def redirect(request):
    return b'', 'text/plain', 303, [('Location', '/')]

@app.route('/items/{item_id}', methods=['DELETE'])
async def delete_item(request, item_id):
    return b'', 'application/json', 204

Dicts and lists always get application/json, even inside a tuple. Other bodies must be bytes: encode text yourself, for example return 'hello'.encode(), 'text/plain; charset=utf-8', 200. Custom headers can carry cookies, cache policies, CORS rules, or Retry-After; Wred sends them, you decide what they mean. For behavior across every route, an ASGI wrapper can intercept requests and responses to apply CORS or compression. Keep that plumbing out of your handlers.

Static files

await app.static(filename, root=directory) returns (bytes, content_type, status). The root is required and accepts a string or Path.

from pathlib import Path

static_root = Path(__file__).parent / 'static'

@app.route('/')
async def index(request):
    return await app.static('index.html', root=static_root)

@app.route('/static/{filename}')
async def asset(request, filename):
    return await app.static(filename, root=static_root)

The helper guesses the MIME type and rejects files outside the root, including symlink escapes. Missing or unreadable files return a plain-text 404. Files are read in a worker thread, entirely into memory. Small files. Don't feed it a movie.

Errors

Malformed JSON and WredRequestError become HTTP 400 with {'error': message}. Unsupported handler response types become JSON 500 errors. Other exceptions aren't caught by Wred; fix your shit or handle them in your application.

Example app

The example_app notebook demonstrates signup, login, refresh cookies, logout, protected endpoints, note CRUD, and rate limiting. Those features belong to the example, not the framework. Clone the repository to try it:

git clone https://gitman.io/git/patx/wred
cd wred
uv sync
uv run granian --interface asginl --workers 1 example_app.app:app

Open http://127.0.0.1:8000. Demo data lives in memory and disappears on restart.

Benchmarks

GET / returning {"hello": "world"}, measured on October 13, 2026. Three runs per framework, with the run order rotated each round.

Framework Median requests/sec Min Max
Wred 0.01 272,088 262,187 277,656
BlackSheep 2.6.4 211,619 205,719 220,455
MicroPie 0.32 207,720 197,520 216,010

Python 3.14.4, Granian 2.8.4, eight workers, HTTP/1 keep-alive, asyncio, and access logging disabled. wrk -t4 -c256 -d20s --latency, after a five-second warmup for each run. Client and server shared the same machine over loopback. All nine measured runs had zero socket errors or non-success responses.

Wred delivered about 31% more requests/sec than MicroPie in this workload. It's a tiny JSON response, not your database, auth, or entire application. Benchmark your own shit.

Metadata

Release files for wred 0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for wred 0.2
File Size Uploaded
wred-0.2.tar.gz 330.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for wred 0.2
File Interpreter ABI Platform
wred-0.2-py3-none-any.whl Python 3 none any Details

Total release size: 339.2 kB

Release files / wred-0.2.tar.gz

Download URL wred-0.2.tar.gz
Size 330.9 kB
Tags Source
SHA-256 checksum
How to use checksums
c98df3650ac42fa0c9b7c0173892c2861fc198e56e65f6edddd7ac10ea8cd214
BLAKE2b-256 checksum
How to use checksums
532963075437f193501232dd55d810bc22cb51e779961623436b3f9f31471cc1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via python-requests/2.32.5

Release files / wred-0.2-py3-none-any.whl

Download URL wred-0.2-py3-none-any.whl
Size 8.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
621def50e33b7498e09a864484a7f496040c882ca8b61f730b17aa55d0d32047
BLAKE2b-256 checksum
How to use checksums
7209822863f19db48a001313e5f584d3027cbb438c8919ab06b5b635bb5bc470
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via python-requests/2.32.5

Release history Release notifications | RSS feed

0.5

2 release files

0.4

2 release files

0.3

2 release files

This release

0.2 This release

2 release files

0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page