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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:14:56 +03:30
parent 5c0a5938f8
commit 9a4b173b95

View File

@@ -1,9 +1,31 @@
from typing import Any
from pydantic import Field, model_validator from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict 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): 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" host: str = "127.0.0.1"
port: int = 5433 port: int = 5433
@@ -17,7 +39,9 @@ class PostgresSettings(BaseSettings):
class MinioSettings(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" endpoint: str = "127.0.0.1:9100"
access_key: str = "chatbot" access_key: str = "chatbot"
@@ -33,7 +57,9 @@ class IngestionSettings(BaseSettings):
timeouts, or callers give up on work that is still succeeding. 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 max_concurrency: int = 4
thread_pool_size: int = 8 thread_pool_size: int = 8
@@ -57,7 +83,9 @@ class ChunkingSettings(BaseSettings):
`search_document: ` task prefix and any heading text carried into a chunk. `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" strategy: str = "fixed_size"
chunk_size: int = 400 chunk_size: int = 400
@@ -81,7 +109,9 @@ class ChunkingSettings(BaseSettings):
class QdrantSettings(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" url: str = "http://127.0.0.1:6343"
api_key: str | None = None api_key: str | None = None
@@ -102,7 +132,9 @@ class NomicEmbeddingSettings(BaseSettings):
so it stays off until an emet run measures the pair together. 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" base_url: str = "http://192.168.10.10:11435/v1"
model: str = "nomic-embed-text-v2-moe" model: str = "nomic-embed-text-v2-moe"
@@ -121,7 +153,9 @@ class OpenaiEmbeddingSettings(BaseSettings):
tweak. 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" base_url: str = "https://api.openai.com/v1"
model: str = "text-embedding-3-large" model: str = "text-embedding-3-large"
@@ -144,7 +178,9 @@ class SparseEmbeddingSettings(BaseSettings):
statistics without a code change. 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" analyzer: str = "fa_norm_stop"
k: float = 1.2 k: float = 1.2
@@ -159,16 +195,27 @@ class EmbeddingSettings(BaseSettings):
openai: OpenaiEmbeddingSettings = Field(default_factory=OpenaiEmbeddingSettings) openai: OpenaiEmbeddingSettings = Field(default_factory=OpenaiEmbeddingSettings)
sparse: SparseEmbeddingSettings = Field(default_factory=SparseEmbeddingSettings) 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): 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" env: str = "local"
readiness_check_timeout_seconds: float = 2.0 readiness_check_timeout_seconds: float = 2.0
class LoggingSettings(BaseSettings): 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" level: str = "INFO"
json_format: bool = False json_format: bool = False
@@ -185,3 +232,15 @@ class Settings(BaseSettings):
embedding: EmbeddingSettings = Field(default_factory=EmbeddingSettings) embedding: EmbeddingSettings = Field(default_factory=EmbeddingSettings)
app: AppLimitSettings = Field(default_factory=AppLimitSettings) app: AppLimitSettings = Field(default_factory=AppLimitSettings)
logging: LoggingSettings = Field(default_factory=LoggingSettings) 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)