avalon 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- avalon-0.2.0/LICENSE +21 -0
- avalon-0.2.0/PKG-INFO +200 -0
- avalon-0.2.0/README.md +172 -0
- avalon-0.2.0/pyproject.toml +43 -0
- avalon-0.2.0/setup.cfg +4 -0
- avalon-0.2.0/src/avalon/__init__.py +32 -0
- avalon-0.2.0/src/avalon/app.py +220 -0
- avalon-0.2.0/src/avalon/channels.py +75 -0
- avalon-0.2.0/src/avalon/client.py +68 -0
- avalon-0.2.0/src/avalon/http.py +191 -0
- avalon-0.2.0/src/avalon/py.typed +0 -0
- avalon-0.2.0/src/avalon/routing.py +108 -0
- avalon-0.2.0/src/avalon/websocket.py +230 -0
- avalon-0.2.0/src/avalon.egg-info/PKG-INFO +200 -0
- avalon-0.2.0/src/avalon.egg-info/SOURCES.txt +19 -0
- avalon-0.2.0/src/avalon.egg-info/dependency_links.txt +1 -0
- avalon-0.2.0/src/avalon.egg-info/requires.txt +3 -0
- avalon-0.2.0/src/avalon.egg-info/top_level.txt +1 -0
- avalon-0.2.0/tests/test_app.py +208 -0
- avalon-0.2.0/tests/test_routing.py +70 -0
- avalon-0.2.0/tests/test_websocket.py +132 -0
avalon-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nehz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
avalon-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: avalon
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Real-time web framework: HTTP routes, WebSockets and broadcast channels on pure asyncio
|
|
5
|
+
Author: nehz
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: web,framework,websocket,real-time,asyncio,pubsub
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# avalon
|
|
30
|
+
|
|
31
|
+
**Real-time web framework.** HTTP routes, WebSockets and broadcast channels in one
|
|
32
|
+
small, dependency-free package built on `asyncio`.
|
|
33
|
+
|
|
34
|
+
Avalon is for apps where the server pushes data to clients as it happens, such
|
|
35
|
+
as chat, live dashboards, multiplayer cursors and notifications. You don't have to
|
|
36
|
+
wire an HTTP framework, a WebSocket library and a pub/sub layer together
|
|
37
|
+
yourself. Everything is plain standard-library Python you can read in one sitting.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Zero dependencies**: pure standard library (`asyncio`, `hashlib`, `json`, ...), Python 3.10+.
|
|
42
|
+
- **Decorator routing** with typed path parameters: `{id:int}`, `{x:float}`, `{rest:path}`.
|
|
43
|
+
- **Sync or async handlers**: return a `Response`, a `dict`/`list` (JSON), `str`, `bytes` or `None`.
|
|
44
|
+
- **WebSockets built in** (RFC 6455): text/binary messages, fragmentation, automatic ping/pong,
|
|
45
|
+
close handshake, size limits and `async for msg in ws` iteration.
|
|
46
|
+
- **Broadcast channels**: `app.hub.join(room, ws)` and `await app.hub.broadcast(room, msg)`.
|
|
47
|
+
Disconnected sockets are cleaned up automatically.
|
|
48
|
+
- **HTTP/1.1 keep-alive**, `HEAD` support and JSON error responses (`HTTPError`).
|
|
49
|
+
- **WebSocket client** (`avalon.connect`) for tests, scripts and service-to-service links.
|
|
50
|
+
- Fully type-hinted (`py.typed`).
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install avalon
|
|
56
|
+
# or, from a checkout:
|
|
57
|
+
pip install .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quickstart
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from avalon import Avalon
|
|
64
|
+
|
|
65
|
+
app = Avalon()
|
|
66
|
+
|
|
67
|
+
@app.get("/hello/{name}")
|
|
68
|
+
def hello(request):
|
|
69
|
+
return {"hello": request.params["name"]}
|
|
70
|
+
|
|
71
|
+
@app.websocket("/ws/{room}")
|
|
72
|
+
async def room(ws):
|
|
73
|
+
name = ws.params["room"]
|
|
74
|
+
app.hub.join(name, ws)
|
|
75
|
+
async for message in ws:
|
|
76
|
+
await app.hub.broadcast(name, {"room": name, "text": message})
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
app.run("127.0.0.1", 8000)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Talk to it from Python:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import asyncio
|
|
86
|
+
from avalon import connect
|
|
87
|
+
|
|
88
|
+
async def main():
|
|
89
|
+
ws = await connect("ws://127.0.0.1:8000/ws/lobby")
|
|
90
|
+
await ws.send("hi!")
|
|
91
|
+
print(await ws.receive_json()) # {'room': 'lobby', 'text': 'hi!'}
|
|
92
|
+
await ws.close()
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A complete multi-room chat with a browser UI lives in `examples/chat.py`:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
PYTHONPATH=src python3 examples/chat.py # http://127.0.0.1:8000
|
|
101
|
+
PYTHONPATH=src python3 examples/chat.py --demo # scripted two-client demo, then exit
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## API overview
|
|
105
|
+
|
|
106
|
+
Everything below is importable from the top-level `avalon` package.
|
|
107
|
+
|
|
108
|
+
### `Avalon(*, keep_alive_timeout=15.0)`
|
|
109
|
+
|
|
110
|
+
| Member | Description |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `@app.route(path, methods=("GET",))` | Register an HTTP handler `handler(request)`; may be sync or async. |
|
|
113
|
+
| `@app.get(path)` / `@app.post(path)` | Shorthands for `route` with one method. |
|
|
114
|
+
| `@app.websocket(path)` | Register an `async def handler(ws)`. Non-async functions raise `TypeError`. |
|
|
115
|
+
| `app.hub` | The app's `Hub` (broadcast channels). |
|
|
116
|
+
| `app.sockets` | `set[WebSocket]` of connections currently being served. |
|
|
117
|
+
| `app.router` | The underlying `Router`. |
|
|
118
|
+
| `await app.dispatch(request)` | Route a `Request` and return a `Response` (never raises). |
|
|
119
|
+
| `await app.handle_connection(reader, writer)` | Serve one TCP connection (for custom servers). |
|
|
120
|
+
| `await app.serve(host="127.0.0.1", port=8000)` | Start listening and return an `asyncio.Server` (`port=0` picks a free port). |
|
|
121
|
+
| `app.run(host="127.0.0.1", port=8000)` | Blocking: serve forever until Ctrl+C. |
|
|
122
|
+
|
|
123
|
+
`GET` routes also answer `HEAD`. Unknown paths return `404`, a known path with the wrong
|
|
124
|
+
method returns `405`, and an unhandled exception returns `500` (logged on the `avalon` logger).
|
|
125
|
+
All error bodies are JSON: `{"error": "..."}`.
|
|
126
|
+
|
|
127
|
+
### Handler return values: `to_response(value)`
|
|
128
|
+
|
|
129
|
+
| Return | Response |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| `Response` (or subclass) | sent as-is |
|
|
132
|
+
| `dict` / `list` | `JSONResponse` (`application/json`) |
|
|
133
|
+
| `str` | `text/plain; charset=utf-8` |
|
|
134
|
+
| `bytes` | `application/octet-stream` |
|
|
135
|
+
| `None` | `204 No Content` |
|
|
136
|
+
|
|
137
|
+
Anything else raises `TypeError`, which the app turns into a 500.
|
|
138
|
+
|
|
139
|
+
### HTTP
|
|
140
|
+
|
|
141
|
+
- `Request`: `method`, `path` (URL-decoded), `query: dict[str, str]`, `headers: dict[str, str]`
|
|
142
|
+
(lower-case names), `body: bytes`, `params: dict` (converted path params), `version`;
|
|
143
|
+
methods `text()` and `json()` (raises `HTTPError(400)` on invalid JSON); properties
|
|
144
|
+
`keep_alive` and `is_websocket`.
|
|
145
|
+
- `Response(body=b"", status=200, headers=None, media_type=None)`: the default media type is
|
|
146
|
+
`text/plain; charset=utf-8`. `encode(keep_alive=False)` returns the wire bytes.
|
|
147
|
+
- `JSONResponse(body, status=200, headers=None)`: serializes any JSON-compatible `body` with `json.dumps`.
|
|
148
|
+
- `HTMLResponse(body, status=200, headers=None)`: `text/html; charset=utf-8`.
|
|
149
|
+
- `HTTPError(status, detail=None)`: raise it in a handler to return `{"error": detail}` with
|
|
150
|
+
that status. `detail` defaults to the standard reason phrase.
|
|
151
|
+
|
|
152
|
+
### WebSockets
|
|
153
|
+
|
|
154
|
+
- `WebSocket`: `path`, `params`, `headers`, `state` (a free-form dict), `closed`, `close_code`, `max_size`
|
|
155
|
+
- `await ws.send(str | bytes)` sends a text or binary message. `await ws.send_json(obj)` sends JSON as text.
|
|
156
|
+
- `await ws.receive() -> str | bytes` and `await ws.receive_json()`.
|
|
157
|
+
- `async for msg in ws:` yields messages until the peer closes.
|
|
158
|
+
- `await ws.ping(data=b"")` and `await ws.close(code=1000, reason="")`.
|
|
159
|
+
- `WebSocketClosed(code, reason)`: raised by `receive`/`send` once the connection is closed.
|
|
160
|
+
Inside a handler you can let it propagate, because the app treats it as a normal disconnect. An
|
|
161
|
+
unhandled exception in a handler closes the socket with code `1011`.
|
|
162
|
+
- `Opcode`: frame opcode enum (`TEXT`, `BINARY`, `CLOSE`, `PING`, `PONG`, `CONTINUATION`).
|
|
163
|
+
- `await connect(url, *, headers=None) -> WebSocket`: client for `ws://` URLs. It raises
|
|
164
|
+
`HandshakeError` (with `.status`) if the server rejects the upgrade.
|
|
165
|
+
|
|
166
|
+
### Channels: `Hub`
|
|
167
|
+
|
|
168
|
+
| Method | Description |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `join(channel, ws)` / `leave(channel, ws)` | Subscribe or unsubscribe a socket. |
|
|
171
|
+
| `leave_all(ws)` | Remove a socket from every channel (done automatically on disconnect). |
|
|
172
|
+
| `members(channel) -> set[WebSocket]` | Snapshot of a channel's sockets. |
|
|
173
|
+
| `channels() -> list[str]` | Sorted names of non-empty channels. |
|
|
174
|
+
| `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. |
|
|
175
|
+
|
|
176
|
+
### Routing
|
|
177
|
+
|
|
178
|
+
- `Router()`: `add(path, handler, methods=("GET",), kind="http" | "websocket") -> Route` and
|
|
179
|
+
`resolve(path, method="GET", kind="http") -> (Route, params)`, which raises `HTTPError(404/405)`.
|
|
180
|
+
- `Route`: `path`, `handler`, `methods`, `kind`, `match(path) -> dict | None`.
|
|
181
|
+
- Path converters: `{name}` / `{name:str}` (one segment), `{name:int}`, `{name:float}`,
|
|
182
|
+
`{name:path}` (may contain `/`). The first registered match wins.
|
|
183
|
+
|
|
184
|
+
## Limitations
|
|
185
|
+
|
|
186
|
+
Avalon is deliberately small. It has no TLS (put it behind a reverse proxy), no
|
|
187
|
+
WebSocket extensions or subprotocol negotiation, no chunked request bodies (they are rejected with `501`), and
|
|
188
|
+
hubs are in-process only, so they don't fan out across multiple workers.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python3 -m unittest discover -s tests -v
|
|
194
|
+
# or, with pytest installed:
|
|
195
|
+
python3 -m pytest
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
avalon-0.2.0/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# avalon
|
|
2
|
+
|
|
3
|
+
**Real-time web framework.** HTTP routes, WebSockets and broadcast channels in one
|
|
4
|
+
small, dependency-free package built on `asyncio`.
|
|
5
|
+
|
|
6
|
+
Avalon is for apps where the server pushes data to clients as it happens, such
|
|
7
|
+
as chat, live dashboards, multiplayer cursors and notifications. You don't have to
|
|
8
|
+
wire an HTTP framework, a WebSocket library and a pub/sub layer together
|
|
9
|
+
yourself. Everything is plain standard-library Python you can read in one sitting.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Zero dependencies**: pure standard library (`asyncio`, `hashlib`, `json`, ...), Python 3.10+.
|
|
14
|
+
- **Decorator routing** with typed path parameters: `{id:int}`, `{x:float}`, `{rest:path}`.
|
|
15
|
+
- **Sync or async handlers**: return a `Response`, a `dict`/`list` (JSON), `str`, `bytes` or `None`.
|
|
16
|
+
- **WebSockets built in** (RFC 6455): text/binary messages, fragmentation, automatic ping/pong,
|
|
17
|
+
close handshake, size limits and `async for msg in ws` iteration.
|
|
18
|
+
- **Broadcast channels**: `app.hub.join(room, ws)` and `await app.hub.broadcast(room, msg)`.
|
|
19
|
+
Disconnected sockets are cleaned up automatically.
|
|
20
|
+
- **HTTP/1.1 keep-alive**, `HEAD` support and JSON error responses (`HTTPError`).
|
|
21
|
+
- **WebSocket client** (`avalon.connect`) for tests, scripts and service-to-service links.
|
|
22
|
+
- Fully type-hinted (`py.typed`).
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install avalon
|
|
28
|
+
# or, from a checkout:
|
|
29
|
+
pip install .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from avalon import Avalon
|
|
36
|
+
|
|
37
|
+
app = Avalon()
|
|
38
|
+
|
|
39
|
+
@app.get("/hello/{name}")
|
|
40
|
+
def hello(request):
|
|
41
|
+
return {"hello": request.params["name"]}
|
|
42
|
+
|
|
43
|
+
@app.websocket("/ws/{room}")
|
|
44
|
+
async def room(ws):
|
|
45
|
+
name = ws.params["room"]
|
|
46
|
+
app.hub.join(name, ws)
|
|
47
|
+
async for message in ws:
|
|
48
|
+
await app.hub.broadcast(name, {"room": name, "text": message})
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
app.run("127.0.0.1", 8000)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Talk to it from Python:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from avalon import connect
|
|
59
|
+
|
|
60
|
+
async def main():
|
|
61
|
+
ws = await connect("ws://127.0.0.1:8000/ws/lobby")
|
|
62
|
+
await ws.send("hi!")
|
|
63
|
+
print(await ws.receive_json()) # {'room': 'lobby', 'text': 'hi!'}
|
|
64
|
+
await ws.close()
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A complete multi-room chat with a browser UI lives in `examples/chat.py`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
PYTHONPATH=src python3 examples/chat.py # http://127.0.0.1:8000
|
|
73
|
+
PYTHONPATH=src python3 examples/chat.py --demo # scripted two-client demo, then exit
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API overview
|
|
77
|
+
|
|
78
|
+
Everything below is importable from the top-level `avalon` package.
|
|
79
|
+
|
|
80
|
+
### `Avalon(*, keep_alive_timeout=15.0)`
|
|
81
|
+
|
|
82
|
+
| Member | Description |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `@app.route(path, methods=("GET",))` | Register an HTTP handler `handler(request)`; may be sync or async. |
|
|
85
|
+
| `@app.get(path)` / `@app.post(path)` | Shorthands for `route` with one method. |
|
|
86
|
+
| `@app.websocket(path)` | Register an `async def handler(ws)`. Non-async functions raise `TypeError`. |
|
|
87
|
+
| `app.hub` | The app's `Hub` (broadcast channels). |
|
|
88
|
+
| `app.sockets` | `set[WebSocket]` of connections currently being served. |
|
|
89
|
+
| `app.router` | The underlying `Router`. |
|
|
90
|
+
| `await app.dispatch(request)` | Route a `Request` and return a `Response` (never raises). |
|
|
91
|
+
| `await app.handle_connection(reader, writer)` | Serve one TCP connection (for custom servers). |
|
|
92
|
+
| `await app.serve(host="127.0.0.1", port=8000)` | Start listening and return an `asyncio.Server` (`port=0` picks a free port). |
|
|
93
|
+
| `app.run(host="127.0.0.1", port=8000)` | Blocking: serve forever until Ctrl+C. |
|
|
94
|
+
|
|
95
|
+
`GET` routes also answer `HEAD`. Unknown paths return `404`, a known path with the wrong
|
|
96
|
+
method returns `405`, and an unhandled exception returns `500` (logged on the `avalon` logger).
|
|
97
|
+
All error bodies are JSON: `{"error": "..."}`.
|
|
98
|
+
|
|
99
|
+
### Handler return values: `to_response(value)`
|
|
100
|
+
|
|
101
|
+
| Return | Response |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `Response` (or subclass) | sent as-is |
|
|
104
|
+
| `dict` / `list` | `JSONResponse` (`application/json`) |
|
|
105
|
+
| `str` | `text/plain; charset=utf-8` |
|
|
106
|
+
| `bytes` | `application/octet-stream` |
|
|
107
|
+
| `None` | `204 No Content` |
|
|
108
|
+
|
|
109
|
+
Anything else raises `TypeError`, which the app turns into a 500.
|
|
110
|
+
|
|
111
|
+
### HTTP
|
|
112
|
+
|
|
113
|
+
- `Request`: `method`, `path` (URL-decoded), `query: dict[str, str]`, `headers: dict[str, str]`
|
|
114
|
+
(lower-case names), `body: bytes`, `params: dict` (converted path params), `version`;
|
|
115
|
+
methods `text()` and `json()` (raises `HTTPError(400)` on invalid JSON); properties
|
|
116
|
+
`keep_alive` and `is_websocket`.
|
|
117
|
+
- `Response(body=b"", status=200, headers=None, media_type=None)`: the default media type is
|
|
118
|
+
`text/plain; charset=utf-8`. `encode(keep_alive=False)` returns the wire bytes.
|
|
119
|
+
- `JSONResponse(body, status=200, headers=None)`: serializes any JSON-compatible `body` with `json.dumps`.
|
|
120
|
+
- `HTMLResponse(body, status=200, headers=None)`: `text/html; charset=utf-8`.
|
|
121
|
+
- `HTTPError(status, detail=None)`: raise it in a handler to return `{"error": detail}` with
|
|
122
|
+
that status. `detail` defaults to the standard reason phrase.
|
|
123
|
+
|
|
124
|
+
### WebSockets
|
|
125
|
+
|
|
126
|
+
- `WebSocket`: `path`, `params`, `headers`, `state` (a free-form dict), `closed`, `close_code`, `max_size`
|
|
127
|
+
- `await ws.send(str | bytes)` sends a text or binary message. `await ws.send_json(obj)` sends JSON as text.
|
|
128
|
+
- `await ws.receive() -> str | bytes` and `await ws.receive_json()`.
|
|
129
|
+
- `async for msg in ws:` yields messages until the peer closes.
|
|
130
|
+
- `await ws.ping(data=b"")` and `await ws.close(code=1000, reason="")`.
|
|
131
|
+
- `WebSocketClosed(code, reason)`: raised by `receive`/`send` once the connection is closed.
|
|
132
|
+
Inside a handler you can let it propagate, because the app treats it as a normal disconnect. An
|
|
133
|
+
unhandled exception in a handler closes the socket with code `1011`.
|
|
134
|
+
- `Opcode`: frame opcode enum (`TEXT`, `BINARY`, `CLOSE`, `PING`, `PONG`, `CONTINUATION`).
|
|
135
|
+
- `await connect(url, *, headers=None) -> WebSocket`: client for `ws://` URLs. It raises
|
|
136
|
+
`HandshakeError` (with `.status`) if the server rejects the upgrade.
|
|
137
|
+
|
|
138
|
+
### Channels: `Hub`
|
|
139
|
+
|
|
140
|
+
| Method | Description |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `join(channel, ws)` / `leave(channel, ws)` | Subscribe or unsubscribe a socket. |
|
|
143
|
+
| `leave_all(ws)` | Remove a socket from every channel (done automatically on disconnect). |
|
|
144
|
+
| `members(channel) -> set[WebSocket]` | Snapshot of a channel's sockets. |
|
|
145
|
+
| `channels() -> list[str]` | Sorted names of non-empty channels. |
|
|
146
|
+
| `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. |
|
|
147
|
+
|
|
148
|
+
### Routing
|
|
149
|
+
|
|
150
|
+
- `Router()`: `add(path, handler, methods=("GET",), kind="http" | "websocket") -> Route` and
|
|
151
|
+
`resolve(path, method="GET", kind="http") -> (Route, params)`, which raises `HTTPError(404/405)`.
|
|
152
|
+
- `Route`: `path`, `handler`, `methods`, `kind`, `match(path) -> dict | None`.
|
|
153
|
+
- Path converters: `{name}` / `{name:str}` (one segment), `{name:int}`, `{name:float}`,
|
|
154
|
+
`{name:path}` (may contain `/`). The first registered match wins.
|
|
155
|
+
|
|
156
|
+
## Limitations
|
|
157
|
+
|
|
158
|
+
Avalon is deliberately small. It has no TLS (put it behind a reverse proxy), no
|
|
159
|
+
WebSocket extensions or subprotocol negotiation, no chunked request bodies (they are rejected with `501`), and
|
|
160
|
+
hubs are in-process only, so they don't fan out across multiple workers.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
python3 -m unittest discover -s tests -v
|
|
166
|
+
# or, with pytest installed:
|
|
167
|
+
python3 -m pytest
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "avalon"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Real-time web framework: HTTP routes, WebSockets and broadcast channels on pure asyncio"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "nehz" }]
|
|
13
|
+
keywords = ["web", "framework", "websocket", "real-time", "asyncio", "pubsub"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = ["pytest>=7"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.package-data]
|
|
39
|
+
avalon = ["py.typed"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
pythonpath = ["src"]
|
avalon-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Avalon: a small, dependency-free real-time web framework for asyncio.
|
|
2
|
+
|
|
3
|
+
HTTP routes, WebSockets and broadcast channels in one tiny package.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .app import Avalon, to_response
|
|
7
|
+
from .channels import Hub
|
|
8
|
+
from .client import HandshakeError, connect
|
|
9
|
+
from .http import HTMLResponse, HTTPError, JSONResponse, Request, Response
|
|
10
|
+
from .routing import Route, Router
|
|
11
|
+
from .websocket import Opcode, WebSocket, WebSocketClosed
|
|
12
|
+
|
|
13
|
+
__version__ = "0.2.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Avalon",
|
|
17
|
+
"HTMLResponse",
|
|
18
|
+
"HTTPError",
|
|
19
|
+
"HandshakeError",
|
|
20
|
+
"Hub",
|
|
21
|
+
"JSONResponse",
|
|
22
|
+
"Opcode",
|
|
23
|
+
"Request",
|
|
24
|
+
"Response",
|
|
25
|
+
"Route",
|
|
26
|
+
"Router",
|
|
27
|
+
"WebSocket",
|
|
28
|
+
"WebSocketClosed",
|
|
29
|
+
"connect",
|
|
30
|
+
"to_response",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|