src/genro_asgi_multiworker_spa/environ.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """The hosted application's ASGI and WSGI endpoint adapters.16 17 AsgiSeam reconstructs the scope from the HTTP record opened by the worker,18 adds trusted genro.identity/page_id/reply_path, and delegates to the neutral19 BufferedAsgiEndpoint. Bodies are bytes in both directions. The original SPA20 second-read disconnect and finite response-chunk behavior remain available.21 22 WsgiSeam wraps a synchronous application as ASGI and runs it through the23 worker's traffic pool, preserving the active request slot. It translates24 root_path/path to SCRIPT_NAME/PATH_INFO and preserves duplicate headers and25 cookies using the WSGI joining rules. No Python session or Avatar crosses the26 transport; the worker receives only explicitly defined routing context.27 """28 29 from __future__ import annotations30 31 import io32 import sys33 from typing import Any, Callable, Iterable34 35 from genro_asgi.asgi_endpoint import BufferedAsgiEndpoint36 37 __all__ = ["AsgiSeam", "WsgiSeam"]38 39 # The two headers PEP 3333 keeps out of the HTTP_ namespace.40 UNPREFIXED_HEADERS = {"content-type": "CONTENT_TYPE", "content-length": "CONTENT_LENGTH"}41 42 # What travels from the CALL's dict into the hosted code, when it is there.43 CALL_KEYS = ("genro.page_id", "genro.reply_path")44 45 46 class AsgiSeam:47 """One ASGI application, called from the facts of a CALL."""48 49 def __init__(self, asgi_app: Any) -> None:50 """Initialize this instance.51 52 Args:53 asgi_app: the application to call, ``(scope, receive, send)``.54 """55 self.asgi_app = asgi_app56 57 def build_scope(self, http: dict[str, Any], identity: str | None = None) -> dict[str, Any]:58 """The ASGI scope for one ``http`` dict.59 60 Args:61 http: the facts the front packed.62 identity: the CALL's identity, ``None`` when the caller named none.63 64 Returns:65 An ``http`` scope. ``root_path`` is empty: the path the front66 forwards is already mount-relative, so the whole of it is ``path``.67 ``server`` comes from the Host header, the only place the front's68 own address survives the packing.69 """70 headers = [71 (str(name).encode("latin-1"), str(value).encode("latin-1"))72 for name, value in http.get("headers") or []73 ]74 host, port = self.server_address(75 next((v.decode("latin-1") for n, v in headers if n.lower() == b"host"), "")76 )77 client = http.get("client") or []78 scope: dict[str, Any] = {79 "type": "http",80 "asgi": {"version": "3.0", "spec_version": "2.3"},81 "http_version": http.get("http_version", "1.1"),82 "method": http.get("method", "GET"),83 "scheme": http.get("scheme") or "http",84 "path": http.get("path", "/"),85 "raw_path": http.get("raw_path", str(http.get("path", "/")).encode("utf-8")),86 "root_path": http.get("root_path", ""),87 "query_string": str(http.get("query_string", "")).encode("latin-1"),88 "headers": headers,89 "server": tuple(http["server"]) if http.get("server") else (host, int(port)),90 "client": (str(client[0]), int(client[1])) if len(client) > 1 else None,91 "genro.identity": identity,92 }93 for key in CALL_KEYS:94 if http.get(key.split(".")[1]) is not None:95 scope[key] = http[key.split(".")[1]]96 return scope97 98 def server_address(self, host_header: str) -> tuple[str, str]:99 """Split a Host header into ``(name, port)``."""100 if not host_header:101 return "localhost", "80"102 host, _, port = host_header.partition(":")103 return host, port or "80"104 105 async def serve(self, http: dict[str, Any], identity: str | None = None) -> dict[str, Any]:106 """Call the application on one ``http`` dict and shape its reply.107 108 Args:109 http: the facts the front packed.110 identity: the CALL's identity.111 112 Returns:113 ``{"status", "headers", "body"}``, the body raw bytes — the same shape114 the WSGI road produced before this seam existed.115 116 Raises:117 RuntimeError: the application answered nothing.118 """119 scope = self.build_scope(http, identity)120 return await BufferedAsgiEndpoint(self.asgi_app, reject_streaming=False).serve(121 scope, http.get("body") or b""122 )123 124 125 class WsgiSeam:126 """One WSGI callable, reached as an ASGI application.127 128 What a hosted ASGI application calls to delegate one request to the legacy,129 and the road the core itself takes for the ``wsgi_app`` shortcut: one way130 in, whether the consumer delegates or hosts nothing else.131 132 It holds no state of a request: the same instance serves concurrent133 requests, because the router of a consumer calls it that way.134 """135 136 def __init__(self, wsgi_app: Callable[..., Iterable[bytes]], worker: Any) -> None:137 """Initialize this instance.138 139 Args:140 wsgi_app: the consumer's WSGI callable, ``(environ, start_response)``.141 worker: the worker whose traffic pool runs it — WSGI is synchronous,142 and the request's slot follows the work onto that thread.143 """144 self.wsgi_app = wsgi_app145 self.worker = worker146 147 def build_environ(self, scope: dict[str, Any], body: bytes) -> dict[str, Any]:148 """The PEP 3333 environ for one ASGI scope.149 150 Args:151 scope: the http scope of the request being delegated.152 body: the whole request body, already drained.153 154 Returns:155 The environ. ``SCRIPT_NAME`` is the scope's ``root_path`` and156 ``PATH_INFO`` what is left of ``path`` once that prefix is taken157 off — so the legacy site's view of its own URLs does not change,158 whether the router in front of it moved the prefix into159 ``root_path`` or left the path whole.160 """161 script_name = str(scope.get("root_path") or "")162 path_info = str(scope.get("path") or "/")163 if script_name and path_info.startswith(script_name):164 path_info = path_info[len(script_name) :]165 headers = [166 (name.decode("latin-1"), value.decode("latin-1"))167 for name, value in scope.get("headers") or []168 ]169 environ: dict[str, Any] = {170 "REQUEST_METHOD": scope.get("method", "GET"),171 "SCRIPT_NAME": script_name,172 "PATH_INFO": path_info,173 "QUERY_STRING": bytes(scope.get("query_string") or b"").decode("latin-1"),174 "SERVER_PROTOCOL": "HTTP/1.1",175 "wsgi.version": (1, 0),176 "wsgi.url_scheme": scope.get("scheme") or "http",177 "wsgi.input": io.BytesIO(body),178 "wsgi.errors": sys.stderr,179 "wsgi.multithread": True,180 "wsgi.multiprocess": False,181 "wsgi.run_once": False,182 "genro.identity": scope.get("genro.identity"),183 }184 for key in CALL_KEYS:185 if key in scope:186 environ[key] = scope[key]187 environ.update(self.header_environ(headers))188 if body and "CONTENT_LENGTH" not in environ:189 environ["CONTENT_LENGTH"] = str(len(body))190 server = scope.get("server") or ("localhost", 80)191 environ["SERVER_NAME"] = str(server[0])192 environ["SERVER_PORT"] = str(server[1])193 client = scope.get("client")194 if client:195 environ["REMOTE_ADDR"] = str(client[0])196 if len(client) > 1:197 environ["REMOTE_PORT"] = str(client[1])198 return environ199 200 def header_environ(self, headers: list[tuple[str, str]]) -> dict[str, str]:201 """The header half of the environ: ``HTTP_*`` keys, duplicates joined.202 203 A repeated header is one environ key holding the values comma-joined —204 the reassembly PEP 3333 prescribes, and the reason the wire carries a205 pair-list instead of a mapping. ``Cookie`` is the one exception: its206 pairs rejoin with ``"; "`` (RFC 6265, restated by RFC 7540 §8.1.2.5) —207 a comma would fuse two cookies into one mangled value.208 """209 packed: dict[str, str] = {}210 for name, value in headers:211 key = UNPREFIXED_HEADERS.get(name.lower()) or f"HTTP_{name.upper().replace('-', '_')}"212 if key in packed:213 joiner = "; " if key == "HTTP_COOKIE" else ","214 packed[key] = f"{packed[key]}{joiner}{value}"215 else:216 packed[key] = value217 return packed218 219 async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:220 """Serve one ASGI request through the WSGI callable.221 222 Args:223 scope: the http scope; ``root_path`` becomes ``SCRIPT_NAME``.224 receive: drained to the last ``http.request`` before the app runs.225 send: gets one ``http.response.start`` and one226 ``http.response.body`` — ``Set-Cookie`` and ``Location`` travel227 like any other header, so a legacy redirect reaches the browser228 as it is.229 230 The callable is synchronous, so it runs on the worker's traffic pool231 through ``run_sync``: the request's slot follows it onto that thread,232 and whatever the site announces while serving rides this CALL's reply.233 234 An application that read the body itself delegates with an EMPTY one —235 what is left on ``receive`` by then is the disconnect — so delegate236 before reading, or hand the legacy what you read some other way.237 """238 body = await self.read_body(receive)239 environ = self.build_environ(scope, body)240 status, headers, payload = await self.worker.run_sync(241 lambda: self.serve_environ(environ)242 )243 await send(244 {245 "type": "http.response.start",246 "status": status,247 "headers": [248 (name.encode("latin-1"), value.encode("latin-1")) for name, value in headers249 ],250 }251 )252 await send({"type": "http.response.body", "body": payload})253 254 async def read_body(self, receive: Any) -> bytes:255 """Drain the request body to its last chunk."""256 chunks: list[bytes] = []257 while True:258 message = await receive()259 if message["type"] == "http.disconnect":260 break261 chunks.append(message.get("body", b""))262 if not message.get("more_body"):263 break264 return b"".join(chunks)265 266 def serve_environ(self, environ: dict[str, Any]) -> tuple[int, list[tuple[str, str]], bytes]:267 """Run the WSGI callable on one environ, on the calling thread.268 269 Returns:270 The status, the headers and the whole body.271 272 The iterable is consumed FIRST and closed as PEP 3333 requires; the273 deprecated ``write`` callable is supported the only way a whole-body274 reply can support it — its chunks lead the bytes the iterable yields.275 """276 status = "200 OK"277 headers: list[tuple[str, str]] = []278 written: list[bytes] = []279 280 def start_response(281 answer: str, answer_headers: list[tuple[str, str]], exc_info: Any = None282 ) -> Callable[[bytes], None]:283 nonlocal status, headers284 status, headers = answer, list(answer_headers)285 return written.append286 287 result = self.wsgi_app(environ, start_response)288 try:289 chunks = list(result)290 body = b"".join(written + chunks)291 finally:292 close = getattr(result, "close", None)293 if close is not None:294 close()295 return int(status.split(" ", 1)[0]), headers, body