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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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