Source code for structum_lab.plugins.dynaconf.core.builders

# src/structum_lab.plugins.dynaconf/builders.py
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: 2025 PythonWoods

"""Builder di Configurazione (Parte del Builder Pattern).

Questo modulo fornisce la classe base astratta `AbstractConfigBuilder` che
definisce il contratto per tutti i builder di configurazione.

L'applicazione che utilizza structum_lab.plugins.dynaconf deve creare le proprie
implementazioni concrete di questa classe base.
"""

import logging
import os
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, TypeVar

from dotenv import load_dotenv  # type: ignore[import-not-found]
from pydantic import BaseModel  # type: ignore[import-not-found]

from dynaconf import Dynaconf  # type: ignore

if TYPE_CHECKING:
    from structum_lab.plugins.dynaconf.features.migrations import Migration

log = logging.getLogger(__name__)

T = TypeVar("T", bound=BaseModel)


[docs] class AbstractConfigBuilder(Generic[T], ABC): """Contratto astratto per tutti i builder di configurazione. Per creare un builder concreto, estendi questa classe e implementa: - config_name: nome univoco della configurazione - config_model_class: la classe Pydantic per validare la configurazione - _get_specific_files: lista dei file TOML da caricare Esempio: class MyConfigBuilder(AbstractConfigBuilder[MyConfig]): @property def config_name(self) -> str: return "myapp" @property def config_model_class(self) -> Type[MyConfig]: return MyConfig def _get_specific_files(self) -> List[str]: return ["config/app/myapp.toml"] """ @property @abstractmethod def config_name(self) -> str: """Il nome univoco della configurazione (es. 'backend').""" pass @property @abstractmethod def config_model_class(self) -> type[T]: """La classe Pydantic che rappresenta la configurazione.""" pass @property def envvar_prefix(self) -> str: """Prefisso per le variabili d'ambiente. Returns: str: Il prefisso (default: STRUCTUM). """ return "STRUCTUM" @abstractmethod def _get_specific_files(self) -> list[str]: """File TOML specifici relativi alla cartella config/.""" pass def _resolve_config_root(self) -> Path: """Risolve la directory root del PROGETTO (dove stanno i TOML statici).""" # 1. Override Env Var if env_root := os.getenv("STRUCTUM_CONFIG_ROOT"): path = Path(env_root) if (path / "config").exists(): return path # 2. Docker Standard Paths for container_path in [Path("/code"), Path("/app")]: if container_path.exists() and (container_path / "config").exists(): return container_path # 3. Risalita ricorsiva dalla directory corrente (fino a trovare config/) current = Path.cwd() for parent in [current] + list(current.parents): if (parent / "config").exists(): return parent # 4. Fallback relativo al file sorgente (utile in sviluppo locale del plugin) dev_root = Path(__file__).resolve().parents[2] if (dev_root / "config").exists(): return dev_root raise FileNotFoundError(f"Could not find 'config/' directory. Current CWD: {Path.cwd()}")
[docs] def get_user_config_dir(self) -> Path: """Risolve la directory utente per la persistenza (~/.structum).""" if env_dir := os.getenv("STRUCTUM_USER_CONFIG_DIR"): return Path(env_dir) return Path.home() / ".structum"
[docs] def get_persistence_file(self) -> Path: """Restituisce il path completo del file JSON di persistenza.""" return self.get_user_config_dir() / f"{self.config_name}_saved.json"
[docs] def get_migrations(self) -> list["Migration"]: """Override this method to register migrations for this config. Returns: List of Migration instances to apply. """ return []
[docs] def load_settings(self) -> Dynaconf: """Carica le impostazioni unendo TOML statici e JSON utente.""" project_root = self._resolve_config_root() # Caricamento esplicito .env da root env_file = project_root / ".env" if env_file.exists(): load_dotenv(env_file) log.debug(f"Loaded environment from {env_file}") # 0. Run Migrations (before loading) saved_file = self.get_persistence_file() if saved_file.exists(): migrations = self.get_migrations() if migrations: from structum_lab.plugins.dynaconf.features.migrations import ( MigrationRunner, ) runner = MigrationRunner(saved_file) for migration in migrations: runner.register(migration) runner.migrate_to_latest() # 1. File Base (Read-Only) toml_files = [str(project_root / "config/app/settings.toml")] for f in self._get_specific_files(): toml_files.append(str(project_root / f)) # 2. File Utente (Read-Write Override) json_includes = [str(saved_file)] if saved_file.exists() else [] if json_includes: log.info(f"[{self.config_name}] User override loaded: {saved_file}") return Dynaconf( envvar_prefix=f"{self.envvar_prefix}_{self.config_name.upper()}", preload=toml_files, includes=json_includes, merge_enabled=True, load_dotenv=False, environments=True, lowercase_read=True, )
[docs] def create_config_model(self, settings: Dynaconf) -> T: """Crea e valida il modello Pydantic dai settings Dynaconf.""" config_dict = settings.as_dict() normalized_dict = self._normalize_keys(config_dict) return self.config_model_class.model_validate(normalized_dict) # type: ignore[no-any-return]
def _normalize_keys(self, obj: Any) -> Any: """Converte ricorsivamente tutte le chiavi di dizionario in lowercase.""" if isinstance(obj, dict): return {k.lower(): self._normalize_keys(v) for k, v in obj.items()} elif isinstance(obj, list): return [self._normalize_keys(item) for item in obj] return obj
[docs] class GenericConfigBuilder(AbstractConfigBuilder): """ Un builder generico pronto all'uso. Elimina la necessità di creare sottoclassi per ogni file di configurazione. Uso: builder = GenericConfigBuilder("auth", ["config/app/auth.toml"]) provider.register_builder("auth", builder) Opzionale: Puoi passare un modello Pydantic specifico per enforcement dei tipi. Se non fornito, viene creato un modello dinamico permissivo (extra="allow"). """
[docs] def __init__( self, name: str, files: list[str], model: type[BaseModel] | None = None, env_prefix: str = "STRUCTUM", ): """Initialize a generic configuration builder. Args: name: Unique name for this configuration (e.g., 'auth'). files: List of TOML file paths relative to config/ directory. model: Optional Pydantic model for validation. If None, a dynamic permissive model is created. env_prefix: Prefix for environment variable overrides. """ self._name = name self._files = files self._env_prefix = env_prefix if model: self._model = model else: # Create a dynamic permissive model from pydantic import ConfigDict, create_model # type: ignore[import-not-found] self._model = create_model( f"DynamicConfig_{name}", __config__=ConfigDict(extra="allow") )
@property def config_name(self) -> str: """Return the unique configuration name.""" return self._name @property def config_model_class(self) -> type[BaseModel]: """Return the Pydantic model class for this configuration.""" return self._model @property def envvar_prefix(self) -> str: """Return the environment variable prefix.""" return self._env_prefix def _get_specific_files(self) -> list[str]: """Return the list of TOML files for this configuration.""" return self._files