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