avalon 0.2.0__py3-none-any.whl
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/__init__.py +32 -0
- avalon/app.py +220 -0
- avalon/channels.py +75 -0
- avalon/client.py +68 -0
- avalon/http.py +191 -0
- avalon/py.typed +0 -0
- avalon/routing.py +108 -0
- avalon/websocket.py +230 -0
- avalon-0.2.0.dist-info/METADATA +200 -0
- avalon-0.2.0.dist-info/RECORD +13 -0
- avalon-0.2.0.dist-info/WHEEL +5 -0
- avalon-0.2.0.dist-info/licenses/LICENSE +21 -0
- avalon-0.2.0.dist-info/top_level.txt +1 -0
avalon/__init__.py
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
|
+
]
|
avalon/app.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The :class:`Avalon` application: routing, dispatch and the asyncio server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Awaitable, Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
from .channels import Hub
|
|
11
|
+
from .http import HTTPError, JSONResponse, Request, Response, read_request
|
|
12
|
+
from .routing import Router
|
|
13
|
+
from .websocket import WebSocket, WebSocketClosed, accept_key
|
|
14
|
+
|
|
15
|
+
__all__ = ["Avalon"]
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("avalon")
|
|
18
|
+
|
|
19
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
20
|
+
HTTPHandler = Callable[[Request], Any]
|
|
21
|
+
WebSocketHandler = Callable[[WebSocket], Awaitable[None]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def to_response(result: Any) -> Response:
|
|
25
|
+
"""Coerce a handler's return value into a :class:`Response`.
|
|
26
|
+
|
|
27
|
+
``Response`` is passed through; ``dict``/``list`` become JSON; ``str`` is
|
|
28
|
+
``text/plain``; ``bytes`` is ``application/octet-stream``; ``None`` is an
|
|
29
|
+
empty ``204 No Content``.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(result, Response):
|
|
32
|
+
return result
|
|
33
|
+
if isinstance(result, (dict, list)):
|
|
34
|
+
return JSONResponse(result)
|
|
35
|
+
if isinstance(result, str):
|
|
36
|
+
return Response(result)
|
|
37
|
+
if isinstance(result, (bytes, bytearray)):
|
|
38
|
+
return Response(bytes(result), media_type="application/octet-stream")
|
|
39
|
+
if result is None:
|
|
40
|
+
return Response(status=204)
|
|
41
|
+
raise TypeError(f"cannot convert {type(result).__name__} to a Response")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Avalon:
|
|
45
|
+
"""A real-time web application.
|
|
46
|
+
|
|
47
|
+
Register HTTP handlers with :meth:`route` (or :meth:`get`/:meth:`post`)
|
|
48
|
+
and WebSocket handlers with :meth:`websocket`, then call :meth:`run`.
|
|
49
|
+
Handlers may be plain functions or coroutines. Use :attr:`hub` to
|
|
50
|
+
broadcast messages to groups of connected sockets.
|
|
51
|
+
|
|
52
|
+
Example::
|
|
53
|
+
|
|
54
|
+
app = Avalon()
|
|
55
|
+
|
|
56
|
+
@app.get("/hello/{name}")
|
|
57
|
+
def hello(request):
|
|
58
|
+
return {"hello": request.params["name"]}
|
|
59
|
+
|
|
60
|
+
@app.websocket("/ws/{room}")
|
|
61
|
+
async def room(ws):
|
|
62
|
+
app.hub.join(ws.params["room"], ws)
|
|
63
|
+
async for msg in ws:
|
|
64
|
+
await app.hub.broadcast(ws.params["room"], msg)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, keep_alive_timeout: float = 15.0) -> None:
|
|
68
|
+
self.router = Router()
|
|
69
|
+
self.hub = Hub()
|
|
70
|
+
self.sockets: set[WebSocket] = set()
|
|
71
|
+
"""WebSocket connections currently being served."""
|
|
72
|
+
self.keep_alive_timeout = keep_alive_timeout
|
|
73
|
+
|
|
74
|
+
# -- registration ----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def route(self, path: str, methods: tuple[str, ...] | list[str] = ("GET",)) -> Callable[[F], F]:
|
|
77
|
+
"""Decorator registering an HTTP handler for ``path`` and ``methods``."""
|
|
78
|
+
|
|
79
|
+
def decorator(func: F) -> F:
|
|
80
|
+
self.router.add(path, func, methods, kind="http")
|
|
81
|
+
return func
|
|
82
|
+
|
|
83
|
+
return decorator
|
|
84
|
+
|
|
85
|
+
def get(self, path: str) -> Callable[[F], F]:
|
|
86
|
+
"""Shorthand for ``route(path, methods=["GET"])``."""
|
|
87
|
+
return self.route(path, ("GET",))
|
|
88
|
+
|
|
89
|
+
def post(self, path: str) -> Callable[[F], F]:
|
|
90
|
+
"""Shorthand for ``route(path, methods=["POST"])``."""
|
|
91
|
+
return self.route(path, ("POST",))
|
|
92
|
+
|
|
93
|
+
def websocket(self, path: str) -> Callable[[F], F]:
|
|
94
|
+
"""Decorator registering an ``async def handler(ws)`` for ``path``."""
|
|
95
|
+
|
|
96
|
+
def decorator(func: F) -> F:
|
|
97
|
+
if not inspect.iscoroutinefunction(func):
|
|
98
|
+
raise TypeError("WebSocket handlers must be async functions")
|
|
99
|
+
self.router.add(path, func, kind="websocket")
|
|
100
|
+
return func
|
|
101
|
+
|
|
102
|
+
return decorator
|
|
103
|
+
|
|
104
|
+
# -- dispatch --------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
async def dispatch(self, request: Request) -> Response:
|
|
107
|
+
"""Route ``request`` to its handler and return the response.
|
|
108
|
+
|
|
109
|
+
Never raises: ``HTTPError`` becomes a JSON error response and any
|
|
110
|
+
other exception becomes ``500 Internal Server Error``.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
route, request.params = self.router.resolve(request.path, request.method)
|
|
114
|
+
result = route.handler(request)
|
|
115
|
+
if inspect.isawaitable(result):
|
|
116
|
+
result = await result
|
|
117
|
+
return to_response(result)
|
|
118
|
+
except HTTPError as exc:
|
|
119
|
+
return JSONResponse({"error": exc.detail}, status=exc.status)
|
|
120
|
+
except Exception:
|
|
121
|
+
logger.exception("unhandled error in %s %s", request.method, request.path)
|
|
122
|
+
return JSONResponse({"error": "Internal Server Error"}, status=500)
|
|
123
|
+
|
|
124
|
+
async def _serve_websocket(
|
|
125
|
+
self, request: Request, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
126
|
+
) -> None:
|
|
127
|
+
try:
|
|
128
|
+
route, params = self.router.resolve(request.path, kind="websocket")
|
|
129
|
+
key = request.headers.get("sec-websocket-key")
|
|
130
|
+
if not key or request.headers.get("sec-websocket-version") != "13":
|
|
131
|
+
raise HTTPError(400, "invalid WebSocket handshake")
|
|
132
|
+
except HTTPError as exc:
|
|
133
|
+
writer.write(JSONResponse({"error": exc.detail}, status=exc.status).encode())
|
|
134
|
+
await writer.drain()
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
writer.write(
|
|
138
|
+
(
|
|
139
|
+
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
140
|
+
"Upgrade: websocket\r\n"
|
|
141
|
+
"Connection: Upgrade\r\n"
|
|
142
|
+
f"Sec-WebSocket-Accept: {accept_key(key)}\r\n\r\n"
|
|
143
|
+
).encode("latin-1")
|
|
144
|
+
)
|
|
145
|
+
await writer.drain()
|
|
146
|
+
|
|
147
|
+
ws = WebSocket(reader, writer, path=request.path, params=params, headers=request.headers)
|
|
148
|
+
self.sockets.add(ws)
|
|
149
|
+
try:
|
|
150
|
+
await route.handler(ws)
|
|
151
|
+
except WebSocketClosed:
|
|
152
|
+
pass
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.exception("unhandled error in WebSocket handler %s", request.path)
|
|
155
|
+
await ws.close(1011, "internal error")
|
|
156
|
+
finally:
|
|
157
|
+
self.sockets.discard(ws)
|
|
158
|
+
self.hub.leave_all(ws)
|
|
159
|
+
await ws.close()
|
|
160
|
+
|
|
161
|
+
async def handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
162
|
+
"""Serve one TCP connection (HTTP keep-alive or a WebSocket upgrade)."""
|
|
163
|
+
try:
|
|
164
|
+
while True:
|
|
165
|
+
try:
|
|
166
|
+
request = await asyncio.wait_for(read_request(reader), self.keep_alive_timeout)
|
|
167
|
+
except asyncio.TimeoutError:
|
|
168
|
+
break
|
|
169
|
+
except HTTPError as exc:
|
|
170
|
+
writer.write(JSONResponse({"error": exc.detail}, status=exc.status).encode())
|
|
171
|
+
await writer.drain()
|
|
172
|
+
break
|
|
173
|
+
if request is None:
|
|
174
|
+
break
|
|
175
|
+
if request.is_websocket:
|
|
176
|
+
await self._serve_websocket(request, reader, writer)
|
|
177
|
+
return
|
|
178
|
+
response = await self.dispatch(request)
|
|
179
|
+
keep_alive = request.keep_alive
|
|
180
|
+
data = response.encode(keep_alive)
|
|
181
|
+
if request.method == "HEAD" and response.body:
|
|
182
|
+
data = data[: -len(response.body)]
|
|
183
|
+
writer.write(data)
|
|
184
|
+
await writer.drain()
|
|
185
|
+
if not keep_alive:
|
|
186
|
+
break
|
|
187
|
+
except (ConnectionError, asyncio.IncompleteReadError):
|
|
188
|
+
pass
|
|
189
|
+
finally:
|
|
190
|
+
if not writer.is_closing():
|
|
191
|
+
writer.close()
|
|
192
|
+
try:
|
|
193
|
+
await writer.wait_closed()
|
|
194
|
+
except (ConnectionError, RuntimeError):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# -- serving ---------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
async def serve(self, host: str = "127.0.0.1", port: int = 8000) -> asyncio.Server:
|
|
200
|
+
"""Start listening and return the running :class:`asyncio.Server`.
|
|
201
|
+
|
|
202
|
+
Pass ``port=0`` to pick a free port; read it back from
|
|
203
|
+
``server.sockets[0].getsockname()[1]``.
|
|
204
|
+
"""
|
|
205
|
+
return await asyncio.start_server(self.handle_connection, host, port)
|
|
206
|
+
|
|
207
|
+
def run(self, host: str = "127.0.0.1", port: int = 8000) -> None:
|
|
208
|
+
"""Serve forever (blocking) until interrupted with Ctrl+C."""
|
|
209
|
+
|
|
210
|
+
async def main() -> None:
|
|
211
|
+
server = await self.serve(host, port)
|
|
212
|
+
addr = server.sockets[0].getsockname()
|
|
213
|
+
print(f"Avalon running on http://{addr[0]}:{addr[1]} (Ctrl+C to quit)")
|
|
214
|
+
async with server:
|
|
215
|
+
await server.serve_forever()
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
asyncio.run(main())
|
|
219
|
+
except KeyboardInterrupt:
|
|
220
|
+
pass
|
avalon/channels.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Named broadcast channels (pub/sub) over WebSocket connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .websocket import WebSocket, WebSocketClosed
|
|
10
|
+
|
|
11
|
+
__all__ = ["Hub"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Hub:
|
|
15
|
+
"""Tracks which WebSockets are subscribed to which channels.
|
|
16
|
+
|
|
17
|
+
Every :class:`~avalon.Avalon` app owns a hub at ``app.hub``; sockets are
|
|
18
|
+
removed from all channels automatically when their handler returns.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._channels: dict[str, set[WebSocket]] = {}
|
|
23
|
+
|
|
24
|
+
def join(self, channel: str, ws: WebSocket) -> None:
|
|
25
|
+
"""Subscribe ``ws`` to ``channel``."""
|
|
26
|
+
self._channels.setdefault(channel, set()).add(ws)
|
|
27
|
+
|
|
28
|
+
def leave(self, channel: str, ws: WebSocket) -> None:
|
|
29
|
+
"""Unsubscribe ``ws`` from ``channel`` (no-op if not subscribed)."""
|
|
30
|
+
members = self._channels.get(channel)
|
|
31
|
+
if members is None:
|
|
32
|
+
return
|
|
33
|
+
members.discard(ws)
|
|
34
|
+
if not members:
|
|
35
|
+
del self._channels[channel]
|
|
36
|
+
|
|
37
|
+
def leave_all(self, ws: WebSocket) -> None:
|
|
38
|
+
"""Unsubscribe ``ws`` from every channel."""
|
|
39
|
+
for channel in list(self._channels):
|
|
40
|
+
self.leave(channel, ws)
|
|
41
|
+
|
|
42
|
+
def members(self, channel: str) -> set[WebSocket]:
|
|
43
|
+
"""Return a copy of the set of sockets subscribed to ``channel``."""
|
|
44
|
+
return set(self._channels.get(channel, ()))
|
|
45
|
+
|
|
46
|
+
def channels(self) -> list[str]:
|
|
47
|
+
"""Return the names of all channels with at least one member."""
|
|
48
|
+
return sorted(self._channels)
|
|
49
|
+
|
|
50
|
+
async def broadcast(
|
|
51
|
+
self,
|
|
52
|
+
channel: str,
|
|
53
|
+
message: Any,
|
|
54
|
+
*,
|
|
55
|
+
exclude: WebSocket | None = None,
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Send ``message`` to every member of ``channel`` concurrently.
|
|
58
|
+
|
|
59
|
+
``str``/``bytes`` are sent as-is; anything else is JSON-encoded.
|
|
60
|
+
Sockets that fail to receive are dropped from the hub. Returns the
|
|
61
|
+
number of sockets the message was delivered to.
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(message, (str, bytes)):
|
|
64
|
+
message = json.dumps(message, separators=(",", ":"))
|
|
65
|
+
targets = [ws for ws in self.members(channel) if ws is not exclude]
|
|
66
|
+
results = await asyncio.gather(*(ws.send(message) for ws in targets), return_exceptions=True)
|
|
67
|
+
delivered = 0
|
|
68
|
+
for ws, result in zip(targets, results):
|
|
69
|
+
if isinstance(result, WebSocketClosed):
|
|
70
|
+
self.leave_all(ws)
|
|
71
|
+
elif isinstance(result, BaseException):
|
|
72
|
+
raise result
|
|
73
|
+
else:
|
|
74
|
+
delivered += 1
|
|
75
|
+
return delivered
|
avalon/client.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""A minimal asyncio WebSocket client, handy for tests and scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import os
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from .websocket import WebSocket, accept_key
|
|
11
|
+
|
|
12
|
+
__all__ = ["HandshakeError", "connect"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HandshakeError(Exception):
|
|
16
|
+
"""Raised when the server rejects or botches the WebSocket handshake."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, status: int, message: str) -> None:
|
|
19
|
+
self.status = status
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def connect(url: str, *, headers: dict[str, str] | None = None) -> WebSocket:
|
|
24
|
+
"""Open a client WebSocket to ``url`` (``ws://host[:port]/path``).
|
|
25
|
+
|
|
26
|
+
Returns a :class:`WebSocket` that masks outgoing frames as RFC 6455
|
|
27
|
+
requires of clients. Only plain ``ws://`` is supported.
|
|
28
|
+
"""
|
|
29
|
+
parts = urlsplit(url)
|
|
30
|
+
if parts.scheme != "ws":
|
|
31
|
+
raise ValueError(f"unsupported URL scheme {parts.scheme!r}; use ws://")
|
|
32
|
+
host = parts.hostname or "127.0.0.1"
|
|
33
|
+
port = parts.port or 80
|
|
34
|
+
target = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
|
|
35
|
+
|
|
36
|
+
reader, writer = await asyncio.open_connection(host, port)
|
|
37
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
38
|
+
request_headers = {
|
|
39
|
+
"Host": f"{host}:{port}",
|
|
40
|
+
"Upgrade": "websocket",
|
|
41
|
+
"Connection": "Upgrade",
|
|
42
|
+
"Sec-WebSocket-Key": key,
|
|
43
|
+
"Sec-WebSocket-Version": "13",
|
|
44
|
+
**(headers or {}),
|
|
45
|
+
}
|
|
46
|
+
head = f"GET {target} HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in request_headers.items())
|
|
47
|
+
writer.write((head + "\r\n").encode("latin-1"))
|
|
48
|
+
await writer.drain()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
raw = await reader.readuntil(b"\r\n\r\n")
|
|
52
|
+
except asyncio.IncompleteReadError:
|
|
53
|
+
writer.close()
|
|
54
|
+
raise HandshakeError(0, "connection closed during handshake") from None
|
|
55
|
+
lines = raw.decode("latin-1").split("\r\n")
|
|
56
|
+
status = int(lines[0].split(" ", 2)[1])
|
|
57
|
+
resp_headers = {}
|
|
58
|
+
for line in lines[1:]:
|
|
59
|
+
name, sep, value = line.partition(":")
|
|
60
|
+
if sep:
|
|
61
|
+
resp_headers[name.strip().lower()] = value.strip()
|
|
62
|
+
if status != 101:
|
|
63
|
+
writer.close()
|
|
64
|
+
raise HandshakeError(status, f"server responded {lines[0]!r}")
|
|
65
|
+
if resp_headers.get("sec-websocket-accept") != accept_key(key):
|
|
66
|
+
writer.close()
|
|
67
|
+
raise HandshakeError(status, "bad Sec-WebSocket-Accept")
|
|
68
|
+
return WebSocket(reader, writer, client=True, path=parts.path or "/", headers=resp_headers)
|
avalon/http.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""HTTP/1.1 primitives: request parsing, responses and errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from http import HTTPStatus
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qsl, unquote, urlsplit
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"HTTPError",
|
|
14
|
+
"HTMLResponse",
|
|
15
|
+
"JSONResponse",
|
|
16
|
+
"Request",
|
|
17
|
+
"Response",
|
|
18
|
+
"read_request",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
MAX_HEADER_BYTES = 64 * 1024
|
|
22
|
+
MAX_BODY_BYTES = 8 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HTTPError(Exception):
|
|
26
|
+
"""Raise from a handler to abort with an HTTP error status.
|
|
27
|
+
|
|
28
|
+
The error is rendered as a JSON body ``{"error": detail}``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status: int, detail: str | None = None) -> None:
|
|
32
|
+
self.status = int(status)
|
|
33
|
+
self.detail = detail if detail is not None else HTTPStatus(self.status).phrase
|
|
34
|
+
super().__init__(f"{self.status}: {self.detail}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Request:
|
|
39
|
+
"""An incoming HTTP request.
|
|
40
|
+
|
|
41
|
+
Header names are stored lower-cased. ``query`` keeps the last value of
|
|
42
|
+
each query-string key; ``params`` holds values captured from the route
|
|
43
|
+
path (already converted, e.g. ``{id:int}`` yields an ``int``).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
method: str
|
|
47
|
+
path: str
|
|
48
|
+
query: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
body: bytes = b""
|
|
51
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
version: str = "HTTP/1.1"
|
|
53
|
+
|
|
54
|
+
def text(self, encoding: str = "utf-8") -> str:
|
|
55
|
+
"""Return the body decoded as text."""
|
|
56
|
+
return self.body.decode(encoding)
|
|
57
|
+
|
|
58
|
+
def json(self) -> Any:
|
|
59
|
+
"""Return the body parsed as JSON; raises ``HTTPError(400)`` if invalid."""
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(self.body or b"null")
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise HTTPError(400, f"invalid JSON body: {exc}") from None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def keep_alive(self) -> bool:
|
|
67
|
+
"""Whether the client asked to keep the connection open."""
|
|
68
|
+
conn = self.headers.get("connection", "").lower()
|
|
69
|
+
if self.version == "HTTP/1.0":
|
|
70
|
+
return conn == "keep-alive"
|
|
71
|
+
return conn != "close"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_websocket(self) -> bool:
|
|
75
|
+
"""Whether this request is a WebSocket upgrade handshake."""
|
|
76
|
+
return (
|
|
77
|
+
self.headers.get("upgrade", "").lower() == "websocket"
|
|
78
|
+
and "upgrade" in self.headers.get("connection", "").lower()
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Response:
|
|
83
|
+
"""An outgoing HTTP response with a fully buffered body."""
|
|
84
|
+
|
|
85
|
+
media_type = "text/plain; charset=utf-8"
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
body: str | bytes = b"",
|
|
90
|
+
status: int = 200,
|
|
91
|
+
headers: dict[str, str] | None = None,
|
|
92
|
+
media_type: str | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.body = self.render(body)
|
|
95
|
+
self.status = int(status)
|
|
96
|
+
self.headers: dict[str, str] = dict(headers or {})
|
|
97
|
+
if media_type is not None:
|
|
98
|
+
self.media_type = media_type
|
|
99
|
+
|
|
100
|
+
def render(self, content: Any) -> bytes:
|
|
101
|
+
"""Convert ``content`` into body bytes."""
|
|
102
|
+
if isinstance(content, bytes):
|
|
103
|
+
return content
|
|
104
|
+
return str(content).encode("utf-8")
|
|
105
|
+
|
|
106
|
+
def encode(self, keep_alive: bool = False) -> bytes:
|
|
107
|
+
"""Serialize status line, headers and body to wire format."""
|
|
108
|
+
phrase = HTTPStatus(self.status).phrase if self.status in HTTPStatus._value2member_map_ else ""
|
|
109
|
+
headers = {"Content-Type": self.media_type, **self.headers}
|
|
110
|
+
headers["Content-Length"] = str(len(self.body))
|
|
111
|
+
headers["Connection"] = "keep-alive" if keep_alive else "close"
|
|
112
|
+
lines = [f"HTTP/1.1 {self.status} {phrase}"]
|
|
113
|
+
lines += [f"{k}: {v}" for k, v in headers.items()]
|
|
114
|
+
return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1") + self.body
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class JSONResponse(Response):
|
|
118
|
+
"""A response whose body is ``content`` serialized as JSON."""
|
|
119
|
+
|
|
120
|
+
media_type = "application/json"
|
|
121
|
+
|
|
122
|
+
def render(self, content: Any) -> bytes:
|
|
123
|
+
return json.dumps(content, separators=(",", ":")).encode("utf-8")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class HTMLResponse(Response):
|
|
127
|
+
"""A ``text/html`` response."""
|
|
128
|
+
|
|
129
|
+
media_type = "text/html; charset=utf-8"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def read_request(reader: asyncio.StreamReader) -> Request | None:
|
|
133
|
+
"""Read one HTTP request from ``reader``.
|
|
134
|
+
|
|
135
|
+
Returns ``None`` if the peer closed the connection before sending a
|
|
136
|
+
request. Raises ``HTTPError`` for malformed or oversized requests.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
140
|
+
except asyncio.IncompleteReadError as exc:
|
|
141
|
+
if not exc.partial.strip():
|
|
142
|
+
return None
|
|
143
|
+
raise HTTPError(400, "incomplete request head") from None
|
|
144
|
+
except asyncio.LimitOverrunError:
|
|
145
|
+
raise HTTPError(431) from None
|
|
146
|
+
if len(head) > MAX_HEADER_BYTES:
|
|
147
|
+
raise HTTPError(431)
|
|
148
|
+
|
|
149
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
150
|
+
try:
|
|
151
|
+
method, target, version = lines[0].split(" ", 2)
|
|
152
|
+
except ValueError:
|
|
153
|
+
raise HTTPError(400, "malformed request line") from None
|
|
154
|
+
|
|
155
|
+
headers: dict[str, str] = {}
|
|
156
|
+
for line in lines[1:]:
|
|
157
|
+
if not line:
|
|
158
|
+
continue
|
|
159
|
+
name, sep, value = line.partition(":")
|
|
160
|
+
if not sep:
|
|
161
|
+
raise HTTPError(400, "malformed header")
|
|
162
|
+
headers[name.strip().lower()] = value.strip()
|
|
163
|
+
|
|
164
|
+
if "transfer-encoding" in headers:
|
|
165
|
+
raise HTTPError(501, "Transfer-Encoding request bodies are not supported")
|
|
166
|
+
|
|
167
|
+
body = b""
|
|
168
|
+
length = headers.get("content-length")
|
|
169
|
+
if length is not None:
|
|
170
|
+
try:
|
|
171
|
+
n = int(length)
|
|
172
|
+
except ValueError:
|
|
173
|
+
raise HTTPError(400, "invalid Content-Length") from None
|
|
174
|
+
if n < 0:
|
|
175
|
+
raise HTTPError(400, "invalid Content-Length")
|
|
176
|
+
if n > MAX_BODY_BYTES:
|
|
177
|
+
raise HTTPError(413)
|
|
178
|
+
try:
|
|
179
|
+
body = await reader.readexactly(n)
|
|
180
|
+
except asyncio.IncompleteReadError:
|
|
181
|
+
raise HTTPError(400, "incomplete body") from None
|
|
182
|
+
|
|
183
|
+
url = urlsplit(target)
|
|
184
|
+
return Request(
|
|
185
|
+
method=method.upper(),
|
|
186
|
+
path=unquote(url.path) or "/",
|
|
187
|
+
query=dict(parse_qsl(url.query, keep_blank_values=True)),
|
|
188
|
+
headers=headers,
|
|
189
|
+
body=body,
|
|
190
|
+
version=version.strip(),
|
|
191
|
+
)
|
avalon/py.typed
ADDED
|
File without changes
|
avalon/routing.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Path-pattern routing with typed parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from .http import HTTPError
|
|
10
|
+
|
|
11
|
+
__all__ = ["Route", "Router"]
|
|
12
|
+
|
|
13
|
+
_PARAM = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)(?::(str|int|float|path))?\}")
|
|
14
|
+
|
|
15
|
+
_CONVERTERS: dict[str, tuple[str, Callable[[str], Any]]] = {
|
|
16
|
+
"str": (r"[^/]+", str),
|
|
17
|
+
"int": (r"-?\d+", int),
|
|
18
|
+
"float": (r"-?\d+(?:\.\d+)?", float),
|
|
19
|
+
"path": (r".+", str),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compile_path(path: str) -> tuple[re.Pattern[str], dict[str, Callable[[str], Any]]]:
|
|
24
|
+
"""Compile a route path such as ``/users/{id:int}`` into a regex.
|
|
25
|
+
|
|
26
|
+
Supported converters: ``str`` (default), ``int``, ``float`` and ``path``
|
|
27
|
+
(which may contain slashes).
|
|
28
|
+
"""
|
|
29
|
+
if not path.startswith("/"):
|
|
30
|
+
raise ValueError(f"route path must start with '/': {path!r}")
|
|
31
|
+
pattern, converters, pos = "^", {}, 0
|
|
32
|
+
for m in _PARAM.finditer(path):
|
|
33
|
+
name, kind = m.group(1), m.group(2) or "str"
|
|
34
|
+
if name in converters:
|
|
35
|
+
raise ValueError(f"duplicate parameter {name!r} in {path!r}")
|
|
36
|
+
regex, conv = _CONVERTERS[kind]
|
|
37
|
+
pattern += re.escape(path[pos : m.start()]) + f"(?P<{name}>{regex})"
|
|
38
|
+
converters[name] = conv
|
|
39
|
+
pos = m.end()
|
|
40
|
+
pattern += re.escape(path[pos:]) + "$"
|
|
41
|
+
return re.compile(pattern), converters
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Route:
|
|
46
|
+
"""A single registered route.
|
|
47
|
+
|
|
48
|
+
``kind`` is ``"http"`` or ``"websocket"``; ``methods`` is only meaningful
|
|
49
|
+
for HTTP routes.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
path: str
|
|
53
|
+
handler: Callable[..., Any]
|
|
54
|
+
methods: frozenset[str] = frozenset({"GET"})
|
|
55
|
+
kind: str = "http"
|
|
56
|
+
pattern: re.Pattern[str] = field(init=False, repr=False)
|
|
57
|
+
converters: dict[str, Callable[[str], Any]] = field(init=False, repr=False)
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
self.pattern, self.converters = compile_path(self.path)
|
|
61
|
+
|
|
62
|
+
def match(self, path: str) -> dict[str, Any] | None:
|
|
63
|
+
"""Return converted path params if ``path`` matches, else ``None``."""
|
|
64
|
+
m = self.pattern.match(path)
|
|
65
|
+
if m is None:
|
|
66
|
+
return None
|
|
67
|
+
return {k: self.converters[k](v) for k, v in m.groupdict().items()}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Router:
|
|
71
|
+
"""An ordered collection of routes; first match wins."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self.routes: list[Route] = []
|
|
75
|
+
|
|
76
|
+
def add(
|
|
77
|
+
self,
|
|
78
|
+
path: str,
|
|
79
|
+
handler: Callable[..., Any],
|
|
80
|
+
methods: tuple[str, ...] | list[str] = ("GET",),
|
|
81
|
+
kind: str = "http",
|
|
82
|
+
) -> Route:
|
|
83
|
+
"""Register ``handler`` for ``path`` and return the new ``Route``."""
|
|
84
|
+
if kind not in ("http", "websocket"):
|
|
85
|
+
raise ValueError(f"unknown route kind {kind!r}")
|
|
86
|
+
route = Route(path, handler, frozenset(m.upper() for m in methods), kind)
|
|
87
|
+
self.routes.append(route)
|
|
88
|
+
return route
|
|
89
|
+
|
|
90
|
+
def resolve(self, path: str, method: str = "GET", kind: str = "http") -> tuple[Route, dict[str, Any]]:
|
|
91
|
+
"""Find the route for ``path``.
|
|
92
|
+
|
|
93
|
+
Raises ``HTTPError(404)`` if nothing matches and ``HTTPError(405)`` if
|
|
94
|
+
a path matches but not with the given method.
|
|
95
|
+
"""
|
|
96
|
+
method_mismatch = False
|
|
97
|
+
for route in self.routes:
|
|
98
|
+
if route.kind != kind:
|
|
99
|
+
continue
|
|
100
|
+
params = route.match(path)
|
|
101
|
+
if params is None:
|
|
102
|
+
continue
|
|
103
|
+
if kind == "http" and method.upper() not in route.methods:
|
|
104
|
+
if not (method.upper() == "HEAD" and "GET" in route.methods):
|
|
105
|
+
method_mismatch = True
|
|
106
|
+
continue
|
|
107
|
+
return route, params
|
|
108
|
+
raise HTTPError(405 if method_mismatch else 404)
|
avalon/websocket.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""RFC 6455 WebSocket protocol: handshake helpers and a connection object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import struct
|
|
11
|
+
from enum import IntEnum
|
|
12
|
+
from typing import Any, AsyncIterator
|
|
13
|
+
|
|
14
|
+
__all__ = ["Opcode", "WebSocket", "WebSocketClosed", "accept_key"]
|
|
15
|
+
|
|
16
|
+
_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def accept_key(key: str) -> str:
|
|
20
|
+
"""Compute the ``Sec-WebSocket-Accept`` value for a client ``key``."""
|
|
21
|
+
digest = hashlib.sha1((key + _GUID).encode("ascii")).digest()
|
|
22
|
+
return base64.b64encode(digest).decode("ascii")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Opcode(IntEnum):
|
|
26
|
+
"""WebSocket frame opcodes."""
|
|
27
|
+
|
|
28
|
+
CONTINUATION = 0x0
|
|
29
|
+
TEXT = 0x1
|
|
30
|
+
BINARY = 0x2
|
|
31
|
+
CLOSE = 0x8
|
|
32
|
+
PING = 0x9
|
|
33
|
+
PONG = 0xA
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WebSocketClosed(Exception):
|
|
37
|
+
"""Raised when receiving from or sending to a closed WebSocket."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, code: int = 1006, reason: str = "") -> None:
|
|
40
|
+
self.code = code
|
|
41
|
+
self.reason = reason
|
|
42
|
+
super().__init__(f"WebSocket closed ({code}) {reason}".rstrip())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _apply_mask(data: bytes, mask: bytes) -> bytes:
|
|
46
|
+
"""XOR ``data`` with the 4-byte ``mask`` (masking is its own inverse)."""
|
|
47
|
+
if not data:
|
|
48
|
+
return data
|
|
49
|
+
repeated = (mask * (len(data) // 4 + 1))[: len(data)]
|
|
50
|
+
return (int.from_bytes(data, "big") ^ int.from_bytes(repeated, "big")).to_bytes(len(data), "big")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def encode_frame(opcode: int, payload: bytes, *, mask: bool = False, fin: bool = True) -> bytes:
|
|
54
|
+
"""Encode a single WebSocket frame."""
|
|
55
|
+
header = bytearray([(0x80 if fin else 0) | opcode])
|
|
56
|
+
mask_bit = 0x80 if mask else 0
|
|
57
|
+
n = len(payload)
|
|
58
|
+
if n < 126:
|
|
59
|
+
header.append(mask_bit | n)
|
|
60
|
+
elif n < 1 << 16:
|
|
61
|
+
header.append(mask_bit | 126)
|
|
62
|
+
header += struct.pack("!H", n)
|
|
63
|
+
else:
|
|
64
|
+
header.append(mask_bit | 127)
|
|
65
|
+
header += struct.pack("!Q", n)
|
|
66
|
+
if mask:
|
|
67
|
+
key = os.urandom(4)
|
|
68
|
+
return bytes(header) + key + _apply_mask(payload, key)
|
|
69
|
+
return bytes(header) + payload
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class WebSocket:
|
|
73
|
+
"""One end of an established WebSocket connection.
|
|
74
|
+
|
|
75
|
+
Server-side sockets are handed to ``@app.websocket`` handlers; client-side
|
|
76
|
+
sockets come from :func:`avalon.connect`. Control frames are handled
|
|
77
|
+
transparently: pings are answered with pongs and a close frame from the
|
|
78
|
+
peer is echoed before :class:`WebSocketClosed` is raised.
|
|
79
|
+
|
|
80
|
+
Iterating with ``async for msg in ws`` yields messages until the peer
|
|
81
|
+
closes the connection.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
reader: asyncio.StreamReader,
|
|
87
|
+
writer: asyncio.StreamWriter,
|
|
88
|
+
*,
|
|
89
|
+
client: bool = False,
|
|
90
|
+
path: str = "/",
|
|
91
|
+
params: dict[str, Any] | None = None,
|
|
92
|
+
headers: dict[str, str] | None = None,
|
|
93
|
+
max_size: int = 1 << 20,
|
|
94
|
+
) -> None:
|
|
95
|
+
self._reader = reader
|
|
96
|
+
self._writer = writer
|
|
97
|
+
self._client = client
|
|
98
|
+
self._send_lock = asyncio.Lock()
|
|
99
|
+
self._closed = False
|
|
100
|
+
self.path = path
|
|
101
|
+
self.params: dict[str, Any] = dict(params or {})
|
|
102
|
+
self.headers: dict[str, str] = dict(headers or {})
|
|
103
|
+
self.max_size = max_size
|
|
104
|
+
self.close_code: int | None = None
|
|
105
|
+
self.state: dict[str, Any] = {}
|
|
106
|
+
"""Free-form per-connection storage for application data."""
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def closed(self) -> bool:
|
|
110
|
+
"""Whether the connection has been closed (by either side)."""
|
|
111
|
+
return self._closed
|
|
112
|
+
|
|
113
|
+
# -- sending ---------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async def _send_frame(self, opcode: int, payload: bytes) -> None:
|
|
116
|
+
if self._closed:
|
|
117
|
+
raise WebSocketClosed(self.close_code or 1006, "connection is closed")
|
|
118
|
+
async with self._send_lock:
|
|
119
|
+
try:
|
|
120
|
+
self._writer.write(encode_frame(opcode, payload, mask=self._client))
|
|
121
|
+
await self._writer.drain()
|
|
122
|
+
except (ConnectionError, RuntimeError) as exc:
|
|
123
|
+
self._closed = True
|
|
124
|
+
raise WebSocketClosed(1006, str(exc)) from None
|
|
125
|
+
|
|
126
|
+
async def send(self, data: str | bytes) -> None:
|
|
127
|
+
"""Send a text (``str``) or binary (``bytes``) message."""
|
|
128
|
+
if isinstance(data, str):
|
|
129
|
+
await self._send_frame(Opcode.TEXT, data.encode("utf-8"))
|
|
130
|
+
else:
|
|
131
|
+
await self._send_frame(Opcode.BINARY, bytes(data))
|
|
132
|
+
|
|
133
|
+
async def send_json(self, obj: Any) -> None:
|
|
134
|
+
"""Serialize ``obj`` as JSON and send it as a text message."""
|
|
135
|
+
await self.send(json.dumps(obj, separators=(",", ":")))
|
|
136
|
+
|
|
137
|
+
async def ping(self, data: bytes = b"") -> None:
|
|
138
|
+
"""Send a ping control frame."""
|
|
139
|
+
await self._send_frame(Opcode.PING, data)
|
|
140
|
+
|
|
141
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
142
|
+
"""Send a close frame (if still open) and shut down the transport."""
|
|
143
|
+
if not self._closed:
|
|
144
|
+
payload = struct.pack("!H", code) + reason.encode("utf-8")[:123]
|
|
145
|
+
try:
|
|
146
|
+
await self._send_frame(Opcode.CLOSE, payload)
|
|
147
|
+
except WebSocketClosed:
|
|
148
|
+
pass
|
|
149
|
+
self._closed = True
|
|
150
|
+
self.close_code = code
|
|
151
|
+
self._writer.close()
|
|
152
|
+
try:
|
|
153
|
+
await self._writer.wait_closed()
|
|
154
|
+
except (ConnectionError, RuntimeError):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
# -- receiving -------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def _read_frame(self) -> tuple[bool, int, bytes]:
|
|
160
|
+
try:
|
|
161
|
+
b1, b2 = await self._reader.readexactly(2)
|
|
162
|
+
fin, opcode = bool(b1 & 0x80), b1 & 0x0F
|
|
163
|
+
masked, n = bool(b2 & 0x80), b2 & 0x7F
|
|
164
|
+
if n == 126:
|
|
165
|
+
(n,) = struct.unpack("!H", await self._reader.readexactly(2))
|
|
166
|
+
elif n == 127:
|
|
167
|
+
(n,) = struct.unpack("!Q", await self._reader.readexactly(8))
|
|
168
|
+
if n > self.max_size:
|
|
169
|
+
await self.close(1009, "message too big")
|
|
170
|
+
raise WebSocketClosed(1009, "message too big")
|
|
171
|
+
key = await self._reader.readexactly(4) if masked else b""
|
|
172
|
+
payload = await self._reader.readexactly(n)
|
|
173
|
+
except (asyncio.IncompleteReadError, ConnectionError):
|
|
174
|
+
self._closed = True
|
|
175
|
+
raise WebSocketClosed(1006, "connection lost") from None
|
|
176
|
+
if masked:
|
|
177
|
+
payload = _apply_mask(payload, key)
|
|
178
|
+
return fin, opcode, payload
|
|
179
|
+
|
|
180
|
+
async def receive(self) -> str | bytes:
|
|
181
|
+
"""Wait for the next data message (``str`` for text, ``bytes`` for binary).
|
|
182
|
+
|
|
183
|
+
Raises :class:`WebSocketClosed` once the connection is closed.
|
|
184
|
+
"""
|
|
185
|
+
if self._closed:
|
|
186
|
+
raise WebSocketClosed(self.close_code or 1006, "connection is closed")
|
|
187
|
+
msg_opcode: int | None = None
|
|
188
|
+
chunks: list[bytes] = []
|
|
189
|
+
while True:
|
|
190
|
+
fin, opcode, payload = await self._read_frame()
|
|
191
|
+
if opcode == Opcode.PING:
|
|
192
|
+
await self._send_frame(Opcode.PONG, payload)
|
|
193
|
+
continue
|
|
194
|
+
if opcode == Opcode.PONG:
|
|
195
|
+
continue
|
|
196
|
+
if opcode == Opcode.CLOSE:
|
|
197
|
+
code = struct.unpack("!H", payload[:2])[0] if len(payload) >= 2 else 1005
|
|
198
|
+
reason = payload[2:].decode("utf-8", "replace")
|
|
199
|
+
await self.close(code if code != 1005 else 1000)
|
|
200
|
+
self.close_code = code
|
|
201
|
+
raise WebSocketClosed(code, reason)
|
|
202
|
+
if opcode in (Opcode.TEXT, Opcode.BINARY):
|
|
203
|
+
if msg_opcode is not None:
|
|
204
|
+
await self.close(1002, "expected continuation frame")
|
|
205
|
+
raise WebSocketClosed(1002, "expected continuation frame")
|
|
206
|
+
msg_opcode = opcode
|
|
207
|
+
elif opcode != Opcode.CONTINUATION or msg_opcode is None:
|
|
208
|
+
await self.close(1002, "protocol error")
|
|
209
|
+
raise WebSocketClosed(1002, "protocol error")
|
|
210
|
+
chunks.append(payload)
|
|
211
|
+
if sum(map(len, chunks)) > self.max_size:
|
|
212
|
+
await self.close(1009, "message too big")
|
|
213
|
+
raise WebSocketClosed(1009, "message too big")
|
|
214
|
+
if fin:
|
|
215
|
+
data = b"".join(chunks)
|
|
216
|
+
return data.decode("utf-8") if msg_opcode == Opcode.TEXT else data
|
|
217
|
+
|
|
218
|
+
async def receive_json(self) -> Any:
|
|
219
|
+
"""Receive a message and parse it as JSON."""
|
|
220
|
+
return json.loads(await self.receive())
|
|
221
|
+
|
|
222
|
+
def __aiter__(self) -> AsyncIterator[str | bytes]:
|
|
223
|
+
return self._iter()
|
|
224
|
+
|
|
225
|
+
async def _iter(self) -> AsyncIterator[str | bytes]:
|
|
226
|
+
while True:
|
|
227
|
+
try:
|
|
228
|
+
yield await self.receive()
|
|
229
|
+
except WebSocketClosed:
|
|
230
|
+
return
|
|
@@ -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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
avalon/__init__.py,sha256=Oa0W94oWdvmaH2y8YPR1qYkWB3eo2uMR_pIoE42bnVg,729
|
|
2
|
+
avalon/app.py,sha256=HpZFor6O6yni2YXOIJMeo7gKPCAMEM6UKaR36h1-yI0,8361
|
|
3
|
+
avalon/channels.py,sha256=-B9c_MKerDEmMSq7VYdqtgs9vNSy17y0Shf0ppHyV4E,2598
|
|
4
|
+
avalon/client.py,sha256=PV8LMk-oRObqnePODal530puCa16sIxuw9FRQP-4v9o,2453
|
|
5
|
+
avalon/http.py,sha256=kJyITRIZqtSVly5KkM56nrz3lxGRnPmUzxoxBndlcqE,6191
|
|
6
|
+
avalon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
avalon/routing.py,sha256=IhUj1v7no-DTvhZMetamJGfJMTjUjO3qs14LZ5kGsj0,3681
|
|
8
|
+
avalon/websocket.py,sha256=RLQgDXNJdO-MusJS94cFUok-gvMAb-h0cz38WEmt3Mw,8614
|
|
9
|
+
avalon-0.2.0.dist-info/licenses/LICENSE,sha256=pAfYREEW9GAy7cnK20OXjDn7ofJahYny9GCuIZQTDAA,1061
|
|
10
|
+
avalon-0.2.0.dist-info/METADATA,sha256=-5sVTTPkc-Q_H4W-uFbcdt-l_cy_qwr_ZbHxJhpfmMQ,8309
|
|
11
|
+
avalon-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
avalon-0.2.0.dist-info/top_level.txt,sha256=EwZXVOkUJp6PYD0VdJG2tmGsYN_zGk4goHK7-8PcA8c,7
|
|
13
|
+
avalon-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
avalon
|