Skip to content

src/genro_asgi/config/handler.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 """ConfigurationHandler — the server's read door on its configuration.16 17 A contrib ``ConfigHandler`` subclass: it inherits the callable four-layer read18 stack (written value → signature default → call-site ``default=`` → noisy19 ``KeyError``) and adds the section→kwargs mapping helpers ``AsgiServer.__init__``20 consumes. It NEVER builds a server: the server builds ITS OWN handler21 (``AsgiServer(config=source)``) and asks these helpers for its kwargs, so there22 is one direction of dependency and no materializer.23 24 The helpers read the tree by two rules, and the grammar decides which applies:25 26 - a node with a CLOSED signature is read attribute by attribute THROUGH the27   handler itself, so the element's signature defaults and any resolver sitting28   in an attribute are honored (``server``, ``provider``, ``mount``, ...);29 - a node with OPEN ``**kwargs`` has no signature to consult, so its attributes30   are read in bulk through ``builder.runtime_values`` — resolvers resolved,31   everything else verbatim (``application``, ``plugin``, ``database``).32 33 Section → constructor kwarg:34 35 - ``server`` → ``host``/``port``/``external_url``/``max_threads``/``shutdown_timeout_seconds``, its36   ``session`` child → ``session_ttl``, its ``tasks`` child → ``tasks``.37 - ``middleware`` → ``middleware`` ({name: bool | dict} switches).38 - ``authentication`` → ``admin_password``/``users``/``tokens`` (the store39   kwargs ``AuthMixin`` peels), ``auth`` (the ``AuthCore`` entries folded from40   ``credentials``) and ``server_app`` (``login`` + ``oidc``, forwarded to the41   ``_server`` application).42 - ``storage`` → ``storage`` (genro-storage's own ``list[dict]`` of mounts) and43   ``storage_key`` (the section's at-rest key material).44 - ``applications`` → ``applications``/``default`` (each entry an45   ``(app_class, kwargs)`` pair the server instantiates).46 - ``databases`` → one descriptor per entry, registered by the server after the47   cooperative chain has run.48 - ``plugins`` → ``plugins`` ({code: bool | dict} switches).49 - ``openapi`` → no core-1a consumer; read and skipped.50 - ``orchestration`` → the SPA front's whole orchestration subtree: its own three51   words, and under it ``commander`` — the vertex's kwargs and one kwargs set per52   declared group (the two installation paths folded in, the child's own keys53   gathered into its ``worker_kwargs``).54 """55 56 from __future__ import annotations57 58 from typing import Any59 60 from genro_builders.contrib.config import ConfigHandler61 62 __all__ = ["ConfigError", "ConfigurationHandler"]63 64 65 class ConfigError(Exception):66     """A configuration recipe names something the runtime cannot honor."""67 68 69 class ConfigurationHandler(ConfigHandler):70     """Read door over an ``asgiconfig`` tree, plus the section→kwargs mapping."""71 72     def server_kwargs(self) -> dict[str, Any]:73         """The ``server`` section as server kwargs, its children lifted.74 75         ``session`` becomes ``session_ttl``, ``tasks`` becomes the ``tasks``76         tuning dict and ``websocket`` the websocket options: all three are77         server-domain (sessions, the task backbone and the sockets live on the78         server), so their values lift to the kwargs the owning mixins peel79         while the config keeps them under ``server`` where they belong. The80         ``origins`` of a handshake are written as one comma-separated string in81         a recipe and reach the server as the list it reads.82         """83         kwargs = self.closed_attrs(84             "server", "host", "port", "external_url", "max_threads", "shutdown_timeout_seconds"85         )86         if self.node("server.session") is not None:87             kwargs["session_ttl"] = self("server.session.ttl")88         if self.node("server.websocket") is not None:89             websocket = self.closed_attrs("server.websocket", "origins", "max_concurrent")90             origins = websocket.get("origins")91             if origins is not None:92                 websocket["origins"] = [part.strip() for part in str(origins).split(",") if part.strip()]93             kwargs["websocket"] = websocket94         if self.node("server.tasks") is not None:95             kwargs["tasks"] = self.closed_attrs(96                 "server.tasks", "enabled", "tick_seconds", "mount"97             )98         return kwargs99 100     def middleware_config(self) -> dict[str, Any] | None:101         """The ``middleware`` switches, or ``None`` when the section is absent102         (the composition's own defaults then apply)."""103         if self.node("middleware") is None:104             return None105         return self.closed_attrs(106             "middleware", "errors", "wellknown", "logging", "cors", "auth", "session"107         )108 109     def identity_kwargs(self) -> dict[str, Any]:110         """The identity STORE kwargs of ``authentication`` (``AuthMixin`` peels them).111 112         ``admin_password`` is the ``admin_password`` node's VALUE, which a113         resolver must supply — the grammar rejects a literal at the recipe114         line (secrets stay out of recipes). Resolving empty is a boot error115         (the recipe promised a secret that does not exist — an empty bootstrap116         password would arm a passwordless SUPERADMIN), and so is resolving to117         a non-string. ``users``/``tokens`` are ``{mount, prefix}`` descriptors.118         """119         kwargs: dict[str, Any] = {}120         password_node = self.node("authentication.admin_password")121         if password_node is not None:122             kwargs["admin_password"] = self.admin_password(password_node)123         for tag in ("users", "tokens"):124             if self.node(f"authentication.{tag}") is not None:125                 kwargs[tag] = self.closed_attrs(f"authentication.{tag}", "mount", "prefix")126         return kwargs127 128     def admin_password(self, node: Any) -> str:129         """The bootstrap password carried by ``node``, resolved to a non-empty string.130 131         The grammar already rejects a literal at the recipe line132         (``node_value: BagResolver``); here we validate what the resolver133         actually DELIVERED at boot.134         """135         value = node.value136         if not value:137             raise ConfigError("authentication.admin_password resolved empty")138         if not isinstance(value, str):139             raise ConfigError("authentication.admin_password must resolve to a string")140         return value141 142     def auth_entries(self) -> dict[str, Any] | None:143         """The ``credentials`` children folded into the ``AuthCore`` sections.144 145         ``basic_user`` entries are keyed by ``username`` and ``bearer_token``146         entries by ``identity`` — the keys ``AuthCore`` reads back as the147         authenticated identity — while ``jwt`` entries stay an ORDERED list (the148         first verifier that verifies wins). ``None`` when nothing is configured:149         the server then arms no header backend.150         """151         node = self.node("authentication.credentials")152         if node is None:153             return None154         basic: dict[str, Any] = {}155         bearer: dict[str, Any] = {}156         jwt: list[dict[str, Any]] = []157         for child in node.value:158             path = f"authentication.credentials.{child.label}"159             if child.node_tag == "basic_user":160                 attrs = self.closed_attrs(path, "username", "password", "tags")161                 basic[attrs.pop("username")] = attrs162             elif child.node_tag == "bearer_token":163                 attrs = self.closed_attrs(path, "identity", "token", "tags")164                 bearer[attrs.pop("identity")] = attrs165             else:166                 jwt.append(167                     self.closed_attrs(168                         path, "name", "secret", "public_key", "algorithm", "tags"169                     )170                 )171         entries = {"basic": basic, "bearer": bearer, "jwt": jwt}172         return {name: value for name, value in entries.items() if value} or None173 174     def server_app_kwargs(self) -> dict[str, Any]:175         """The LOGIN surface of ``authentication`` → the ``_server`` app's kwargs.176 177         ``login`` is the lockout policy and ``oidc`` the providers keyed by178         ``code``. These values belong to the application that peels them, so179         they travel as ONE server kwarg (``server_app``) forwarded at mount time180         instead of being lifted onto the server itself.181         """182         kwargs: dict[str, Any] = {}183         if self.node("authentication.login") is not None:184             kwargs["login"] = self.closed_attrs(185                 "authentication.login", "max_attempts", "backoff"186             )187         providers = self.oidc_providers()188         if providers:189             kwargs["oidc"] = providers190         return kwargs191 192     def oidc_providers(self) -> dict[str, dict[str, Any]]:193         """The ``oidc`` providers as ``{code: attrs}``, defaults applied.194 195         ``scopes`` and ``identity_claim`` come from the element's signature, so196         every provider carries them whether the recipe wrote them or not;197         ``tags`` defaults to the empty list here (a mutable signature default is198         never declared).199         """200         node = self.node("authentication.oidc")201         if node is None:202             return {}203         providers: dict[str, dict[str, Any]] = {}204         for child in node.value:205             attrs = self.closed_attrs(206                 f"authentication.oidc.{child.label}",207                 "issuer",208                 "client_id",209                 "client_secret",210                 "scopes",211                 "identity_claim",212                 "tags",213             )214             attrs.setdefault("tags", [])215             providers[child.label] = attrs216         return providers217 218     def storage_config(self) -> tuple[list[dict[str, Any]], str | None] | None:219         """The ``storage`` section as ``(mounts, storage_key)``, or ``None`` when it220         is absent (the composition builds its default manager).221 222         The subtree is written in genro-storage's grammar, so it is flattened223         GENERICALLY into that library's ``list[dict]``: the tag IS the protocol224         and every attribute rides through as-is (``name`` among them — the225         envelope is transparent to containment, so the children carry auto226         labels and their key lives in the attribute the foreign grammar227         declares). This dialect knows no storage vocabulary to translate.228 229         A section carrying only its ``storage_key`` and no mount is legitimate —230         "the default layout, plus this key" — so it yields an EMPTY mount list231         rather than an error; the composition reads that as "use the default232         ``site:`` mount". With ``BaseConfiguration`` layered underneath the233         merged tree normally carries the ``site`` mount anyway, so this is the234         shape a handler built without parents produces.235         """236         node = self.node("storage")237         if node is None:238             return None239         mounts: list[dict[str, Any]] = []240         for child in node.value or ():241             mount = self.open_attrs(child)242             mount["protocol"] = child.node_tag243             mounts.append(mount)244         return mounts, self("storage.storage_key", default=None)245 246     def plugins_config(self) -> dict[str, bool | dict[str, Any]] | None:247         """The ``plugins`` switches as ``{code: bool | dict}``, or ``None`` when248         the section is absent (the composition arms no extra plugin).249 250         A plugin maps to ``False`` when ``enabled`` is explicitly false, to its251         remaining options when it carries any, else to ``True``.252         """253         node = self.node("plugins")254         if node is None:255             return None256         switches: dict[str, bool | dict[str, Any]] = {}257         for child in node.value:258             options = self.open_attrs(child)259             options.pop("code", None)260             enabled = options.pop("enabled", True)261             if not enabled:262                 switches[child.label] = False263             else:264                 switches[child.label] = options or True265         return switches266 267     def applications(self) -> tuple[list[tuple[type, dict[str, Any]]], str | None]:268         """The declared applications as ``(app_class, kwargs)`` pairs, plus ``default``.269 270         Every attribute of the envelope except ``app_class`` is a constructor271         kwarg of the application — ``code`` and ``mount`` included, since the app272         owns their resolution. The mounted subtree is NOT passed: an application273         reads its own configuration back through the handler274         (``applications.<code>.<path>``), it never receives a slice of the tree.275         """276         node = self.node("applications")277         if node is None:278             return [], None279         entries: list[tuple[type, dict[str, Any]]] = []280         for child in node.value:281             if not child.label:282                 raise ConfigError(283                     "applications: 'code' must be a non-empty string — an empty "284                     "code files the subtree under a label the application's own "285                     "read door can never reach"286                 )287             kwargs = self.open_attrs(child)288             entries.append((kwargs.pop("app_class"), kwargs))289         return entries, self("applications.default", default=None)290 291     def databases(self) -> list[dict[str, Any]]:292         """The ``databases`` descriptors as ``{code, db_class, db_handler_class, params}``.293 294         ``db_handler_class`` is ``None`` when the recipe omits it (the server295         substitutes its default) and ``params`` are the remaining connection296         kwargs handed to ``db_class(**params)``.297         """298         node = self.node("databases")299         if node is None:300             return []301         descriptors: list[dict[str, Any]] = []302         for child in node.value:303             params = self.open_attrs(child)304             params.pop("code", None)305             descriptors.append(306                 {307                     "code": child.label,308                     "db_class": params.pop("db_class"),309                     "db_handler_class": params.pop("db_handler_class", None),310                     "params": params,311                 }312             )313         return descriptors314 315     def orchestration_kwargs(self, code: str) -> dict[str, Any] | None:316         """One application's orchestration node, or ``None`` when it has none.317 318         Args:319             code: the application whose orchestration this is — the words live320                 under ``applications.<code>.orchestration``.321 322         Returns:323             ``profiles_path``, ``profile_name`` and ``control_enabled``, the324             three the recipe actually wrote, or ``None`` when the node is absent325             — which is a front that declares no pool at all.326         """327         if self.node(f"applications.{code}.orchestration") is None:328             return None329         return self.closed_attrs(330             f"applications.{code}.orchestration",331             "profiles_path",332             "profile_name",333             "control_enabled",334         )335 336     def commander_kwargs(self, code: str) -> dict[str, Any] | None:337         """The pool of one application as its vertex's own constructor kwargs.338 339         Args:340             code: the application whose pool this is — a pool belongs to the341                 front that owns it, so the words live under342                 ``applications.<code>.orchestration.commander``.343 344         Returns:345             The vertex's kwargs, or ``None`` when the node is absent — an346             orchestration node with no commander under it, which the front347             refuses.348 349         ``instance_dir`` is NOT among them: the sockets are the workers' business,350         so that path is folded into every group instead (``group_kwargs``). The351         group ELECTED to receive a newcomer is declared one level down, on the352         collection, and is folded in here because the vertex is what reads it.353         What the recipe leaves out is left out, and the vertex's own default354         answers.355         """356         section = f"applications.{code}.orchestration.commander"357         if self.node(section) is None:358             return None359         kwargs = self.closed_attrs(360             section,361             "frozen_users_path",362             "memory_max_percent",363             "machine_memory_alarm_percent",364             "orchestration_log_path",365             "orchestration_log_max_bytes",366             "orchestration_log_backup_count",367             "user_expiry_hours",368             "guest_expiry_hours",369             "cpu_temperature_sample_seconds",370         )371         elected = self(f"{section}.groups.default", default=None)372         if elected is not None:373             kwargs["default_group"] = elected374         return kwargs375 376     def group_kwargs(self, code: str) -> dict[str, dict[str, Any]]:377         """One application's groups as ``{name: kwargs}``, one ``GroupHandler`` each.378 379         Args:380             code: the application whose pool these groups belong to.381 382         The two paths of the installation live on ``commander`` and are folded in383         here, because a group is what builds the workers that need them. The one384         key the CHILD reads travels in its own ``worker_kwargs``: the group's385         name, which stamps every item it writes. So a recipe writes each policy386         once, on the rung it belongs to, and the child is handed what is his.387         """388         section = f"applications.{code}.orchestration.commander"389         node = self.node(f"{section}.groups")390         if node is None:391             return {}392         shared = self.closed_attrs(section, "frozen_users_path", "instance_dir")393         groups: dict[str, dict[str, Any]] = {}394         for child in node.value:395             path = f"{section}.groups.{child.label}"396             kwargs = self.closed_attrs(397                 path,398                 "memory_max_percent",399                 "worker_max_number",400                 "worker_memory_max_percent",401                 "worker_memory_admission_percent",402                 "restart_occupancy_max_percent",403                 "cpu_close_percent",404                 "cpu_admission_close_percent",405                 "cpu_admission_reopen_percent",406                 "cpu_offload_percent",407                 "cpu_retirement_quiet_seconds",408                 "cpu_heating_seconds",409                 "cpu_cooling_seconds",410                 "worker_admission_interval_seconds",411                 "worker_min_life_seconds",412                 "worker_max_users",413                 "user_idle_freeze_minutes",414                 "entry_module",415                 "executable",416                 "worker_class",417                 "main_threadpool_size",418                 "aux_threadpool_size",419                 "worker_kwargs",420                 "engine_factory",421                 "engine_kwargs",422             )423             worker_kwargs = dict(kwargs.pop("worker_kwargs", None) or {}, group=child.label)424             groups[child.label] = {**shared, **kwargs, "worker_kwargs": worker_kwargs}425         return groups426 427     def node(self, path: str) -> Any:428         """The node at ``path`` (relative to the root element), or ``None``."""429         return self.builder.source.get_node(f"{self.root_label}.{path}")430 431     def closed_attrs(self, path: str, *names: str) -> dict[str, Any]:432         """Read ``names`` at ``path`` through the read stack, skipping the absent.433 434         One four-layer read per attribute, so a resolver sitting in an attribute435         resolves and the element's signature defaults apply. ``None`` means436         "not configured and no default" and is left out — the consumer's own437         default then applies.438         """439         attrs: dict[str, Any] = {}440         for name in names:441             value = self(f"{path}.{name}", default=None)442             if value is not None:443                 attrs[name] = value444         return attrs445 446     def open_attrs(self, node: Any) -> dict[str, Any]:447         """Every attribute ``node`` carries, resolvers resolved.448 449         The read for elements whose signature is OPEN (``**kwargs``): there is450         no declared attribute list to walk and no signature default to consult,451         so the node's own attributes are the whole truth.452         """453         return dict(self.builder.runtime_values(node)[1])