Skip to content

src/genro_asgi_multiworker_spa/inspector_section.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 ``_server/inspector`` section: the SPA pool shown to a human.16 17 Three addresses, one purpose — watching a pool while it works:18 19     ``/_server/inspector/page``     the page (HTML, from ``resources/``)20     ``/_server/inspector/census``   the whole pool as JSON, under the front's code21     ``/_server/inspector/stream``   the observation stream, as SSE22 23 **Mounting IS the gate.** The SPA front attaches this section on its own24 startup, only when ``GNR_ASGI_INSPECTOR`` is set, and no route carries an25 ``auth_rule``: the inspector is a collaudo instrument, so it exists where26 somebody asked for it and nowhere else. It lives here, in the SPA world, and27 not among the server sections, because it reads a pool and nothing else — a28 core that carries no orchestration must not import one to look at it.29 30 **It never traverses the hosted site.** No cookie is minted, no connection is31 opened, no site path is called: the section reads the commander's own surfaces32 (``get_pool_census``) and its observation stream. An observer that changes33 what it observes is useless.34 35 Parent (dual relationship): the SpaApplication that attached it, stored as36 ``self.application``. A server has ONE (owner, 2026-09-07), so the census is37 keyed by that front's code and carries the one entry the page already reads.38 """39 40 from __future__ import annotations41 42 import asyncio43 from collections.abc import AsyncIterator44 from pathlib import Path45 from typing import TYPE_CHECKING, Any46 47 from genro_routes import RoutingClass, route48 49 from genro_asgi.sse import SseStream50 from genro_asgi.streaming import StreamingResponse51 52 if TYPE_CHECKING:53     from .spa_app import SpaApplication54 55 __all__ = ["InspectorSection", "INSPECTOR_ENV_VAR"]56 57 #: The environment variable whose presence mounts the inspector at all.58 INSPECTOR_ENV_VAR = "GNR_ASGI_INSPECTOR"59 60 61 class InspectorSection(RoutingClass):62     """The ``_server/inspector`` mount: the page, the census, the stream.63 64     Args:65         application: the SpaApplication that attached this section — the front66             whose pool is being watched.67     """68 69     def __init__(self, application: SpaApplication) -> None:70         self.application = application71 72     @route(media_type="text/html")73     def page(self) -> str:74         """The inspector page: the commander above, one row per worker below.75 76         Note:77             Route: GET /_server/inspector/page78         """79         return (Path(__file__).parent / "resources" / "inspector.html").read_text()80 81     @route(media_type="application/json")82     async def census(self) -> dict[str, Any]:83         """This front's whole pool, keyed by its application code.84 85         Note:86             Route: GET /_server/inspector/census87         """88         return {self.application.code: await self.application.commander.get_pool_census()}89 90     @route()91     async def stream(self) -> StreamingResponse:92         """The observation stream: one ``census`` event, then every mutation.93 94         Note:95             Route: GET /_server/inspector/stream96         """97         return SseStream(self.observation_events(), retry_ms=2000).response()98 99     async def observation_events(self) -> AsyncIterator[dict[str, Any]]:100         """The events the page reads: the opening census, then what the pool reports.101 102         Subscribes the front's commander on open and unsubscribes it when the103         reader goes away — which is what switches the workers' reporting off104         again.105         """106         queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()107         commander = self.application.commander108         await commander.subscribe_observation(queue)109         try:110             yield {"event": "census", "data": await self.census()}111             while True:112                 yield {"event": "observation", "data": await queue.get()}113         finally:114             await commander.unsubscribe_observation(queue)