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