Skip to content

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")