Skip to content

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