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