avalon
Real-time web framework. HTTP routes, WebSockets and broadcast channels in one
small, dependency-free package built on asyncio.
Avalon is for apps where the server pushes data to clients as it happens, such as chat, live dashboards, multiplayer cursors and notifications. You don't have to wire an HTTP framework, a WebSocket library and a pub/sub layer together yourself. Everything is plain standard-library Python you can read in one sitting.
Features
- Zero dependencies: pure standard library (
asyncio,hashlib,json, ...), Python 3.10+. - Decorator routing with typed path parameters:
{id:int},{x:float},{rest:path}. - Sync or async handlers: return a
Response, adict/list(JSON),str,bytesorNone. - WebSockets built in (RFC 6455): text/binary messages, fragmentation, automatic ping/pong,
close handshake, size limits and
async for msg in wsiteration. - Broadcast channels:
app.hub.join(room, ws)andawait app.hub.broadcast(room, msg). Disconnected sockets are cleaned up automatically. - HTTP/1.1 keep-alive,
HEADsupport and JSON error responses (HTTPError). - WebSocket client (
avalon.connect) for tests, scripts and service-to-service links. - Fully type-hinted (
py.typed).
Install
pip install avalon
# or, from a checkout:
pip install .
Quickstart
from avalon import Avalon
app = Avalon()
@app.get("/hello/{name}")
def hello(request):
return {"hello": request.params["name"]}
@app.websocket("/ws/{room}")
async def room(ws):
name = ws.params["room"]
app.hub.join(name, ws)
async for message in ws:
await app.hub.broadcast(name, {"room": name, "text": message})
if __name__ == "__main__":
app.run("127.0.0.1", 8000)
Talk to it from Python:
import asyncio
from avalon import connect
async def main():
ws = await connect("ws://127.0.0.1:8000/ws/lobby")
await ws.send("hi!")
print(await ws.receive_json()) # {'room': 'lobby', 'text': 'hi!'}
await ws.close()
asyncio.run(main())
A complete multi-room chat with a browser UI lives in examples/chat.py:
PYTHONPATH=src python3 examples/chat.py # http://127.0.0.1:8000
PYTHONPATH=src python3 examples/chat.py --demo # scripted two-client demo, then exit
API overview
Everything below is importable from the top-level avalon package.
Avalon(*, keep_alive_timeout=15.0)
| Member | Description |
|---|---|
@app.route(path, methods=("GET",)) |
Register an HTTP handler handler(request); may be sync or async. |
@app.get(path) / @app.post(path) |
Shorthands for route with one method. |
@app.websocket(path) |
Register an async def handler(ws). Non-async functions raise TypeError. |
app.hub |
The app's Hub (broadcast channels). |
app.sockets |
set[WebSocket] of connections currently being served. |
app.router |
The underlying Router. |
await app.dispatch(request) |
Route a Request and return a Response (never raises). |
await app.handle_connection(reader, writer) |
Serve one TCP connection (for custom servers). |
await app.serve(host="127.0.0.1", port=8000) |
Start listening and return an asyncio.Server (port=0 picks a free port). |
app.run(host="127.0.0.1", port=8000) |
Blocking: serve forever until Ctrl+C. |
GET routes also answer HEAD. Unknown paths return 404, a known path with the wrong
method returns 405, and an unhandled exception returns 500 (logged on the avalon logger).
All error bodies are JSON: {"error": "..."}.
Handler return values: to_response(value)
| Return | Response |
|---|---|
Response (or subclass) |
sent as-is |
dict / list |
JSONResponse (application/json) |
str |
text/plain; charset=utf-8 |
bytes |
application/octet-stream |
None |
204 No Content |
Anything else raises TypeError, which the app turns into a 500.
HTTP
Request:method,path(URL-decoded),query: dict[str, str],headers: dict[str, str](lower-case names),body: bytes,params: dict(converted path params),version; methodstext()andjson()(raisesHTTPError(400)on invalid JSON); propertieskeep_aliveandis_websocket.Response(body=b"", status=200, headers=None, media_type=None): the default media type istext/plain; charset=utf-8.encode(keep_alive=False)returns the wire bytes.JSONResponse(body, status=200, headers=None): serializes any JSON-compatiblebodywithjson.dumps.HTMLResponse(body, status=200, headers=None):text/html; charset=utf-8.HTTPError(status, detail=None): raise it in a handler to return{"error": detail}with that status.detaildefaults to the standard reason phrase.
WebSockets
WebSocket:path,params,headers,state(a free-form dict),closed,close_code,max_sizeawait ws.send(str | bytes)sends a text or binary message.await ws.send_json(obj)sends JSON as text.await ws.receive() -> str | bytesandawait ws.receive_json().async for msg in ws:yields messages until the peer closes.await ws.ping(data=b"")andawait ws.close(code=1000, reason="").
WebSocketClosed(code, reason): raised byreceive/sendonce the connection is closed. Inside a handler you can let it propagate, because the app treats it as a normal disconnect. An unhandled exception in a handler closes the socket with code1011.Opcode: frame opcode enum (TEXT,BINARY,CLOSE,PING,PONG,CONTINUATION).await connect(url, *, headers=None) -> WebSocket: client forws://URLs. It raisesHandshakeError(with.status) if the server rejects the upgrade.
Channels: Hub
| Method | Description |
|---|---|
join(channel, ws) / leave(channel, ws) |
Subscribe or unsubscribe a socket. |
leave_all(ws) |
Remove a socket from every channel (done automatically on disconnect). |
members(channel) -> set[WebSocket] |
Snapshot of a channel's sockets. |
channels() -> list[str] |
Sorted names of non-empty channels. |
await broadcast(channel, message, *, exclude=None) -> int |
Send to all members concurrently. Non-str/bytes messages are JSON-encoded. Dead sockets are dropped. Returns the delivery count. |
Routing
Router():add(path, handler, methods=("GET",), kind="http" | "websocket") -> Routeandresolve(path, method="GET", kind="http") -> (Route, params), which raisesHTTPError(404/405).Route:path,handler,methods,kind,match(path) -> dict | None.- Path converters:
{name}/{name:str}(one segment),{name:int},{name:float},{name:path}(may contain/). The first registered match wins.
Limitations
Avalon is deliberately small. It has no TLS (put it behind a reverse proxy), no
WebSocket extensions or subprotocol negotiation, no chunked request bodies (they are rejected with 501), and
hubs are in-process only, so they don't fan out across multiple workers.
Development
python3 -m unittest discover -s tests -v
# or, with pytest installed:
python3 -m pytest
License
MIT
Metadata
Release files for avalon 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| avalon-0.2.0.tar.gz | 21.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| avalon-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 39.8 kB
Release files / avalon-0.2.0.tar.gz
| Download URL | avalon-0.2.0.tar.gz |
|---|---|
| Size | 21.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
31d3986169b2b40bcaf3457fafd76f603874ab4afbee83fbb51b5899d78a0a83
|
|
BLAKE2b-256 checksum How to use checksums |
af2d5171134aa08e05747188498d4a565160f8d27683fb4a836d495510ad8a24
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / avalon-0.2.0-py3-none-any.whl
| Download URL | avalon-0.2.0-py3-none-any.whl |
|---|---|
| Size | 18.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6496c0491492047ac8abb05c84d02f041aaf99358930f8590add2fe35134385e
|
|
BLAKE2b-256 checksum How to use checksums |
02e3edf5257942cbeaaec21522291aa3bbbb2b445f1896fe802ac3ff209847f4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|