src/genro_asgi/application.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 """App-side contract: the base class every mountable application extends.16 17 ``BaseApplication`` is what the server requires of an app (SPECIFICATION.md18 §4, D7): an ASGI callable (``__call__`` implemented by concrete subclasses)19 with an identity (``code``) and a placement (``mount``), a ``server``20 property assigned exactly once by the owning server at attach time (ownership21 channel, one direction — a second assignment raises ``RuntimeError``), and22 lifecycle hooks ``on_startup``/``on_shutdown`` that subclasses may override23 as sync OR async (the caller detects which at call time).24 25 An application is a triplet **code + instance + mount**. ``code`` names it26 (the key of ``server.applications``); ``mount`` is the URL prefix it answers27 under, and ``""`` is the site root — a legitimate value, never a "missing"28 one. Both are class attributes a subclass sets declaratively and a29 constructor kwarg overrides per instance, so the same class can be installed30 twice under different codes:31 32 .. code-block:: python33 34 class Shop(RoutedApplication):35 mount = "" # this app is a site root by design36 37 Shop(code="outlet", mount="outlet")38 39 Cooperative init (D16): every class in the family implements40 ``__init__(self, **kwargs)``, peels ITS OWN kwargs and forwards the rest via41 ``super().__init__(**rest)``. Mixins go BEFORE the base in the MRO; this base42 is the end of the chain and raises ``TypeError`` naming any leftover kwargs.43 44 Every application also carries its own CONFIGURATION GRAMMAR as the class45 attribute ``grammar``, inherited by MRO like ``code``/``mount``: the site recipe46 mounts it at the ``application(app_class=...)`` line (subbuilder by reference),47 so the app declares its own vocabulary and the site dialect never validates it.48 ``ApplicationGrammar`` is the minimal one every app inherits — a single49 ``parameters`` element for free options — and a richer app subclasses it.50 51 An app READS that subtree back through ``self.config(path)``, which prefixes52 ``applications.<code>.`` and delegates to the server's own read door: the app53 never holds a slice of the tree, only an address in it.54 """55 56 from __future__ import annotations57 58 from typing import TYPE_CHECKING, Any59 60 from genro_builders.builder import element61 62 if TYPE_CHECKING:63 from .server import BaseServer64 from .types import Receive, Scope, Send65 66 __all__ = ["ApplicationGrammar", "BaseApplication"]67 68 _MISSING = object()69 70 71 class ApplicationGrammar:72 """The configuration grammar every application inherits.73 74 One element, ``parameters``, for the free options a plain app needs: an75 application with nothing of its own still has a mountable grammar (an EMPTY76 grammar class is rejected by builders), and a richer app subclasses this to77 add its own vocabulary.78 """79 80 @element(node_label="parameters")81 def parameters(self, **options: Any) -> None:82 """Free application options, read back as83 ``applications.<code>.parameters.<name>``."""84 85 86 class BaseApplication:87 """Base class for applications attached to a ``BaseServer``.88 89 Constructor kwargs peeled here: ``code`` — the application's identity,90 empty meaning the class name lowercased — and ``mount`` — the URL prefix91 it answers under, ``None`` meaning the same as the code. Both default to92 the class attributes below, so a subclass can set them declaratively.93 """94 95 code: str = ""96 mount: str | None = None97 grammar: type = ApplicationGrammar98 99 def __init__(self, **kwargs: Any) -> None:100 cls = type(self)101 code: str = kwargs.pop("code", cls.code) or cls.__name__.lower()102 mount: str | None = kwargs.pop("mount", cls.mount)103 self.code = code104 # ``is None`` and never truthiness: ``mount=""`` IS the site root, and105 # ``mount or code`` would silently move a root app to ``/code``.106 self.mount = code if mount is None else mount107 self._server: BaseServer | None = None108 if kwargs:109 unexpected = ", ".join(sorted(kwargs))110 raise TypeError(111 f"{type(self).__name__}.__init__() got unexpected keyword arguments: {unexpected}"112 )113 super().__init__()114 115 @property116 def server(self) -> BaseServer | None:117 """The server that owns this app (``None`` until attached)."""118 return self._server119 120 @server.setter121 def server(self, value: BaseServer) -> None:122 """Assign the owning server once; a second assignment raises ``RuntimeError``."""123 if self._server is not None:124 raise RuntimeError(f"{type(self).__name__} is already owned by a server")125 self._server = value126 127 def config(self, path: str, default: Any = _MISSING) -> Any:128 """Read this application's own configuration through the server's read door.129 130 Paths are relative to the application: ``self.config("parameters.title")``131 reads ``applications.<code>.parameters.title``, so an app addresses its132 own mounted subtree and never a slice of someone else's. The four-layer133 read stack (written value → signature default → call-site ``default`` →134 noisy ``KeyError``) is the handler's, untouched.135 136 With nothing to read — no server, or a server built bare — the call-site137 ``default`` answers, and its absence raises the same noisy ``KeyError``.138 """139 full_path = f"applications.{self.code}.{path}"140 handler = getattr(self.server, "config", None)141 if handler is None:142 if default is _MISSING:143 raise KeyError(144 f"missing config value '{full_path}': "145 f"{type(self).__name__} is not attached to a configured server"146 )147 return default148 if default is _MISSING:149 return handler(full_path)150 return handler(full_path, default=default)151 152 @property153 def handshake_cookie(self) -> str | None:154 """The cookie a websocket handshake must carry to reach this application.155 156 Returns:157 The cookie's name, or ``None`` — this application gates nothing at158 the handshake, which is the base answer.159 160 The path of a handshake names its home application, and the server asks161 THAT application whether a connection is admissible before accepting162 one. An application whose messages only make sense for a known163 connection — the SPA, whose every message is a request of a user — names164 its cookie here, and a handshake without it is accepted and closed with165 1008, so the browser reads why.166 """167 return None168 169 @property170 def app_snapshot(self) -> dict[str, Any]:171 """This app as the monitor sees it, at the instant it is read.172 173 The monitor aggregates one entry per mounted application by reading174 this on each. Subclasses extend it with their own panel data175 (registers, pool, gauges) on top of these identity facts.176 """177 return {"class": type(self).__name__, "code": self.code, "mount": self.mount}178 179 @property180 def app_panel(self) -> dict[str, Any]:181 """Which panel renders this app on the monitor shell: a class constant.182 183 The presentation complement of ``app_snapshot``: the snapshot carries184 the data (polled), this says who draws it (fetched once). ``panel``185 names a renderer in the shell's registry and an unknown name falls186 back to the generic one; ``src`` — when a subclass declares it — is187 the module URL the shell imports to learn that renderer.188 """189 return {"panel": "generic"}190 191 def on_startup(self) -> None:192 """Lifecycle hook run at server startup. Override as sync or async."""193 194 def on_shutdown(self) -> None:195 """Lifecycle hook run at server shutdown. Override as sync or async."""196 197 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:198 """ASGI entry point: concrete applications must implement it."""199 raise NotImplementedError(f"{type(self).__name__} does not implement the ASGI callable")