· Pydantic· FastAPI· Python· konfiguration· environment· settings· backend· SaaS
Pydantic Settings v2 — miljøkonfiguration i FastAPI
Pydantic Settings v2 til konfigurationsstyring i FastAPI: .env filer, miljøvariabler, nested settings, secrets, validering og test-overrides.
Af M-Bus Gateway
Pydantic Settings v2 er standarden for typesikker konfiguration i FastAPI-applikationer. Her er mønstrene der bruges i produktions-SaaS.
Installation og grundlæggende setup
# pydantic-settings installeres separat fra pydantic v2:
# uv add pydantic-settings
# server/src/config.py
from pydantic import Field, SecretStr, PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore", # Ignorer ukendte env vars
)
# Database
database_url: PostgresDsn = Field(
default="postgresql+asyncpg://mbus:secret@localhost:5432/mbus"
)
# Redis / Celery
redis_url: RedisDsn = Field(default="redis://localhost:6379/0")
celery_broker_url: str = Field(default="redis://localhost:6379/1")
# JWT
jwt_secret: SecretStr # Ingen default — SKAL sættes i .env
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60
jwt_refresh_expire_days: int = 30
# Stripe
stripe_secret_key: SecretStr = Field(default=SecretStr("sk_test_placeholder"))
stripe_webhook_secret: SecretStr = Field(default=SecretStr("whsec_placeholder"))
stripe_price_id_monthly: str = ""
# Brevo / email
brevo_api_key: SecretStr = Field(default=SecretStr(""))
brevo_sender_email: str = "noreply@mbus-gateway.dk"
brevo_sender_name: str = "M-Bus Gateway"
# Hetzner Object Storage
hetzner_access_key: SecretStr = Field(default=SecretStr(""))
hetzner_secret_key: SecretStr = Field(default=SecretStr(""))
hetzner_bucket: str = "mbus-production"
hetzner_endpoint: str = "https://fsn1.your-objectstorage.com"
# MQTT
mqtt_host: str = "localhost"
mqtt_port: int = 1883
mqtt_tls_port: int = 8883
mqtt_username: str = ""
mqtt_password: SecretStr = Field(default=SecretStr(""))
# App
debug: bool = False
allowed_origins: list[str] = ["http://localhost:5173"]
log_level: str = "INFO"
environment: str = "production"
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def database_url_str(self) -> str:
return str(self.database_url)
@lru_cache
def get_settings() -> Settings:
"""Cached settings — indlæses én gang pr. process."""
return Settings()
Nested settings med model-prefix
# server/src/config.py (udvidet med nested settings)
class DMISettings(BaseSettings):
"""DMI Klimagrid API konfiguration."""
model_config = SettingsConfigDict(env_prefix="DMI_")
api_key: SecretStr = Field(default=SecretStr(""))
base_url: str = "https://dmigw.govcloud.dk/v2/climateData"
timeout_seconds: int = 30
retry_attempts: int = 3
class MQTTSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="MQTT_")
host: str = "localhost"
port: int = 1883
tls_port: int = 8883
ca_cert_path: str = "/etc/mosquitto/ca.crt"
client_cert_path: str = "/etc/mosquitto/client.crt"
client_key_path: str = "/etc/mosquitto/client.key"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__", # MQTT__HOST=localhost sætter mqtt.host
)
# Nested settings (via env_nested_delimiter)
dmi: DMISettings = DMISettings()
mqtt: MQTTSettings = MQTTSettings()
database_url: PostgresDsn = ...
jwt_secret: SecretStr = ...
.env filer pr. miljø
# .env (lokalt udviklingsmiljø — GIT-IGNORERET)
DATABASE_URL=postgresql+asyncpg://mbus:devpassword@localhost:5432/mbus_dev
REDIS_URL=redis://localhost:6379/0
JWT_SECRET=dev-secret-never-use-in-production-min-32-chars
STRIPE_SECRET_KEY=sk_test_4eC39HqLyjWDarjtT1zdp7dc
DEBUG=true
ENVIRONMENT=development
LOG_LEVEL=DEBUG
# .env.test (test-miljø — kan commites)
DATABASE_URL=postgresql+asyncpg://mbus:test@localhost:5432/mbus_test
JWT_SECRET=test-jwt-secret-exactly-32-chars!
ENVIRONMENT=test
DEBUG=false
# Produktion: Ingen .env fil — sættes via GitHub Secrets / Docker env
Dependency injection i FastAPI
# server/src/dependencies.py
from fastapi import Depends
from functools import lru_cache
from server.src.config import Settings, get_settings
def get_jwt_secret(settings: Settings = Depends(get_settings)) -> str:
"""Returnerer JWT secret som string (ikke SecretStr)."""
return settings.jwt_secret.get_secret_value()
def get_stripe_key(settings: Settings = Depends(get_settings)) -> str:
return settings.stripe_secret_key.get_secret_value()
# I router — settings injiceres automatisk:
@router.post("/auth/login")
async def login(
credentials: LoginRequest,
settings: Settings = Depends(get_settings),
):
token = create_jwt(
user_id=str(user.id),
secret=settings.jwt_secret.get_secret_value(),
algorithm=settings.jwt_algorithm,
expire_minutes=settings.jwt_expire_minutes,
)
return {"access_token": token}
SecretStr — beskyt credentials i logs
# SecretStr skjuler værdien i repr() og str():
from pydantic import SecretStr
s = SecretStr("my-super-secret-key")
print(s) # **********
print(repr(s)) # SecretStr('**********')
print(s.get_secret_value()) # my-super-secret-key (kun her)
# I structlog / logging:
import structlog
log = structlog.get_logger()
settings = get_settings()
log.info("Starting server", database=str(settings.database_url))
# Database URL er OK at logge (ingen password i asyncpg URL ved korrekt opsætning)
# JWT secret: aldrig log settings.jwt_secret — SecretStr beskytter automatisk
log.debug("Config loaded", jwt_algo=settings.jwt_algorithm) # OK
Test-overrides med pytest
# server/tests/conftest.py
import pytest
from unittest.mock import patch
from server.src.config import Settings, get_settings
from server.src.main import app
@pytest.fixture(autouse=True)
def override_settings():
"""Override settings for alle tests — isolerer fra produktion."""
test_settings = Settings(
database_url="postgresql+asyncpg://mbus:test@localhost:5432/mbus_test",
jwt_secret="test-jwt-secret-min-32-characters!!",
redis_url="redis://localhost:6379/15", # Separat test-database
environment="test",
debug=True,
stripe_secret_key="sk_test_dummy",
stripe_webhook_secret="whsec_dummy",
brevo_api_key="",
)
app.dependency_overrides[get_settings] = lambda: test_settings
yield test_settings
app.dependency_overrides.clear()
get_settings.cache_clear() # Ryd lru_cache
@pytest.fixture
def settings(override_settings: Settings) -> Settings:
"""Giver adgang til test-settings i tests."""
return override_settings
# Brug i test:
def test_jwt_secret_is_not_empty(settings: Settings):
assert len(settings.jwt_secret.get_secret_value()) >= 32
def test_environment_is_test(settings: Settings):
assert settings.environment == "test"
assert not settings.is_production
Validering med @field_validator
# server/src/config.py
from pydantic import field_validator, model_validator
class Settings(BaseSettings):
jwt_secret: SecretStr
jwt_expire_minutes: int = 60
allowed_origins: list[str] = []
@field_validator("jwt_secret")
@classmethod
def validate_jwt_secret(cls, v: SecretStr) -> SecretStr:
secret = v.get_secret_value()
if len(secret) < 32:
raise ValueError("JWT_SECRET skal være mindst 32 tegn")
if secret == "dev-secret" or "changeme" in secret.lower():
import warnings
warnings.warn("JWT_SECRET er usikkert — brug et random secret i produktion")
return v
@field_validator("allowed_origins", mode="before")
@classmethod
def parse_origins(cls, v: str | list) -> list[str]:
"""Tillad ALLOWED_ORIGINS som kommasepareret string i .env."""
if isinstance(v, str):
return [origin.strip() for origin in v.split(",") if origin.strip()]
return v
@model_validator(mode="after")
def check_production_requirements(self) -> "Settings":
if self.is_production:
if not self.stripe_secret_key.get_secret_value().startswith("sk_live"):
raise ValueError("Brug kun Stripe LIVE nøgle i produktion")
if not self.brevo_api_key.get_secret_value():
raise ValueError("BREVO_API_KEY er påkrævet i produktion")
return self
Konklusion
Pydantic Settings v2 giver typesikker, valideret konfiguration med automatisk indlæsning fra .env og miljøvariabler. SecretStr beskytter credentials mod utilsigtede log-eksponeringer. @lru_cache sikrer at settings kun indlæses én gang. Test-overrides via dependency_overrides isolerer tests fra produktion.
Se FastAPI dependency injection guide eller FastAPI SQLModel multi-tenant guide.