Skip to content

src/genro_asgi/config/default_config.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 """DefaultConfig — the defaults layer a recipe declares for itself.16 17 Three layers reach the server's read door, lowest first:18 19 1. ``BaseConfiguration`` — the package's shipped defaults;20 2. the recipe's ``default_config`` source — the deployment's own layer;21 3. the site's own recipe — always last, always winning.22 23 The middle layer is what a sysadmin owns: a mount that only exists on this24 host, a key material source, a listener — set once, inherited by every site25 deployed there, and overridable by any of them.26 27 WHICH source that is, the RECIPE declares (``default_config`` on28 ``AsgiConfigBuilder``); this class only resolves the declaration:29 30 - unset / ``None`` / ``True`` → the conventional ``<base_dir>/config.py``,31   layered only when the file exists;32 - ``False`` → no defaults layer at all: the site sits straight on the package33   defaults;34 - a path → THAT file, and a missing one is a ``ConfigError`` — an explicit35   choice the runtime cannot honour is a configuration mistake, never a silent36   skip.37 38 ``base_dir`` resolves with precedence: the explicit argument → the env var39 ``GENRO_ASGI_HOME`` → ``~/.genroasgi``. The same variable is honoured by the40 CLI's ``AppsRegistry``, so ONE variable relocates everything genro-asgi keeps41 outside a deployment — containers and test runs included.42 """43 44 from __future__ import annotations45 46 import importlib.util47 import os48 from pathlib import Path49 50 from genro_builders.builder import BuilderBase51 from genro_builders.contrib.config import ConfigBuilder52 53 from .builder import BaseConfiguration54 from .handler import ConfigError55 56 __all__ = ["HOME_ENV", "DefaultConfig"]57 58 HOME_ENV = "GENRO_ASGI_HOME"59 """Env var relocating everything genro-asgi keeps outside a deployment."""60 61 RecipeSource = str | Path | type | BuilderBase62 63 64 class DefaultConfig:65     """The defaults layer a recipe declares, and the parent chain it forms."""66 67     def __init__(self, base_dir: str | Path | None = None) -> None:68         self.base_dir = Path(base_dir or os.environ.get(HOME_ENV) or Path.home() / ".genroasgi")69 70     @property71     def path(self) -> Path:72         """The conventional defaults recipe of this ``base_dir`` — it need not exist."""73         return self.base_dir / "config.py"74 75     def parents_for(self, source: RecipeSource) -> list[type | Path]:76         """The parent recipes of *source*'s handler, lowest layer first.77 78         Always the package defaults, then the layer *source* declares — the three79         ``default_config`` forms are in the module docstring.80         """81         parents: list[type | Path] = [BaseConfiguration]82         declared = self.declared_by(source)83         if declared is False:84             return parents85         if declared is None or declared is True:86             if self.path.is_file():87                 parents.append(self.path)88             return parents89         path = Path(declared).expanduser()90         if not path.is_file():91             raise ConfigError(f"default_config names a file that does not exist: {path}")92         parents.append(path)93         return parents94 95     def declared_by(self, source: RecipeSource) -> bool | str | Path | None:96         """The ``default_config`` value *source* declares.97 98         A recipe class or instance answers directly; a ``config.py`` path is99         imported to reach its class first.100         """101         if isinstance(source, (str, Path)):102             source = self.recipe_class(source)103         return getattr(source, "default_config", None)104 105     def recipe_class(self, path: str | Path) -> type:106         """Import a ``config.py`` and return the single recipe class it defines.107 108         Mirrors the loader contract of109         ``genro_builders.contrib.config.handler.ConfigHandler._load_recipe_class``:110         the module is executed from its file location, never registered in111         ``sys.modules``, and must define exactly ONE ``ConfigBuilder`` subclass112         (an imported shared base recipe does not count).113         """114         path = Path(path).resolve()115         spec = importlib.util.spec_from_file_location(path.stem, path)116         if spec is None or spec.loader is None:117             raise ConfigError(f"cannot import config module from {path}")118         module = importlib.util.module_from_spec(spec)119         spec.loader.exec_module(module)120         found = {121             obj122             for obj in vars(module).values()123             if isinstance(obj, type)124             and issubclass(obj, ConfigBuilder)125             and obj is not ConfigBuilder126             and obj.__module__ == module.__name__127         }128         if len(found) != 1:129             names = sorted(cls.__name__ for cls in found) or "none"130             raise ConfigError(131                 f"{path} must define exactly one ConfigBuilder subclass, found: {names}"132             )133         return found.pop()