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:
12
PLAN.md
12
PLAN.md
@@ -7,12 +7,12 @@ Check off items as completed. Each phase builds on the previous.
|
||||
## Phase 1: Foundation
|
||||
|
||||
### Step 1.1: Project Structure & Configuration
|
||||
- [ ] Create Python package structure (`src/animaltrack/`)
|
||||
- [ ] Create `pyproject.toml` with dependencies
|
||||
- [ ] Create `flake.nix` with dev environment
|
||||
- [ ] Create `.envrc`, `.gitignore`
|
||||
- [ ] Implement `config.py` with all env vars from spec §18
|
||||
- [ ] Write tests for config loading and validation
|
||||
- [x] Create Python package structure (`src/animaltrack/`)
|
||||
- [x] Create `pyproject.toml` with dependencies
|
||||
- [x] Create `flake.nix` with dev environment
|
||||
- [x] Create `.envrc`, `.gitignore`
|
||||
- [x] Implement `config.py` with all env vars from spec §18
|
||||
- [x] Write tests for config loading and validation
|
||||
- [ ] **Commit checkpoint**
|
||||
|
||||
### Step 1.2: Database Connection & Pragmas
|
||||
|
||||
@@ -47,6 +47,7 @@ ignore = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
python_files = "test_*.py"
|
||||
python_classes = "Test*"
|
||||
python_functions = "test_*"
|
||||
|
||||
65
src/animaltrack/config.py
Normal file
65
src/animaltrack/config.py
Normal 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,
|
||||
}
|
||||
297
tests/test_config.py
Normal file
297
tests/test_config.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# ABOUTME: Tests for the configuration module.
|
||||
# ABOUTME: Validates environment variable loading, defaults, and validation.
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
class TestConfigDefaults:
|
||||
"""Test that config loads with correct default values."""
|
||||
|
||||
def test_default_port(self):
|
||||
"""Default port should be 3366 (EGG in leet)."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.port == 3366
|
||||
|
||||
def test_default_db_path(self):
|
||||
"""Default DB path should be animaltrack.db."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.db_path == "animaltrack.db"
|
||||
|
||||
def test_default_auth_header_name(self):
|
||||
"""Default auth header should be X-Oidc-Username."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.auth_header_name == "X-Oidc-Username"
|
||||
|
||||
def test_default_trusted_proxy_ips_empty(self):
|
||||
"""Default trusted proxy IPs should be empty list."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.trusted_proxy_ips == []
|
||||
|
||||
def test_default_csrf_cookie_name(self):
|
||||
"""Default CSRF cookie name should be csrf_token."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.csrf_cookie_name == "csrf_token"
|
||||
|
||||
def test_default_base_path(self):
|
||||
"""Default base path should be /."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.base_path == "/"
|
||||
|
||||
def test_default_seed_on_start(self):
|
||||
"""Default seed on start should be False."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.seed_on_start is False
|
||||
|
||||
def test_default_log_level(self):
|
||||
"""Default log level should be INFO."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.log_level == "INFO"
|
||||
|
||||
def test_default_metrics_enabled(self):
|
||||
"""Default metrics enabled should be False."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.metrics_enabled is False
|
||||
|
||||
|
||||
class TestConfigEnvOverrides:
|
||||
"""Test that environment variables override defaults."""
|
||||
|
||||
def test_port_override(self):
|
||||
"""PORT env var should override default."""
|
||||
with patch.dict(os.environ, {"PORT": "8080", "CSRF_SECRET": "test-secret"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.port == 8080
|
||||
|
||||
def test_db_path_override(self):
|
||||
"""DB_PATH env var should override default."""
|
||||
with patch.dict(
|
||||
os.environ, {"DB_PATH": "/data/custom.db", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.db_path == "/data/custom.db"
|
||||
|
||||
def test_auth_header_name_override(self):
|
||||
"""AUTH_HEADER_NAME env var should override default."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"AUTH_HEADER_NAME": "X-Custom-User", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.auth_header_name == "X-Custom-User"
|
||||
|
||||
def test_csrf_cookie_name_override(self):
|
||||
"""CSRF_COOKIE_NAME env var should override default."""
|
||||
with patch.dict(
|
||||
os.environ, {"CSRF_COOKIE_NAME": "my_csrf", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.csrf_cookie_name == "my_csrf"
|
||||
|
||||
def test_base_path_override(self):
|
||||
"""BASE_PATH env var should override default."""
|
||||
with patch.dict(
|
||||
os.environ, {"BASE_PATH": "/app", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.base_path == "/app"
|
||||
|
||||
def test_seed_on_start_true(self):
|
||||
"""SEED_ON_START=true should set to True."""
|
||||
with patch.dict(
|
||||
os.environ, {"SEED_ON_START": "true", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.seed_on_start is True
|
||||
|
||||
def test_seed_on_start_false(self):
|
||||
"""SEED_ON_START=false should set to False."""
|
||||
with patch.dict(
|
||||
os.environ, {"SEED_ON_START": "false", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.seed_on_start is False
|
||||
|
||||
def test_log_level_override(self):
|
||||
"""LOG_LEVEL env var should override default."""
|
||||
with patch.dict(
|
||||
os.environ, {"LOG_LEVEL": "DEBUG", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.log_level == "DEBUG"
|
||||
|
||||
def test_metrics_enabled_true(self):
|
||||
"""METRICS_ENABLED=true should set to True."""
|
||||
with patch.dict(
|
||||
os.environ, {"METRICS_ENABLED": "true", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.metrics_enabled is True
|
||||
|
||||
|
||||
class TestTrustedProxyIPs:
|
||||
"""Test TRUSTED_PROXY_IPS parsing."""
|
||||
|
||||
def test_single_ip(self):
|
||||
"""Single IP should be parsed as list with one element."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": "192.168.1.1", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.trusted_proxy_ips == ["192.168.1.1"]
|
||||
|
||||
def test_multiple_ips_comma_separated(self):
|
||||
"""Multiple IPs should be parsed from comma-separated string."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": "192.168.1.1,10.0.0.1,172.16.0.1", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.trusted_proxy_ips == ["192.168.1.1", "10.0.0.1", "172.16.0.1"]
|
||||
|
||||
def test_ips_with_spaces_trimmed(self):
|
||||
"""Spaces around IPs should be trimmed."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TRUSTED_PROXY_IPS": " 192.168.1.1 , 10.0.0.1 ", "CSRF_SECRET": "test-secret"},
|
||||
clear=True,
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.trusted_proxy_ips == ["192.168.1.1", "10.0.0.1"]
|
||||
|
||||
def test_empty_string_gives_empty_list(self):
|
||||
"""Empty string should give empty list."""
|
||||
with patch.dict(
|
||||
os.environ, {"TRUSTED_PROXY_IPS": "", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.trusted_proxy_ips == []
|
||||
|
||||
|
||||
class TestCsrfSecretRequired:
|
||||
"""Test that CSRF_SECRET is required."""
|
||||
|
||||
def test_missing_csrf_secret_raises_error(self):
|
||||
"""Missing CSRF_SECRET should raise ValidationError."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings()
|
||||
# Check that the error is about csrf_secret
|
||||
errors = exc_info.value.errors()
|
||||
assert any(e["loc"] == ("csrf_secret",) for e in errors)
|
||||
|
||||
def test_csrf_secret_provided(self):
|
||||
"""Provided CSRF_SECRET should be accessible."""
|
||||
with patch.dict(os.environ, {"CSRF_SECRET": "my-super-secret-key"}, clear=True):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.csrf_secret == "my-super-secret-key"
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""Test validation of config values."""
|
||||
|
||||
def test_invalid_port_not_integer(self):
|
||||
"""Non-integer PORT should raise ValidationError."""
|
||||
with patch.dict(
|
||||
os.environ, {"PORT": "not-a-number", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Settings()
|
||||
|
||||
def test_invalid_log_level(self):
|
||||
"""Invalid LOG_LEVEL should raise ValidationError."""
|
||||
with patch.dict(
|
||||
os.environ, {"LOG_LEVEL": "INVALID_LEVEL", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Settings()
|
||||
|
||||
def test_valid_log_levels(self):
|
||||
"""All standard log levels should be valid."""
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
for level in valid_levels:
|
||||
with patch.dict(
|
||||
os.environ, {"LOG_LEVEL": level, "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.log_level == level
|
||||
|
||||
def test_log_level_case_insensitive(self):
|
||||
"""Log level should be case-insensitive (normalized to uppercase)."""
|
||||
with patch.dict(
|
||||
os.environ, {"LOG_LEVEL": "debug", "CSRF_SECRET": "test-secret"}, clear=True
|
||||
):
|
||||
from animaltrack.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
assert settings.log_level == "DEBUG"
|
||||
@@ -1,7 +0,0 @@
|
||||
# ABOUTME: Placeholder test to ensure test infrastructure works.
|
||||
# ABOUTME: Remove once real tests are added.
|
||||
|
||||
|
||||
def test_placeholder():
|
||||
"""Placeholder test - remove when real tests exist."""
|
||||
assert True
|
||||
Reference in New Issue
Block a user