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)