Source code for genro_asgi.config.configurable

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

"""Configurable classes — per-class configuration grammars (extraction-ready).

The configuration grammar follows the class hierarchy: every runtime class
can declare its own grammar as a *companion* mixin of ``@element`` markers,
linked through the ``config_grammar`` class attribute::

    class VehicleElements:
        @element(sub_tags="")
        def wheels(self) -> None:
            '''Wheel options.'''

    class Vehicle:
        config_grammar = VehicleElements

Companions mirror the runtime hierarchy with plain Python inheritance
(``CarElements(VehicleElements)`` as ``Car(Vehicle)``), so grammars compose
along the chain exactly like D16 cooperative constructors — an element
declared by class X materializes into the kwarg class X peels. The site
recipe composes companions EXPLICITLY
(``class Site(BuilderBase, Car.config_grammar)``): no auto-discovery, no
global registry. genro-builders collects the markers from mixin bases
without removing them (``_pop_decorated_methods``), so a companion stays
introspectable after any number of recipes consumed it.

The module offers three operations over that convention:

- :func:`declared_elements` — which tags a companion chain declares
  (MRO scan of the ``_decorator`` markers).
- :func:`element_kwargs` — the materializer: a node's attributes become
  constructor kwargs, each child element becomes a dict-valued kwarg keyed
  by its tag (``users(mount=...)`` -> ``users={"mount": ...}``). STRICT:
  a child not declared by the owner's grammar chain, a duplicate child, or
  a child carrying children of its own raise :class:`ConfigError` — never
  a silent skip (D16 symmetry).
- :func:`resolve_pointers` — resolve a node's ``^`` pointers through the
  builder's ``runtime_values``; a configured pointer that resolves empty
  (None or "") raises :class:`ConfigError` (a secret the recipe promised
  MUST exist — no silent degradation).

EXTRACTION BOUNDARY: this module imports nothing from genro_asgi and
nothing ASGI — it is duck-typed over genro-builders' public seams (the
``_decorator`` marker attribute, the source-node API, ``runtime_values``)
and moves as-is to genro-builders or a standalone package, tests included.
"""

from __future__ import annotations

from typing import Any

__all__ = ["ConfigError", "declared_elements", "element_kwargs", "resolve_pointers"]


[docs] class ConfigError(Exception): """A configuration recipe names something its grammar chain cannot honor."""
[docs] def declared_elements(grammar: type) -> set[str]: """Return the tags declared by ``grammar``'s companion chain. Walks the companion's MRO collecting every attribute that carries a ``_decorator`` marker; ``@abstract`` declarations (inheritance-only) and ``@container`` methods (source generators, not tags) are skipped — the same filter genro-builders applies when it builds a class schema. """ tags: set[str] = set() for klass in grammar.__mro__: for name, obj in vars(klass).items(): decorator = getattr(obj, "_decorator", None) if decorator is None: continue if decorator.get("abstract") or decorator.get("container"): continue tags.add(name) return tags
[docs] def element_kwargs( node: Any, owner_class: type, exclude: tuple[str, ...] = () ) -> dict[str, Any]: """Materialize ``node`` into constructor kwargs for ``owner_class``. The node's attributes become kwargs (minus ``exclude``, the names the caller consumes itself); each child element becomes a dict-valued kwarg keyed by its tag. Strict-unknown: a child whose tag is not declared by ``owner_class.config_grammar``'s chain (or any child when the owner has no ``config_grammar``) raises :class:`ConfigError`, as do a duplicate child tag and a child carrying children of its own (the materializer maps ONE level: child -> dict kwarg). """ kwargs: dict[str, Any] = { name: value for name, value in node.fixed_attr_items() if name not in exclude } children = node.value if children is None or not hasattr(children, "nodes"): return kwargs grammar = getattr(owner_class, "config_grammar", None) declared = declared_elements(grammar) if grammar is not None else set() for child in children: tag = child.node_tag if tag not in declared: raise ConfigError( f"config element {node.node_tag!r}: child {tag!r} is not " f"declared by the config grammar of {owner_class.__name__}" ) if tag in kwargs: raise ConfigError( f"config element {node.node_tag!r}: child {tag!r} already " "materialized (duplicate child or attribute collision)" ) grandchildren = child.value if grandchildren is not None and hasattr(grandchildren, "nodes"): raise ConfigError( f"config element {node.node_tag!r}: child {tag!r} carries " "nested children; the materializer maps one level " "(child -> dict kwarg)" ) kwargs[tag] = dict(child.fixed_attr_items()) return kwargs
[docs] def resolve_pointers(builder: Any, node: Any) -> tuple[Any, dict[str, Any]]: """Resolve ``node``'s ``^`` pointers via ``builder.runtime_values``, strictly. Returns the ``(runtime_value, runtime_attrs)`` pair unchanged, after enforcing the boot rule: a configured ``^`` pointer (attribute or node value) that resolves empty — None or "" — raises :class:`ConfigError` naming the subject and the pointer. Non-pointer attributes pass through untouched. """ value, attrs = builder.runtime_values(node) for attrname, raw in node.runtime_to_evaluate().items(): if node.pointer_type(raw) != "^": continue resolved = value if attrname is None else attrs.get(attrname) if resolved is None or resolved == "": subject = "value" if attrname is None else f"attribute {attrname!r}" raise ConfigError( f"config element {node.node_tag!r}: {subject} pointer " f"{raw!r} resolved empty" ) return value, attrs
if __name__ == "__main__": from genro_builders.builder import BuilderBase, BuilderHandler, element class VehicleElements: @element(sub_tags="turbo") def vehicle(self) -> None: """One vehicle: attributes plus a ``turbo`` child.""" @element(sub_tags="", parent_tags="vehicle") def turbo(self) -> None: """Turbo options.""" class Vehicle: config_grammar = VehicleElements class _Demo(BuilderBase, VehicleElements): def main(self, root: Any) -> None: vehicle = root.vehicle(wheels=4) vehicle.turbo(psi=14) demo = _Demo(name="config") BuilderHandler().add_builder(demo) vehicle_node = next(iter(demo.source)) assert declared_elements(VehicleElements) == {"vehicle", "turbo"} assert element_kwargs(vehicle_node, Vehicle) == { "wheels": 4, "turbo": {"psi": 14}, }