feat: add config module with environment variable handling

Implements Step 1.1 config.py with Pydantic Settings:
- PORT (default 3366), DB_PATH, BASE_PATH
- AUTH_HEADER_NAME, TRUSTED_PROXY_IPS (comma-separated)
- CSRF_SECRET (required), CSRF_COOKIE_NAME
- SEED_ON_START, LOG_LEVEL (validated), METRICS_ENABLED

Includes 28 TDD tests covering defaults, overrides, and validation.
Also fixes pytest pythonpath configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 17:54:21 +00:00
parent d213abf9d9
commit 61f704c68d
5 changed files with 369 additions and 13 deletions

65
src/animaltrack/config.py Normal file
View File

@@ -0,0 +1,65 @@
# ABOUTME: Application configuration loaded from environment variables.
# ABOUTME: Uses Pydantic Settings for validation and type coercion.
import logging
from functools import cached_property
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
def _parse_comma_separated(value: str) -> list[str]:
"""Parse a comma-separated string into a list of trimmed strings."""
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Server configuration
port: int = 3366 # EGG in leet: 3=E, 6=G, 6=G
db_path: str = "animaltrack.db"
base_path: str = "/"
# Authentication
auth_header_name: str = "X-Oidc-Username"
trusted_proxy_ips_raw: str = Field(default="", validation_alias="trusted_proxy_ips")
# CSRF protection
csrf_secret: str # Required, no default
csrf_cookie_name: str = "csrf_token"
# Application behavior
seed_on_start: bool = False
log_level: str = "INFO"
metrics_enabled: bool = False
@cached_property
def trusted_proxy_ips(self) -> list[str]:
"""Parse trusted proxy IPs from comma-separated raw string."""
return _parse_comma_separated(self.trusted_proxy_ips_raw)
@field_validator("log_level", mode="before")
@classmethod
def normalize_and_validate_log_level(cls, v: str) -> str:
"""Normalize log level to uppercase and validate it's a valid Python log level."""
if v is None:
return "INFO"
normalized = v.upper()
if normalized not in VALID_LOG_LEVELS:
msg = f"Invalid log level: {v}. Must be one of: {', '.join(sorted(VALID_LOG_LEVELS))}"
raise ValueError(msg)
return normalized
def get_numeric_log_level(self) -> int:
"""Get the numeric log level for use with logging module."""
return getattr(logging, self.log_level)
model_config = {
"env_prefix": "",
"case_sensitive": False,
}