obsidian/wiki/shared-patterns/pydantic-settings-env.md
2026-05-15 10:37:25 +01:00

6.5 KiB

title aliases tags sources created updated
pydantic-settings & .env pattern
BaseSettings
pydantic env config
python
pydantic
config
shared-pattern
cc-dashboard/src/config.py
Barclays-banner-builder/backend/app/config.py
DevOps_Click_UP_sync/src/config.py
2026-05-15 2026-05-15

Key Takeaways

  • Always use SettingsConfigDict(env_file=".env", extra="ignore")extra="ignore" prevents crash when .env has keys not declared in Settings.
  • Two naming styles in use: SCREAMING_SNAKE (cc-dashboard) and lowercase + Field(alias=...) (DevOps_Click_UP_sync). Pick one per project and stick to it. SCREAMING_SNAKE is simpler for small configs; alias is required if field name would clash with a Python keyword or SQLAlchemy reserved attr.
  • Computed/derived fields go in model_post_init, not @validator — avoids Pydantic v2 deprecation warnings.
  • Expose as module-level singleton settings = Settings(). For FastAPI DI use @lru_cache + get_settings() (Barclays pattern).
  • Comma-separated list fields (e.g. ADMIN_EMAILS, CORS_ORIGINS) stay as str in Settings; parse to list via a @property.

Standard Settings Class

Full pattern from cc-dashboard (src/config.py):

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    # DB
    DB_PASSWORD: str = "postgres"
    DATABASE_URL: str = ""  # derived in model_post_init if empty

    # JWT
    SECRET_KEY: str = "dev_secret_key_change_in_production"
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
    REFRESH_TOKEN_EXPIRE_DAYS: int = 7

    # App
    BASE_PATH: str = "/cc-dashboard"
    DEBUG: bool = False

    # Azure AD SSO
    AZURE_TENANT_ID: str = ""
    AZURE_CLIENT_ID: str = ""
    ALLOWED_EMAIL_DOMAIN: str = "oliver.agency"
    ADMIN_EMAILS: str = ""  # comma-separated lowercase

    # Mailgun
    MAILGUN_API_KEY: str = ""
    MAILGUN_DOMAIN: str = ""
    MAILGUN_FROM: str = "CC Dashboard <noreply@example.com>"

    # AI
    ANTHROPIC_API_KEY: str = ""

    def model_post_init(self, __context):
        if not self.DATABASE_URL:
            object.__setattr__(
                self,
                "DATABASE_URL",
                f"postgresql+asyncpg://cc_app:{self.DB_PASSWORD}@db:5432/cc_dashboard",
            )


settings = Settings()

FastAPI DI variant from Barclays-banner-builder (backend/app/config.py):

from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    database_url: str = "postgresql+asyncpg://banners:banners_dev@localhost:5432/barclays_banners"
    redis_url: str = "redis://localhost:6379/0"
    secret_key: str = "dev-secret-key-change-in-prod"
    cors_origins: str = "http://localhost:5173,http://localhost:8080"

    @property
    def cors_origin_list(self) -> list[str]:
        return [o.strip() for o in self.cors_origins.split(",") if o.strip()]

    @property
    def is_dev(self) -> bool:
        return self.app_env == "development"


@lru_cache
def get_settings() -> Settings:
    return Settings()

Field Patterns

Optional with None default — use when feature is fully disabled:

from typing import Optional
SENTRY_DSN: Optional[str] = None

Required field (no default) — app crashes at startup if missing from env, which is the desired behaviour for secrets:

SECRET_KEY: str  # no default → required

Field with alias — use when field name conflicts with builtins or SQLAlchemy attrs (DevOps_Click_UP_sync pattern):

from pydantic import Field
ado_pat: str = Field(default="", alias="ADO_PAT")
model_config = {"env_file": ".env", "populate_by_name": True}
# populate_by_name=True allows both ado_pat and ADO_PAT in code

Derived URL from parts (cc-dashboard pattern):

def model_post_init(self, __context):
    if not self.DATABASE_URL:
        object.__setattr__(self, "DATABASE_URL",
            f"postgresql+asyncpg://app:{self.DB_PASSWORD}@db:5432/appdb")

Comma-list as property (Barclays pattern):

ADMIN_EMAILS: str = ""

@property
def admin_email_list(self) -> list[str]:
    return [e.strip().lower() for e in self.ADMIN_EMAILS.split(",") if e.strip()]

.env.example Pattern

Always commit .env.example, never .env. Template used across Oliver projects:

# Database
DB_PASSWORD=postgres
DATABASE_URL=                        # leave empty → auto-built from DB_PASSWORD

# Security
SECRET_KEY=dev_secret_key_change_in_production
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7

# Azure AD SSO (required in production)
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
ALLOWED_EMAIL_DOMAIN=oliver.agency
ADMIN_EMAILS=                        # comma-separated: admin@oliver.agency,other@oliver.agency

# Mailgun (empty → email disabled)
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
MAILGUN_FROM=App <noreply@example.com>

# AI
ANTHROPIC_API_KEY=

# Dev only
DEV_AUTH_BYPASS=false
DEBUG=false
LOG_FORMAT=console                   # console | json

Gotchas

1. model is a reserved field name in Pydantic v2. If you name a settings field model (e.g. openai_model) it silently overwrites the internal model attribute. Use a prefixed name:

# BAD
model: str = "gpt-4"
# GOOD
openai_model: str = "gpt-4"

2. Env var case sensitivity on Linux. SettingsConfigDict reads env vars case-insensitively by default in pydantic-settings, but Docker environment: keys are case-sensitive at the OS level. A field declared DATABASE_URL will match env var database_url on Linux — but only inside the pydantic layer. External tools (healthchecks, entrypoint scripts) that read $DATABASE_URL require exact case. Standardise on SCREAMING_SNAKE in both code and compose files.

3. lru_cache + test isolation. @lru_cache on get_settings() means tests share the same Settings instance across the session. Override with:

app.dependency_overrides[get_settings] = lambda: Settings(SECRET_KEY="test", ...)

Or call get_settings.cache_clear() in a fixture teardown.

4. extra="ignore" masks typos. A misspelled env var in .env (e.g. MAILGN_API_KEY) will silently be ignored. If startup validation is critical, use extra="forbid" in dev and extra="ignore" in prod, controlled by an env flag.