Skip to content

src/genro_asgi/config/elements.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 """AsgiServerGrammar — the configuration grammar of ``AsgiServer``.16 17 The grammar the server class exposes as ``AsgiServer.grammar``: one18 ``configuration`` root (the contrib ``ConfigBuilder`` element, OVERRIDDEN here19 with the full closed section list) whose sections describe the whole site.20 Every section is a singleton (``[0:1]``), so labels are clean and every path is21 stable and hand-writable: ``configuration.server``,22 ``configuration.authentication.oidc.<code>``,23 ``configuration.applications.<code>``.24 25 Authoring conventions inherited from contrib/config:26 27 - attributes are ANNOTATED, so their signature defaults reach the read stack of28   ``ConfigurationHandler`` (an unannotated parameter never enters29   ``call_args_validations``);30 - an attribute whose value may come from outside (env, file, url) is annotated31   ``<type> | BagResolver`` and receives the resolver IN PLACE — there are no32   ``^pointer`` strings in this dialect;33 - the recipe orchestrates in ``main`` and delegates each section to a method34   taking the PARENT node.35 36 Sections:37 38 - ``server`` — the runtime options (``host``, ``port``, ``external_url``,39   ``max_threads``, ``shutdown_timeout_seconds``) plus the server-domain children ``session``40   (the session TTL) and ``tasks`` (declared by ``TaskGrammar``, the class that41   peels ``tasks=``).42 - ``middleware`` — one ``{name: bool | dict}`` switch per middleware.43 - ``authentication`` — the whole identity surface: the bootstrap44   ``admin_password``, the ``users``/``tokens`` store descriptors, the ``login``45   lockout policy, the ``oidc`` provider collection and the ``credentials``46   handed to ``AuthCore``.47 - ``storage`` — the mount point of genro-storage's own grammar: the mounts of48   the server's ``StorageManager``, plus the section's ``storage_key``.49 - ``applications`` — the app collection keyed by ``code``; each entry MOUNTS50   the grammar its ``app_class`` carries.51 - ``databases`` — one descriptor per database handler.52 - ``plugins`` — the router plugins armed on every routed app.53 - ``openapi`` — the OpenAPI metadata (grammar only in core 1a).54 55 The SPA pool is NOT a section of this dialect: a pool belongs to the application56 that owns it, so its words live in that application's own grammar and its recipe57 is written under ``applications.<code>.orchestration.commander``.58 59 A recipe subclasses ``AsgiConfigBuilder`` and overrides ``main(self, root)``;60 application classes are imported and passed as objects::61 62     from myshop.app import Application as Shop63 64     class ServerConfiguration(AsgiConfigBuilder):65         def main(self, root):66             cfg = root.configuration()67             cfg.server(host="127.0.0.1", port=8000)68             cfg.middleware(cors=True)69             cfg.applications(default="shop").application(code="shop", app_class=Shop)70 """71 72 from __future__ import annotations73 74 from typing import Any75 76 from genro_bag import BagResolver77 from genro_builders.builder import element78 79 from ..tasks.mixin import TaskGrammar80 81 82 class AsgiServerGrammar(TaskGrammar):83     """Configuration grammar of ``AsgiServer``: the site layout, reading elsewhere.84 85     Grammar only — the runtime reads the built tree through86     ``ConfigurationHandler``, never through this class. Capability-owned87     companions are composed explicitly (``TaskGrammar`` — the ``tasks`` child of88     ``server``, owned by ``TaskMixin``).89     """90 91     @element(92         sub_tags=(93             "server[0:1],middleware[0:1],authentication[0:1],storage[0:1],"94             "applications[0:1],databases[0:1],plugins[0:1],openapi[0:1]"95         ),96         node_label="configuration",97     )98     def configuration(self) -> None:99         """Root element of the configuration document (one per recipe).100 101         Overrides the contrib root with the full section list of this dialect.102         Each section is a singleton, so its label IS its tag and every path103         below it is stable.104         """105 106     @element(parent_tags="configuration", sub_tags="session[0:1],tasks[0:1],websocket[0:1]")107     def server(108         self,109         host: str | BagResolver = None,110         port: int | BagResolver = None,111         external_url: str | BagResolver = None,112         max_threads: int | BagResolver = None,113         shutdown_timeout_seconds: float | BagResolver = None,114     ) -> None:115         """Server runtime options.116 117         ``host``/``port`` become the defaults of ``AsgiServer.serve``.118 119         ``external_url`` is the server's PUBLIC base address — what the server120         calls itself when it hands its own URL to a third party121         (``https://shop.example.com``; a trailing slash is stripped). It is not122         the listener: behind a proxy the bind address and the public address123         differ, and only the latter is meaningful to an outside caller. Required124         when an ``oidc`` provider is configured — the provider is given an125         absolute ``redirect_uri`` — and a boot error when missing there.126 127         ``max_threads`` sizes the server's thread pool: ``BaseServer`` peels it128         and hands it to ``WorkPool`` (omitted, the stdlib default129         ``min(32, cpu + 4)`` applies).130 131         ``shutdown_timeout_seconds`` (5.0) bounds how long uvicorn waits for open132         connections at shutdown before cancelling them: one endless response —133         an SSE stream a client never closes — would otherwise hold the process134         for ever, and the lifespan shutdown that stops the applications would135         never run.136 137         Children are server-domain: ``session`` (the session TTL), ``tasks``138         (the task backbone, declared by ``TaskGrammar``) and ``websocket``.139         """140 141     @element(parent_tags="server", sub_tags="")142     def websocket(143         self,144         origins: str | BagResolver = None,145         max_concurrent: int | BagResolver = None,146     ) -> None:147         """Websocket options, server-domain like the session and the tasks.148 149         ``origins`` is the comma-separated list of Origins a handshake may come150         from — ``*`` admits every one, and the default, an empty list, admits151         only the host the handshake came to. A handshake with no ``Origin`` at152         all passes either way: the gate exists against a page on another site,153         not against a client of its own.154 155         ``max_concurrent`` is how many messages of ONE connection may be served156         at once (default 16). The control ping is answered outside it, so a157         connection whose slots are all busy still answers "are you there".158         """159 160     @element(parent_tags="server", sub_tags="")161     def session(self, ttl: int) -> None:162         """Session options: ``ttl`` (seconds, REQUIRED — the grammar rejects a163         session without it) → the server's ``session_ttl`` kwarg. Server-domain,164         so it lives under ``server``, not under an application."""165 166     @element(parent_tags="configuration", sub_tags="")167     def middleware(168         self,169         errors: bool | dict = None,170         wellknown: bool | dict = None,171         logging: bool | dict = None,172         cors: bool | dict = None,173         auth: bool | dict = None,174         session: bool | dict = None,175     ) -> None:176         """Global middleware switches: one ``{name: bool | dict}`` kwarg per177         middleware. A dict value enables the middleware and becomes its178         constructor options. The names are the core's own registry179         (``middleware.default_registry()``); one registered through180         ``middleware_registry=`` is not configurable here."""181 182     @element(183         parent_tags="configuration",184         sub_tags=(185             "admin_password[0:1],users[0:1],tokens[0:1],"186             "login[0:1],oidc[0:1],credentials[0:1]"187         ),188         node_label="authentication",189     )190     def authentication(self) -> None:191         """The server's whole identity surface.192 193         Both the identity STORES (``admin_password``, ``users``, ``tokens`` →194         the kwargs ``AuthMixin`` peels) and the LOGIN surface (``login``,195         ``oidc`` → forwarded to the ``_server`` application) are configured196         here: one section for one subject, whichever object consumes the value.197         """198 199     @element(parent_tags="authentication", sub_tags="")200     def admin_password(self, node_value: BagResolver = None) -> None:201         """The SUPERADMIN bootstrap password as the NODE VALUE, supplied by a202         resolver — never a literal (secrets stay out of recipes; the signature203         rejects a literal at the recipe line). Resolving empty, or to anything204         but a string, is a boot error."""205 206     @element(parent_tags="authentication", sub_tags="")207     def users(self, mount: str = None, prefix: str = None) -> None:208         """Identity store descriptor: ``{mount, prefix}`` (or empty for the209         default) — the ``users=`` kwarg ``AuthMixin`` peels."""210 211     @element(parent_tags="authentication", sub_tags="")212     def tokens(self, mount: str = None, prefix: str = None) -> None:213         """Api-key store descriptor: ``{mount, prefix}`` — the ``tokens=`` kwarg214         ``AuthMixin`` peels."""215 216     @element(parent_tags="authentication", sub_tags="")217     def login(self, max_attempts: int = None, backoff: float = None) -> None:218         """Login-surface policy: lockout tuning (``max_attempts``, ``backoff``)219         — forwarded to ``ServerApplication``, which peels ``login=``."""220 221     @element(222         parent_tags="authentication",223         sub_tags="provider",224         collection_key="code",225         node_label="oidc",226     )227     def oidc(self) -> None:228         """Collection of OIDC providers, each labelled by its ``code`` — stable229         paths ``authentication.oidc.<code>``."""230 231     @element(parent_tags="oidc", sub_tags="")232     def provider(233         self,234         code: str,235         issuer: str = None,236         client_id: str = None,237         client_secret: str | BagResolver = None,238         scopes: str = "openid email profile",239         identity_claim: str = "email",240         tags: str | list = None,241     ) -> None:242         """One OIDC provider: ``code`` (the collection key, REQUIRED),243         ``issuer``, ``client_id``, ``client_secret`` (optional — a public client244         has none; give it a resolver), plus the defaulted ``scopes``,245         ``identity_claim`` and ``tags``."""246 247     @element(248         parent_tags="authentication",249         sub_tags="basic_user,bearer_token,jwt",250         node_label="credentials",251     )252     def credentials(self) -> None:253         """The header credentials handed to ``AuthCore``.254 255         Three repeatable children, one per backend. They are NOT a keyed256         collection: the three tags key differently (``username``, ``identity``,257         nothing at all for ``jwt``, which is an ordered list), so the handler258         folds them into ``AuthCore``'s own shapes by reading each child.259         """260 261     @element(parent_tags="credentials", sub_tags="")262     def basic_user(263         self,264         username: str,265         password: str | BagResolver = None,266         tags: str = None,267     ) -> None:268         """One HTTP Basic user: ``username`` (REQUIRED — the ``AuthCore`` key),269         ``password`` (give it a resolver) and comma-separated ``tags``."""270 271     @element(parent_tags="credentials", sub_tags="")272     def bearer_token(273         self,274         identity: str,275         token: str | BagResolver = None,276         tags: str = None,277     ) -> None:278         """One static Bearer token: ``identity`` (REQUIRED — the identity the279         token authenticates as), ``token`` (give it a resolver) and280         comma-separated ``tags``."""281 282     @element(parent_tags="credentials", sub_tags="")283     def jwt(284         self,285         name: str = None,286         secret: str | BagResolver = None,287         public_key: str | BagResolver = None,288         algorithm: str = "HS256",289         tags: str = None,290     ) -> None:291         """One JWT verifier (repeatable, an ORDERED list — the first that292         verifies wins): ``secret`` (shared HMAC material, the only kind that may293         also SIGN) or ``public_key`` (verify only), the ``algorithm``, an294         optional ``name`` and comma-separated ``tags``."""295 296     @element(parent_tags="configuration", _meta={"subbuilder": "app:grammar"})297     def storage(self, app: type, storage_key: str | BagResolver = None) -> None:298         """The server's storage, and the MOUNT POINT of genro-storage's grammar.299 300         This dialect declares NO storage vocabulary of its own: ``app``301         (``StorageManager``, REQUIRED — the subbuilder reference reads the call302         site, so it cannot be defaulted in the signature) carries the grammar303         governing this node's children, and the mounts are written in304         genro-storage's own words — one element per protocol, the tag IS the305         protocol. The elements hang DIRECTLY under this node: the envelope is306         transparent to containment, so the foreign ``mounts`` collection is not307         part of the recipe.308 309         ``storage_key`` is the at-rest key material of the whole section310         (comma-separated Fernet keys — the first encrypts, all decrypt, for311         rotation), and belongs here rather than on ``server`` because it is312         meaningless without the mounts it unlocks. Give it a resolver so the313         secret stays out of the recipe — a resolver, never a lambda, since a314         callback does not serialize::315 316             from genro_bag.resolvers import EnvResolver317             from genro_storage import StorageManager318 319             def storage_section(self, cfg):320                 s = cfg.storage(app=StorageManager,321                                 storage_key=EnvResolver("GENRO_STORAGE_KEY"))322                 s.local(name="site", base_path=".")323                 s.s3(name="uploads", bucket="shop-media",324                      default_encrypted="shopspa")325 326         Omitted entirely, the server builds its default manager: the single327         ``site:`` mount on the deployment directory.328         """329 330     @element(parent_tags="configuration", sub_tags="application", collection_key="code")331     def applications(self, default: str = None) -> None:332         """Collection of applications, each labelled by its ``code``. The333         optional ``default`` names the application ``/`` **redirects to** (307)334         when no application answers the site root; it elects nothing."""335 336     @element(parent_tags="applications", _meta={"subbuilder": "app_class:grammar"})337     def application(338         self,339         app_class: type,340         code: str = None,341         mount: str = None,342         **app_kwargs: Any,343     ) -> None:344         """One application, and the MOUNT POINT of its own grammar.345 346         ``app_class`` (the imported class, REQUIRED) carries the grammar347         governing this node's children (``app_class.grammar``, subbuilder by348         reference): the site dialect never validates an app's internal349         vocabulary, the app itself declares it. ``code`` is the collection key,350         ``mount`` the URL prefix (defaulting to ``code``; ``mount=""`` is the351         site root — the one application answering ``/`` and every unclaimed352         path). Remaining kwargs are the app's own constructor kwargs and stay353         open — the envelope's attributes belong to THIS grammar, only its354         children live in the mounted one.355         """356 357     @element(parent_tags="configuration", sub_tags="database", collection_key="code")358     def databases(self) -> None:359         """Collection of database descriptors, each labelled by its ``code``."""360 361     @element(parent_tags="databases", sub_tags="")362     def database(363         self,364         db_class: type,365         code: str = None,366         db_handler_class: type = None,367         **params: Any,368     ) -> None:369         """One database: ``code`` (the registry key), ``db_class`` (REQUIRED —370         the grammar rejects a database without it), the optional371         ``db_handler_class`` (``AsgiDbHandlerBase`` when omitted) and the372         connection kwargs handed to ``db_class(**params)``. The ``db_class`` is373         user-provided — the core never imports db drivers."""374 375     @element(parent_tags="configuration", sub_tags="plugin", collection_key="code")376     def plugins(self) -> None:377         """Collection of router plugins, each labelled by its ``code``.378         Materialized as the server's ``plugins=`` switches (``PluginMixin``):379         the server arms every enabled plugin onto each routed app it hosts."""380 381     @element(parent_tags="plugins", sub_tags="")382     def plugin(self, code: str = None, enabled: bool = True, **options: Any) -> None:383         """One router plugin: ``code`` (the collection key), optional384         ``enabled`` (set False to leave it unarmed) and arbitrary options handed385         to ``router.plug(code, **options)``."""386 387     @element(parent_tags="configuration", sub_tags="")388     def openapi(389         self,390         title: str = None,391         version: str = None,392         description: str = None,393     ) -> None:394         """OpenAPI metadata: ``title``, ``version``, ``description``. Grammar395         only in core 1a — the OpenAPI application arrives in core 1c; read and396         skipped here."""