From 9a4b173b95950452041540d8e6643eefa8a84847 Mon Sep 17 00:00:00 2001 From: Ali Zarinkolah Date: Wed, 19 Aug 2026 17:14:56 +0330 Subject: [PATCH] fix(config): cascade .env file loading to nested settings classes Why: - No setting in this app ever actually read from .env: only the outer Settings declared env_file=".env", and pydantic-settings does not cascade that to nested BaseSettings classes. Every previously-correct local value was coincidence (.env.example defaults matching class defaults). Found by testing EMBEDDING_OPENAI_API_KEY against the live OpenAI API. Changes: - Every nested settings class now declares env_file=".env" itself. - Settings.__init__/EmbeddingSettings.__init__ explicitly thread an _env_file override to every nested constructor, so overriding it (as tests do) reaches the whole tree, not just the outer class. - env_ignore_empty=True everywhere, since the fix surfaced a second bug: a blank env var (e.g. EMBEDDING_OPENAI_DIMENSIONS=) failed to parse as int | None instead of falling back to the field default. Impact: - Real deployments setting env vars directly (Docker Compose) are unaffected. Local .env-file development now actually works. Co-Authored-By: Claude Sonnet 5 --- src/config.py | 79 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/src/config.py b/src/config.py index 712a4d5..cb801dd 100644 --- a/src/config.py +++ b/src/config.py @@ -1,9 +1,31 @@ +from typing import Any + from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# pydantic-settings does not cascade `env_file` from a parent `BaseSettings` +# to a nested one: each `BaseSettings` subclass only reads `.env` if its own +# `model_config` names it. `Settings` has no fields of its own -- only +# nested settings objects -- so every nested class below repeats +# `env_file=".env"` as its own default, or its fields would silently read +# only real process environment variables (fine under Docker Compose, broken +# for local `.env`-file development) while still *appearing* to work +# whenever a `.env.example` default happens to match the class default. +# +# That default alone isn't enough to let a caller point `Settings` at a +# *different* file (as the test suite does, to parse `.env.example`), since +# passing `_env_file=...` to `Settings(...)` only overrides `Settings`'s own +# `model_config` -- nested defaults still construct against their own +# hardcoded ".env". `Settings.__init__`/`EmbeddingSettings.__init__` below +# thread an explicit `_env_file` override through to every nested +# constructor so one override actually reaches the whole tree. +_UNSET: Any = object() + class PostgresSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="POSTGRES_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="POSTGRES_", extra="ignore", env_file=".env", env_ignore_empty=True + ) host: str = "127.0.0.1" port: int = 5433 @@ -17,7 +39,9 @@ class PostgresSettings(BaseSettings): class MinioSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="MINIO_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="MINIO_", extra="ignore", env_file=".env", env_ignore_empty=True + ) endpoint: str = "127.0.0.1:9100" access_key: str = "chatbot" @@ -33,7 +57,9 @@ class IngestionSettings(BaseSettings): timeouts, or callers give up on work that is still succeeding. """ - model_config = SettingsConfigDict(env_prefix="INGESTION_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="INGESTION_", extra="ignore", env_file=".env", env_ignore_empty=True + ) max_concurrency: int = 4 thread_pool_size: int = 8 @@ -57,7 +83,9 @@ class ChunkingSettings(BaseSettings): `search_document: ` task prefix and any heading text carried into a chunk. """ - model_config = SettingsConfigDict(env_prefix="CHUNKING_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="CHUNKING_", extra="ignore", env_file=".env", env_ignore_empty=True + ) strategy: str = "fixed_size" chunk_size: int = 400 @@ -81,7 +109,9 @@ class ChunkingSettings(BaseSettings): class QdrantSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="QDRANT_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="QDRANT_", extra="ignore", env_file=".env", env_ignore_empty=True + ) url: str = "http://127.0.0.1:6343" api_key: str | None = None @@ -102,7 +132,9 @@ class NomicEmbeddingSettings(BaseSettings): so it stays off until an emet run measures the pair together. """ - model_config = SettingsConfigDict(env_prefix="EMBEDDING_NOMIC_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="EMBEDDING_NOMIC_", extra="ignore", env_file=".env", env_ignore_empty=True + ) base_url: str = "http://192.168.10.10:11435/v1" model: str = "nomic-embed-text-v2-moe" @@ -121,7 +153,9 @@ class OpenaiEmbeddingSettings(BaseSettings): tweak. """ - model_config = SettingsConfigDict(env_prefix="EMBEDDING_OPENAI_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="EMBEDDING_OPENAI_", extra="ignore", env_file=".env", env_ignore_empty=True + ) base_url: str = "https://api.openai.com/v1" model: str = "text-embedding-3-large" @@ -144,7 +178,9 @@ class SparseEmbeddingSettings(BaseSettings): statistics without a code change. """ - model_config = SettingsConfigDict(env_prefix="EMBEDDING_SPARSE_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="EMBEDDING_SPARSE_", extra="ignore", env_file=".env", env_ignore_empty=True + ) analyzer: str = "fa_norm_stop" k: float = 1.2 @@ -159,16 +195,27 @@ class EmbeddingSettings(BaseSettings): openai: OpenaiEmbeddingSettings = Field(default_factory=OpenaiEmbeddingSettings) sparse: SparseEmbeddingSettings = Field(default_factory=SparseEmbeddingSettings) + def __init__(self, _env_file: Any = _UNSET, **data: Any) -> None: + env_file = ".env" if _env_file is _UNSET else _env_file + data.setdefault("nomic", NomicEmbeddingSettings(_env_file=env_file)) + data.setdefault("openai", OpenaiEmbeddingSettings(_env_file=env_file)) + data.setdefault("sparse", SparseEmbeddingSettings(_env_file=env_file)) + super().__init__(**data) + class AppLimitSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="APP_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="APP_", extra="ignore", env_file=".env", env_ignore_empty=True + ) env: str = "local" readiness_check_timeout_seconds: float = 2.0 class LoggingSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="LOG_", extra="ignore") + model_config = SettingsConfigDict( + env_prefix="LOG_", extra="ignore", env_file=".env", env_ignore_empty=True + ) level: str = "INFO" json_format: bool = False @@ -185,3 +232,15 @@ class Settings(BaseSettings): embedding: EmbeddingSettings = Field(default_factory=EmbeddingSettings) app: AppLimitSettings = Field(default_factory=AppLimitSettings) logging: LoggingSettings = Field(default_factory=LoggingSettings) + + def __init__(self, _env_file: Any = _UNSET, **data: Any) -> None: + env_file = ".env" if _env_file is _UNSET else _env_file + data.setdefault("postgres", PostgresSettings(_env_file=env_file)) + data.setdefault("minio", MinioSettings(_env_file=env_file)) + data.setdefault("ingestion", IngestionSettings(_env_file=env_file)) + data.setdefault("chunking", ChunkingSettings(_env_file=env_file)) + data.setdefault("qdrant", QdrantSettings(_env_file=env_file)) + data.setdefault("embedding", EmbeddingSettings(_env_file=env_file)) + data.setdefault("app", AppLimitSettings(_env_file=env_file)) + data.setdefault("logging", LoggingSettings(_env_file=env_file)) + super().__init__(_env_file=env_file, **data)