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)
| File | Size | Uploaded | |
|---|---|---|---|
| wred-0.2.tar.gz | 330.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|