Source code for genro_asgi.config.elements

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""AsgiConfigElements — grammar for the ``asgiconfig`` dialect (D15).

The config recipe describes the WHOLE site (D15), even where core 1a does not
materialize every section. Each ``@element`` is a top-level section built
directly on the recipe root (``root.server(...)``, ``root.applications(...)``);
there is no enclosing ``config`` node. A section's kwargs become the node's
attributes (read back by ``ConfigurationHandler.materialize`` via
``node.node_tag`` / ``node.fixed_attr_items()``).

Sections and their 1a fate:

- ``server`` — runtime options (``host``, ``port``, ``external_url``,
  ``max_threads``, ``storage_key``). ``host``/``port`` feed
  ``AsgiServer.serve``; ``external_url`` is the server's public base address
  (the OIDC ``redirect_uri`` prefix); ``max_threads`` sizes the server's thread
  pool (peeled by ``BaseServer``, handed to ``WorkPool``); ``storage_key``
  installs at-rest encryption on the server's storage (core 1b).
- ``middleware`` — one ``{name: bool | dict}`` switch per middleware.
- ``storage`` — mounts of the server's ``LocalStorage`` (core 1b); visible to
  every role (survival infrastructure).
- ``auth`` — the credential config (``basic``/``bearer``/``jwt`` kwargs) handed
  verbatim to ``AuthCore`` via the ``auth=`` server kwarg.
- ``applications``/``application`` — the app collection keyed by ``code``; the
  optional ``default`` names the primary (served on ``/``). Each app derives
  its mount from ``code`` unless it declares ``mount``.
- ``groups``/``group`` — grammar only (orchestration package).
- ``databases``/``database`` — grammar only (core 1b).
- ``plugins``/``plugin`` — router plugins armed onto every routed app
  (``PluginMixin``); visible to every role (a worker needs the same router
  behavior).
- ``openapi`` — grammar only (core 1c).

A recipe subclasses ``AsgiConfigBuilder`` and overrides ``main(self, root)``;
application classes are imported by the recipe and passed as objects::

    from myshop.app import Application as Shop

    def main(self, root):
        root.server(host="127.0.0.1", port=8000)
        root.middleware(cors=True)
        apps = root.applications(default="shop")
        apps.application(code="shop", app_class=Shop)
"""

from __future__ import annotations

from genro_builders.builder import element

from ..tasks.mixin import TaskConfigElements


[docs] class AsgiConfigElements(TaskConfigElements): """Element mixin for the ``asgiconfig`` dialect. Grammar only. The sections are top-level elements built directly on the recipe root; a section's kwargs are stored as the node's attributes and read back at materialization time. Capability-owned companions are composed explicitly (``TaskConfigElements`` — the ``tasks`` child of ``server``, owned by ``TaskMixin``). """ @element(sub_tags="session,tasks") def server(self) -> None: """Server runtime options: ``host``, ``port``, ``external_url``, ``max_threads``, ``storage_key``. ``host``/``port`` become the defaults of ``AsgiServer.serve``. ``external_url`` (str, optional) is the server's PUBLIC base address — what the server calls itself when it hands its own URL to a third party (``https://shop.example.com``; a trailing slash is stripped). It is not the listener: behind a proxy the bind address and the public address differ, and only the latter is meaningful to an outside caller. Required when an ``oidc()`` provider is configured — the provider is given an absolute ``redirect_uri`` — and a boot error when missing there. ``max_threads`` sizes the server's thread pool: ``BaseServer`` peels it and hands it to ``WorkPool`` (omitted, the stdlib default ``min(32, cpu + 4)`` applies). ``storage_key`` (str, optional) installs at-rest encryption on the server's storage (comma-separated Fernet keys — first encrypts, all decrypt for rotation). Pass the secret as a ``^pointer`` to an ``EnvResolver`` so it stays out of the recipe, never a literal:: def setup(self, data): data["storage_key"] = EnvResolver("GENRO_STORAGE_KEY") def main(self, root): root.server(host="127.0.0.1", port=8000, storage_key="^storage_key") Configured but resolved empty is an explicit boot error (no silent degradation); omit it to run the encrypted mounts dormant. Children (server-domain, each materialized to a server kwarg): ``session`` (the session TTL) and ``tasks`` (the task backbone — declared by ``TaskConfigElements``, the ``config_grammar`` companion ``TaskMixin`` owns). The ``_server`` app's identity surface is NOT configured here — it has its own config-class (``ServerAppConfig``, see ``applications/server_app.py``) passed to the handler alongside the site recipe. """ @element(sub_tags="", parent_tags="server") def session(self, ttl: int) -> None: """Session options: ``ttl`` (seconds, REQUIRED — the grammar rejects a session without it) → the server's ``session_ttl`` kwarg (the default store's TTL). Server-domain, so it lives under ``server``, not under an application.""" @element(sub_tags="") def middleware(self) -> None: """Global middleware switches: one ``{name: bool | dict}`` kwarg per middleware. A dict value enables the middleware and becomes its constructor options.""" @element(sub_tags="") def auth(self) -> None: """Credential config: ``basic``/``bearer``/``jwt`` kwargs handed verbatim to ``AuthCore`` through the server's ``auth=`` kwarg.""" @element(sub_tags="mount", collection_key="code") def storage(self) -> None: """Collection of storage mounts, each keyed by ``code``. Materialized in core 1b as the server's ``LocalStorage`` (one ``add_mount`` per child). Visible to EVERY role (survival infrastructure — the spool lives on it), unlike ``middleware``/``auth`` which stay root-only.""" @element(sub_tags="", parent_tags="storage") def mount(self, path: str) -> None: """One storage mount: ``code`` (the collection key), ``path`` (the filesystem path, relative to the server base dir unless absolute, REQUIRED — the grammar rejects a mount without it) and optional ``encrypted`` (bool, default False — encrypt at rest, which requires the server's ``storage_key``).""" @element(sub_tags="application", collection_key="code") def applications(self) -> None: """Collection of applications, each keyed by its ``code``. The optional ``default`` attribute names the app served as the primary (mount ``/``).""" @element(sub_tags="*", parent_tags="applications") def application(self, app_class: type) -> None: """One application: ``code`` (the collection key), ``app_class`` (the imported class, REQUIRED — the grammar rejects an application without it), optional ``mount`` plus the app's constructor kwargs. ``mount`` defaults to ``code`` for a secondary; the ``default`` app is the primary (mount ``/``). Children are unconstrained (``sub_tags="*"``): the core reads only the app's own attributes and delegates the rest.""" @element(sub_tags="*", parent_tags="application") def configuration(self) -> None: """The application's own configuration (RESERVED tag): opaque to the core, which reads and skips it — the site dialect never validates an app's internal grammar (distributed configuration). Today it carries free attributes; the machinery that fills and mounts its subtree with the app's own dialect is a future macro.""" @element(sub_tags="group", collection_key="code", parent_tags="application") def groups(self) -> None: """Collection of worker groups for a multi-worker application, keyed by ``code``. Grammar only in core 1a — materialized by the orchestration package; read and skipped here.""" @element(sub_tags="", parent_tags="groups") def group(self) -> None: """One worker group: ``code`` (the collection key), ``workers`` (the pool size) and optional ``python`` (the interpreter). Grammar only in core 1a.""" @element(sub_tags="database", collection_key="code") def databases(self) -> None: """Collection of database descriptors, each keyed by ``code``. Grammar only in core 1a — the server-side handlers arrive in core 1b; read and skipped here.""" @element(sub_tags="", parent_tags="databases") def database(self, db_class: type) -> None: """One database: ``code`` (the registry key), ``db_class`` (REQUIRED — the grammar rejects a database without it) and its connection kwargs. Grammar only in core 1a.""" @element(sub_tags="plugin", collection_key="code") def plugins(self) -> None: """Collection of router plugins, each keyed by ``code``. Materialized as the server's ``plugins=`` switches (``PluginMixin``): the server arms every enabled plugin onto each routed app it hosts. Visible to EVERY role — a worker hosting an app needs the same router behavior.""" @element(sub_tags="", parent_tags="plugins") def plugin(self) -> None: """One router plugin: ``code`` (the collection key), optional ``enabled`` (bool, default True — set False to leave it unarmed) and arbitrary options handed to ``router.plug(code, **options)``.""" @element(sub_tags="") def openapi(self) -> None: """OpenAPI metadata: ``title``, ``version``, ``description``. Grammar only in core 1a — the OpenAPI application arrives in core 1c; read and skipped here."""
if __name__ == "__main__": from typing import Any from genro_builders.builder import BuilderBase, BuilderHandler class _Demo(BuilderBase, AsgiConfigElements): def main(self, root: Any) -> None: root.server(host="127.0.0.1", port=8000) apps = root.applications(default="shop") apps.application(code="shop", app_class=object) demo = _Demo(name="config") BuilderHandler().add_builder(demo) tags = [node.node_tag for node in demo.source] assert tags == ["server", "applications"], tags