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