hayate 0.3.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.
hayate/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """hayate — a web-standards-first Python web framework, inspired by Hono."""
2
+
3
+ from .abort import AbortError, AbortSignal
4
+ from .app import ErrorHandler, Handler, Hayate, Middleware
5
+ from .body import BodyInit
6
+ from .context import Context, ExecutionContext, Next
7
+ from .exceptions import HTTPException, problem
8
+ from .formdata import File, FormData
9
+ from .headers import Headers
10
+ from .request import HayateRequest, Request
11
+ from .response import Response
12
+ from .url import URL, URLSearchParams
13
+ from .urlpattern import URLPattern, URLPatternResult
14
+ from .validator import validator
15
+ from .websocket import WebSocket, WebSocketClosed
16
+
17
+ __version__ = "0.3.0"
18
+
19
+ __all__ = [
20
+ "URL",
21
+ "AbortError",
22
+ "AbortSignal",
23
+ "BodyInit",
24
+ "Context",
25
+ "ErrorHandler",
26
+ "ExecutionContext",
27
+ "File",
28
+ "FormData",
29
+ "HTTPException",
30
+ "Handler",
31
+ "Hayate",
32
+ "HayateRequest",
33
+ "Headers",
34
+ "Middleware",
35
+ "Next",
36
+ "Request",
37
+ "Response",
38
+ "URLPattern",
39
+ "URLPatternResult",
40
+ "URLSearchParams",
41
+ "WebSocket",
42
+ "WebSocketClosed",
43
+ "__version__",
44
+ "problem",
45
+ "validator",
46
+ ]
hayate/abort.py ADDED
@@ -0,0 +1,62 @@
1
+ """Minimal ``AbortSignal``, mirroring the DOM surface a server needs.
2
+
3
+ ``request.signal`` lets handlers observe client disconnects. By default
4
+ hayate does not cancel handlers on abort (same safe default as Hono);
5
+ cancellation is opt-in via middleware.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ _logger = logging.getLogger("hayate")
15
+
16
+
17
+ class AbortError(Exception):
18
+ """Raised by ``AbortSignal.raise_if_aborted()`` (DOMException "AbortError")."""
19
+
20
+
21
+ class AbortSignal:
22
+ __slots__ = ("_aborted", "_callbacks", "_reason")
23
+
24
+ def __init__(self) -> None:
25
+ self._aborted = False
26
+ self._reason: Any = None
27
+ self._callbacks: list[Callable[[], None]] = []
28
+
29
+ @property
30
+ def aborted(self) -> bool:
31
+ return self._aborted
32
+
33
+ @property
34
+ def reason(self) -> Any:
35
+ return self._reason
36
+
37
+ def add_listener(self, callback: Callable[[], None]) -> None:
38
+ """Register an abort callback (fires immediately if already aborted)."""
39
+ if self._aborted:
40
+ callback()
41
+ return
42
+ self._callbacks.append(callback)
43
+
44
+ def raise_if_aborted(self) -> None:
45
+ """Fetch ``throwIfAborted()``."""
46
+ if self._aborted:
47
+ raise AbortError(self._reason if self._reason is not None else "aborted")
48
+
49
+ def _abort(self, reason: Any = None) -> None:
50
+ if self._aborted:
51
+ return
52
+ self._aborted = True
53
+ self._reason = reason
54
+ callbacks, self._callbacks = self._callbacks, []
55
+ for callback in callbacks:
56
+ try:
57
+ callback()
58
+ except Exception:
59
+ _logger.exception("abort listener raised")
60
+
61
+ def __repr__(self) -> str:
62
+ return f"AbortSignal(aborted={self._aborted!r})"
@@ -0,0 +1,5 @@
1
+ """Runtime adapters. The core never imports these at module level."""
2
+
3
+ from .asgi import ASGIAdapter
4
+
5
+ __all__ = ["ASGIAdapter"]
@@ -0,0 +1,220 @@
1
+ """ASGI adapter: translates ASGI ``http`` / ``lifespan`` events to ``app.fetch()``.
2
+
3
+ Users never see ``scope`` / ``receive`` / ``send`` — the ``Hayate`` app is
4
+ itself an ASGI callable that delegates here, so ``uvicorn main:app`` works
5
+ out of the box.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import logging
12
+ from collections.abc import AsyncIterator, Awaitable, Callable
13
+ from typing import TYPE_CHECKING, Any
14
+ from urllib.parse import quote
15
+
16
+ from ..abort import AbortSignal
17
+ from ..context import Context
18
+ from ..headers import Headers
19
+ from ..request import HayateRequest, Request
20
+ from ..router import WEBSOCKET_METHOD
21
+ from ..url import URL
22
+ from ..websocket import WebSocket, WebSocketClosed
23
+
24
+ if TYPE_CHECKING:
25
+ from ..app import Hayate
26
+ from ..response import Response
27
+
28
+ _logger = logging.getLogger("hayate.asgi")
29
+
30
+ type Receive = Callable[[], Awaitable[dict[str, Any]]]
31
+ type Send = Callable[[dict[str, Any]], Awaitable[None]]
32
+
33
+ _DEFAULT_SCHEME_PORTS = {"http": "80", "https": "443", "ws": "80", "wss": "443"}
34
+ # pchar set — keep valid path characters intact when re-encoding a decoded path.
35
+ _PATH_SAFE = "/:@!$&'()*+,;=~-._"
36
+
37
+
38
+ async def _request_body(receive: Receive, signal: AbortSignal) -> AsyncIterator[bytes]:
39
+ while True:
40
+ message = await receive()
41
+ if message["type"] == "http.request":
42
+ chunk = message.get("body", b"")
43
+ if chunk:
44
+ yield chunk
45
+ if not message.get("more_body", False):
46
+ return
47
+ elif message["type"] == "http.disconnect":
48
+ signal._abort("client disconnected")
49
+ return
50
+
51
+
52
+ async def _call_hook(hook: Callable[[], Any]) -> None:
53
+ result = hook()
54
+ if inspect.isawaitable(result):
55
+ await result
56
+
57
+
58
+ class ASGIAdapter:
59
+ __slots__ = ("_app",)
60
+
61
+ def __init__(self, app: Hayate) -> None:
62
+ self._app = app
63
+
64
+ async def __call__(self, scope: dict[str, Any], receive: Receive, send: Send) -> None:
65
+ kind = scope["type"]
66
+ if kind == "http":
67
+ await self._http(scope, receive, send)
68
+ elif kind == "websocket":
69
+ await self._websocket(scope, receive, send)
70
+ elif kind == "lifespan":
71
+ await self._lifespan(receive, send)
72
+ else:
73
+ raise RuntimeError(f"unsupported ASGI scope type: {kind!r}")
74
+
75
+ # -- http -----------------------------------------------------------------
76
+
77
+ async def _http(self, scope: dict[str, Any], receive: Receive, send: Send) -> None:
78
+ raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
79
+ host: bytes | None = None
80
+ expects_body = False
81
+ for name, value in raw_headers:
82
+ if name == b"host":
83
+ if host is None:
84
+ host = value
85
+ elif name == b"content-length":
86
+ expects_body = value not in (b"", b"0")
87
+ elif name == b"transfer-encoding":
88
+ expects_body = True
89
+ if expects_body:
90
+ signal: AbortSignal | None = AbortSignal()
91
+ body: Any = _request_body(receive, signal)
92
+ else:
93
+ # No content-length / transfer-encoding means no request body
94
+ # (RFC 9112) — a null body per Fetch; skip stream and signal.
95
+ signal = None
96
+ body = None
97
+ request = Request(
98
+ _build_url(scope, host),
99
+ method=scope["method"],
100
+ headers=Headers._from_wire(raw_headers, guard="immutable"),
101
+ body=body,
102
+ signal=signal,
103
+ )
104
+ response = await self._app.fetch(request)
105
+ try:
106
+ await _send_response(scope, send, response)
107
+ finally:
108
+ background = response._background
109
+ if background is not None:
110
+ await background._drain()
111
+
112
+ # -- websocket ---------------------------------------------------------------
113
+
114
+ async def _websocket(self, scope: dict[str, Any], receive: Receive, send: Send) -> None:
115
+ raw_path = scope.get("raw_path")
116
+ if raw_path:
117
+ path = raw_path.decode("latin-1")
118
+ else:
119
+ path = quote(scope.get("path", "/"), safe=_PATH_SAFE)
120
+ matched = self._app._router.match(WEBSOCKET_METHOD, path)
121
+ if matched is None:
122
+ await receive() # websocket.connect
123
+ await send({"type": "websocket.close", "code": 4404, "reason": "not found"})
124
+ return
125
+ route, params = matched
126
+ raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
127
+ host: bytes | None = None
128
+ for name, value in raw_headers:
129
+ if name == b"host":
130
+ host = value
131
+ break
132
+ request = Request(
133
+ _build_url(scope, host),
134
+ headers=Headers._from_wire(raw_headers, guard="immutable"),
135
+ )
136
+ hayate_request = HayateRequest(request)
137
+ hayate_request._params = params
138
+ c = Context(hayate_request, self._app._env, None)
139
+ ws = WebSocket(receive, send)
140
+ await ws.accept()
141
+ try:
142
+ await route.handler(c, ws)
143
+ except WebSocketClosed:
144
+ pass
145
+ except Exception:
146
+ _logger.exception("websocket handler failed on %s", path)
147
+ await ws.close(1011, "internal error")
148
+ return
149
+ await ws.close()
150
+
151
+ # -- lifespan ----------------------------------------------------------------
152
+
153
+ async def _lifespan(self, receive: Receive, send: Send) -> None:
154
+ while True:
155
+ message = await receive()
156
+ if message["type"] == "lifespan.startup":
157
+ try:
158
+ for hook in self._app._on_start:
159
+ await _call_hook(hook)
160
+ except Exception as exc:
161
+ await send({"type": "lifespan.startup.failed", "message": str(exc)})
162
+ return
163
+ await send({"type": "lifespan.startup.complete"})
164
+ elif message["type"] == "lifespan.shutdown":
165
+ try:
166
+ for hook in self._app._on_stop:
167
+ await _call_hook(hook)
168
+ except Exception as exc:
169
+ await send({"type": "lifespan.shutdown.failed", "message": str(exc)})
170
+ return
171
+ await send({"type": "lifespan.shutdown.complete"})
172
+ return
173
+
174
+
175
+ def _build_url(scope: dict[str, Any], host_header: bytes | None) -> URL:
176
+ scheme = scope.get("scheme", "http")
177
+ if host_header:
178
+ host = host_header.decode("latin-1")
179
+ else:
180
+ server = scope.get("server")
181
+ if server:
182
+ host = server[0]
183
+ port = server[1]
184
+ if port is not None and str(port) != _DEFAULT_SCHEME_PORTS.get(scheme):
185
+ host = f"{host}:{port}"
186
+ else:
187
+ host = "localhost"
188
+ raw_path = scope.get("raw_path")
189
+ if raw_path:
190
+ path = raw_path.decode("latin-1")
191
+ else:
192
+ path = quote(scope.get("path", "/"), safe=_PATH_SAFE)
193
+ query = scope.get("query_string", b"").decode("latin-1")
194
+ return URL._from_server(scheme, host, path, query)
195
+
196
+
197
+ async def _send_response(scope: dict[str, Any], send: Send, response: Response) -> None:
198
+ status = response.status
199
+ body = response.body
200
+ headers = [
201
+ (name.encode("latin-1"), value.encode("latin-1")) for name, value in response.headers.raw()
202
+ ]
203
+ body_allowed = status >= 200 and status not in (204, 304)
204
+ if body_allowed and not response.headers.has("content-length"):
205
+ if isinstance(body, bytes):
206
+ headers.append((b"content-length", str(len(body)).encode("latin-1")))
207
+ elif body is None:
208
+ headers.append((b"content-length", b"0"))
209
+ # Stream bodies get no content-length; the server frames them.
210
+ await send({"type": "http.response.start", "status": status, "headers": headers})
211
+ suppress_body = scope["method"] == "HEAD" or not body_allowed
212
+ if suppress_body or body is None:
213
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
214
+ return
215
+ if isinstance(body, bytes):
216
+ await send({"type": "http.response.body", "body": body, "more_body": False})
217
+ return
218
+ async for chunk in body:
219
+ await send({"type": "http.response.body", "body": bytes(chunk), "more_body": True})
220
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
hayate/adapters/aws.py ADDED
@@ -0,0 +1,116 @@
1
+ """AWS Lambda adapter (Function URLs / API Gateway HTTP API, payload v2.0).
2
+
3
+ from hayate.adapters.aws import to_lambda
4
+ from app import app
5
+
6
+ handler = to_lambda(app)
7
+
8
+ Each invocation runs the app on a fresh event loop — the standard
9
+ pattern for sync Lambda handlers. Response bodies are emitted as text
10
+ when the content-type is textual and there is no content-encoding;
11
+ otherwise they are base64-encoded. ``Set-Cookie`` values map to the
12
+ payload's ``cookies`` list so they are never comma-joined.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import base64
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from ..headers import Headers
22
+ from ..request import Request
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+ from ..app import Hayate
28
+
29
+ _TEXT_EXACT = {
30
+ "application/json",
31
+ "application/javascript",
32
+ "application/xml",
33
+ "application/manifest+json",
34
+ "image/svg+xml",
35
+ }
36
+
37
+
38
+ def _is_textual(content_type: str) -> bool:
39
+ base = content_type.partition(";")[0].strip().lower()
40
+ return (
41
+ base.startswith("text/")
42
+ or base in _TEXT_EXACT
43
+ or base.endswith("+json")
44
+ or base.endswith("+xml")
45
+ )
46
+
47
+
48
+ async def _handle(app: Hayate, event: dict[str, Any]) -> dict[str, Any]:
49
+ http = event["requestContext"]["http"]
50
+ method = http.get("method", "GET")
51
+ path = event.get("rawPath") or "/"
52
+ query = event.get("rawQueryString") or ""
53
+ header_map: dict[str, str] = event.get("headers") or {}
54
+ pairs = list(header_map.items())
55
+ request_cookies = event.get("cookies")
56
+ if request_cookies:
57
+ pairs.append(("cookie", "; ".join(request_cookies)))
58
+
59
+ host = header_map.get("host") or event["requestContext"].get("domainName", "lambda")
60
+ target = f"https://{host}{path}"
61
+ if query:
62
+ target += f"?{query}"
63
+
64
+ raw_body = event.get("body")
65
+ body: bytes | str | None
66
+ if raw_body is None:
67
+ body = None
68
+ elif event.get("isBase64Encoded"):
69
+ body = base64.b64decode(raw_body)
70
+ else:
71
+ body = raw_body
72
+
73
+ request = Request(target, method=method, headers=Headers(pairs, guard="immutable"), body=body)
74
+ response = await app.fetch(request)
75
+ background = response._background
76
+ if background is not None:
77
+ await background._drain()
78
+
79
+ payload = response.body
80
+ if payload is not None and not isinstance(payload, bytes):
81
+ payload = await response.bytes()
82
+
83
+ headers: dict[str, str] = {}
84
+ set_cookies: list[str] = []
85
+ for name, value in response.headers.raw():
86
+ if name == "set-cookie":
87
+ set_cookies.append(value)
88
+ elif name in headers:
89
+ headers[name] = f"{headers[name]}, {value}"
90
+ else:
91
+ headers[name] = value
92
+
93
+ result: dict[str, Any] = {"statusCode": response.status, "headers": headers}
94
+ if set_cookies:
95
+ result["cookies"] = set_cookies
96
+ if payload is None:
97
+ result["body"] = ""
98
+ result["isBase64Encoded"] = False
99
+ else:
100
+ content_type = response.headers.get("content-type") or ""
101
+ if response.headers.has("content-encoding") or not _is_textual(content_type):
102
+ result["body"] = base64.b64encode(payload).decode("ascii")
103
+ result["isBase64Encoded"] = True
104
+ else:
105
+ result["body"] = payload.decode("utf-8")
106
+ result["isBase64Encoded"] = False
107
+ return result
108
+
109
+
110
+ def to_lambda(app: Hayate) -> Callable[[dict[str, Any], Any], dict[str, Any]]:
111
+ """Build a synchronous Lambda handler: ``handler = to_lambda(app)``."""
112
+
113
+ def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
114
+ return asyncio.run(_handle(app, event))
115
+
116
+ return handler
@@ -0,0 +1,94 @@
1
+ """Cloudflare Python Workers adapter.
2
+
3
+ Usage — the entry module of a Python Worker:
4
+
5
+ from hayate.adapters.workers import to_workers
6
+ from app import app
7
+
8
+ Default = to_workers(app)
9
+
10
+ The Workers runtime modules (``workers``, ``js``) exist only inside
11
+ Pyodide, so they are imported inside ``to_workers()``; the translation
12
+ helpers are plain Python and unit-testable anywhere.
13
+
14
+ Unlike the official FastAPI integration (JS Request -> ASGI -> Starlette
15
+ Request), this is a single-step translation: JS Request -> hayate
16
+ Request, same conceptual model on both sides.
17
+
18
+ v0.2 scope: bodies are buffered across the FFI boundary (streaming
19
+ bridges need on-platform verification — docs/research/cloudflare.md §5)
20
+ and the abort signal is not yet bridged.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from ..context import ExecutionContext
29
+ from ..headers import Headers
30
+ from ..request import Request
31
+
32
+ if TYPE_CHECKING:
33
+ from ..app import Hayate
34
+ from ..response import Response
35
+
36
+
37
+ def _js_headers_to_pairs(js_headers: Any) -> list[tuple[str, str]]:
38
+ return [(str(entry[0]), str(entry[1])) for entry in js_headers.entries()]
39
+
40
+
41
+ async def _to_hayate_request(js_request: Any) -> Request:
42
+ body: bytes | None = None
43
+ if js_request.body is not None:
44
+ buffer = await js_request.arrayBuffer()
45
+ data = buffer.to_py() if hasattr(buffer, "to_py") else buffer
46
+ body = bytes(data) or None
47
+ return Request(
48
+ str(js_request.url),
49
+ method=str(js_request.method),
50
+ headers=Headers(_js_headers_to_pairs(js_request.headers), guard="immutable"),
51
+ body=body,
52
+ )
53
+
54
+
55
+ async def _to_workers_response(
56
+ response: Response, workers_response_cls: Any, js_headers_cls: Any
57
+ ) -> Any:
58
+ body = response.body
59
+ if body is not None and not isinstance(body, bytes):
60
+ body = await response.bytes() # buffered crossing in v0.2
61
+ js_headers = js_headers_cls.new()
62
+ for name, value in response.headers.raw():
63
+ js_headers.append(name, value)
64
+ return workers_response_cls(body, status=response.status, headers=js_headers)
65
+
66
+
67
+ class _WorkersExecutionContext(ExecutionContext):
68
+ """Forwards ``wait_until`` to the Workers runtime (``ctx.waitUntil``)."""
69
+
70
+ __slots__ = ("_js_ctx",)
71
+
72
+ def __init__(self, js_ctx: Any) -> None:
73
+ super().__init__()
74
+ self._js_ctx = js_ctx
75
+
76
+ def wait_until(self, awaitable: Any) -> None:
77
+ # Pyodide converts asyncio futures to JS promises at the boundary.
78
+ self._js_ctx.waitUntil(asyncio.ensure_future(awaitable))
79
+
80
+
81
+ def to_workers(app: Hayate) -> type:
82
+ """Build the Workers entrypoint class: ``Default = to_workers(app)``."""
83
+ from js import Headers as JsHeaders
84
+ from workers import Response as WorkersResponse
85
+ from workers import WorkerEntrypoint
86
+
87
+ class Default(WorkerEntrypoint):
88
+ async def fetch(self, request: Any) -> Any:
89
+ hayate_request = await _to_hayate_request(request)
90
+ exec_ctx = _WorkersExecutionContext(self.ctx)
91
+ response = await app.fetch(hayate_request, env=self.env, ctx=exec_ctx)
92
+ return await _to_workers_response(response, WorkersResponse, JsHeaders)
93
+
94
+ return Default