Metadata-Version: 2.4
Name: avalon
Version: 0.2.0
Summary: Real-time web framework: HTTP routes, WebSockets and broadcast channels on pure asyncio
Author: nehz
License: MIT
Keywords: web,framework,websocket,real-time,asyncio,pubsub
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Dynamic: license-file

# 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`, a `dict`/`list` (JSON), `str`, `bytes` or `None`.
- **WebSockets built in** (RFC 6455): text/binary messages, fragmentation, automatic ping/pong,
  close handshake, size limits and `async for msg in ws` iteration.
- **Broadcast channels**: `app.hub.join(room, ws)` and `await app.hub.broadcast(room, msg)`.
  Disconnected sockets are cleaned up automatically.
- **HTTP/1.1 keep-alive**, `HEAD` support and JSON error responses (`HTTPError`).
- **WebSocket client** (`avalon.connect`) for tests, scripts and service-to-service links.
- Fully type-hinted (`py.typed`).

## Install

```bash
pip install avalon
# or, from a checkout:
pip install .
```

## Quickstart

```python
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:

```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`:

```bash
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`;
  methods `text()` and `json()` (raises `HTTPError(400)` on invalid JSON); properties
  `keep_alive` and `is_websocket`.
- `Response(body=b"", status=200, headers=None, media_type=None)`: the default media type is
  `text/plain; charset=utf-8`. `encode(keep_alive=False)` returns the wire bytes.
- `JSONResponse(body, status=200, headers=None)`: serializes any JSON-compatible `body` with `json.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. `detail` defaults to the standard reason phrase.

### WebSockets

- `WebSocket`: `path`, `params`, `headers`, `state` (a free-form dict), `closed`, `close_code`, `max_size`
  - `await ws.send(str | bytes)` sends a text or binary message. `await ws.send_json(obj)` sends JSON as text.
  - `await ws.receive() -> str | bytes` and `await ws.receive_json()`.
  - `async for msg in ws:` yields messages until the peer closes.
  - `await ws.ping(data=b"")` and `await ws.close(code=1000, reason="")`.
- `WebSocketClosed(code, reason)`: raised by `receive`/`send` once 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 code `1011`.
- `Opcode`: frame opcode enum (`TEXT`, `BINARY`, `CLOSE`, `PING`, `PONG`, `CONTINUATION`).
- `await connect(url, *, headers=None) -> WebSocket`: client for `ws://` URLs. It raises
  `HandshakeError` (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") -> Route` and
  `resolve(path, method="GET", kind="http") -> (Route, params)`, which raises `HTTPError(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

```bash
python3 -m unittest discover -s tests -v
# or, with pytest installed:
python3 -m pytest
```

## License

MIT
