ADR 027: Configuration System Refactoring for Testability¶
Status: Final Type: Feature Created: 2025-11-20 Related-ADRs: 001, 002, 003
Introduction¶
Hop3's configuration system is built around a configuration object rather than module-level constants, giving a more testable, flexible architecture that removes the need for monkeypatching in tests. The four-module configuration split (HopConfig, Config, AppConfig, Hop3Config) is the architecture on which ADR 003's dataclass-based parser builds.
Summary¶
Hop3 replaces module-level constant configuration (hop3/config.py) with a configuration object that supports dependency injection, easier testing, and runtime configuration changes. This removes complex monkeypatching from tests while maintaining backward compatibility.
Context and Goals¶
Context¶
The module-level-constant approach this ADR replaces (packages/hop3-server/src/hop3/config.py):
# Module-level constants computed at import time
HOP3_ROOT = config.get_path("HOP3_ROOT", "/home/hop3")
APP_ROOT = HOP3_ROOT / "apps"
BACKUP_ROOT = HOP3_ROOT / "backups"
# ... many more derived paths
# Used throughout the codebase
from hop3 import config as c
class App:
@property
def app_path(self) -> Path:
return c.APP_ROOT / self.name # Uses module-level constant
Problems with the module-level-constant approach:
-
Testing Complexity: Tests require extensive monkeypatching to override config values:
-
Import-Time Evaluation: Config values are computed when the module is imported, making them hard to change:
-
Multiple Import References: Different modules import config differently:
-
No Runtime Reconfiguration: Can't change configuration without module reloading
-
Unclear Dependencies: Hard to see what config values a component depends on
-
Testing Anti-Pattern: The test migration in ADR 026 highlighted this:
"I don't like monkeypatching the environment. Can we think of something more elegant?"
Goals¶
- Testability: Tests should easily provide custom config without monkeypatching
- Clarity: Config dependencies should be explicit and traceable
- Flexibility: Support multiple config sources (env vars, files, defaults)
- Backward Compatibility: Minimize disruption to existing code
- Type Safety: Maintain or improve type checking for config values
- Performance: No significant runtime overhead
- Simplicity: Don't over-engineer - keep it simple and Pythonic
Tenets¶
- Explicit over Implicit: Dependencies on configuration should be clear
- Testable by Default: Tests shouldn't require hacks or workarounds
- Single Source of Truth: One canonical way to access configuration
- Lazy Evaluation: Derived values (like APP_ROOT) should update when base values change
- Backward Compatible: Existing code should continue to work during migration
Decision¶
Hop3 uses a singleton configuration class with lazy property evaluation and support for dependency injection.
Detailed Design¶
Core Design: Configuration Class¶
# packages/hop3-server/src/hop3/config.py
from __future__ import annotations
import os
from pathlib import Path
from typing import ClassVar
from hop3.lib.config import Config as ConfigLoader
class HopConfig:
"""Hop3 configuration with lazy evaluation and testability.
This class provides:
- Lazy property evaluation (derived values auto-update)
- Dependency injection support for testing
- Type-safe configuration access
- Backward compatibility with module-level access
Usage:
# In production code
from hop3.config import config
app_path = config.APP_ROOT / "myapp"
# In tests
test_config = HopConfig(hop3_root=tmp_path)
app = App(config=test_config)
"""
# Class variable for global singleton instance
_instance: ClassVar[HopConfig | None] = None
def __init__(
self,
config_loader: ConfigLoader | None = None,
hop3_root: Path | str | None = None,
):
"""Initialize configuration.
Args:
config_loader: Optional ConfigLoader instance (for file-based config)
hop3_root: Optional override for HOP3_ROOT (useful for testing)
"""
self._config_loader = config_loader or self._create_default_loader()
self._hop3_root_override = Path(hop3_root) if hop3_root else None
@staticmethod
def _create_default_loader() -> ConfigLoader:
"""Create default config loader."""
testing = "PYTEST_VERSION" in os.environ
if not testing:
hop3_root = Path(os.environ.get("HOP3_ROOT", "/home/hop3"))
config_file = hop3_root / "hop3-server.toml"
if config_file.exists():
return ConfigLoader(file=config_file)
return ConfigLoader()
# Base Configuration Properties
@property
def HOP3_ROOT(self) -> Path:
"""Root directory for all Hop3 data."""
if self._hop3_root_override:
return self._hop3_root_override
return self._config_loader.get_path("HOP3_ROOT", "/home/hop3")
@property
def HOP3_USER(self) -> str:
"""System user running Hop3."""
return self._config_loader.get_str("HOP3_USER", "hop3")
@property
def MODE(self) -> str:
"""Operating mode: production, development, testing."""
return self._config_loader.get_str("MODE", "production")
@property
def HOP3_DEBUG(self) -> bool:
"""Enable debug mode."""
return self._config_loader.get_bool("HOP3_DEBUG", False)
# Security Configuration
@property
def HOP3_SECRET_KEY(self) -> str:
"""Secret key for session encryption."""
return self._config_loader.get_str("HOP3_SECRET_KEY", "")
@property
def HOP3_TOKEN_EXPIRY_HOURS(self) -> int:
"""JWT token expiry in hours."""
return self._config_loader.get_int("HOP3_TOKEN_EXPIRY_HOURS", 24)
@property
def HOP3_UNSAFE(self) -> bool:
"""UNSAFE MODE: Disables authentication. USE ONLY FOR TESTING."""
return self._config_loader.get_bool("HOP3_UNSAFE", False)
# Proxy Configuration
@property
def HOP3_PROXY_TYPE(self) -> str:
"""Reverse proxy type: nginx, caddy, traefik."""
return self._config_loader.get_str("HOP3_PROXY_TYPE", "nginx")
# ACME Configuration
@property
def ACME_ENGINE(self) -> str:
"""ACME client engine: certbot, self-signed."""
testing = "PYTEST_VERSION" in os.environ
default = "self-signed" if testing else "certbot"
return self._config_loader.get_str("ACME_ENGINE", default)
@property
def ACME_ROOT_CA(self) -> str:
"""ACME root certificate authority."""
return self._config_loader.get_str("ACME_ROOT_CA", "letsencrypt.org")
@property
def ACME_EMAIL(self) -> str:
"""Email for ACME registration."""
testing = "PYTEST_VERSION" in os.environ
default = "test@example.com" if testing else "fixme@example.com"
return self._config_loader.get_str("ACME_EMAIL", default)
# Derived Paths (Lazy Evaluation)
@property
def HOP3_BIN(self) -> Path:
"""Binary directory."""
return self.HOP3_ROOT / "bin"
@property
def HOP3_SCRIPT(self) -> str:
"""Path to hop3-server script."""
return str(self.HOP3_ROOT / "venv" / "bin" / "hop3-server")
@property
def APP_ROOT(self) -> Path:
"""Root directory for all applications."""
return self.HOP3_ROOT / "apps"
@property
def BACKUP_ROOT(self) -> Path:
"""Root directory for backups."""
return self.HOP3_ROOT / "backups"
@property
def NGINX_ROOT(self) -> Path:
"""Nginx configuration directory."""
return self.HOP3_ROOT / "nginx"
@property
def CACHE_ROOT(self) -> Path:
"""Cache directory."""
return self.HOP3_ROOT / "cache"
@property
def CADDY_ROOT(self) -> Path:
"""Caddy configuration directory."""
return self.HOP3_ROOT / "caddy"
@property
def TRAEFIK_ROOT(self) -> Path:
"""Traefik configuration directory."""
return self.HOP3_ROOT / "traefik"
@property
def UWSGI_ROOT(self) -> Path:
"""uWSGI configuration root."""
return self.HOP3_ROOT / "uwsgi"
@property
def UWSGI_AVAILABLE(self) -> Path:
"""uWSGI available configurations."""
return self.HOP3_ROOT / "uwsgi-available"
@property
def UWSGI_ENABLED(self) -> Path:
"""uWSGI enabled configurations."""
return self.HOP3_ROOT / "uwsgi-enabled"
@property
def UWSGI_LOG_MAXSIZE(self) -> str:
"""uWSGI log max size."""
return "1048576"
@property
def ACME_WWW(self) -> Path:
"""ACME challenge directory."""
return self.HOP3_ROOT / "acme"
@property
def ROOT_DIRS(self) -> list[Path]:
"""All root directories that should be created on setup."""
return [
self.APP_ROOT,
self.BACKUP_ROOT,
self.CACHE_ROOT,
self.UWSGI_ROOT,
self.UWSGI_AVAILABLE,
self.UWSGI_ENABLED,
self.NGINX_ROOT,
self.ACME_WWW,
]
# Utility Methods
def get_parameters(self) -> dict[str, any]:
"""Get all configuration parameters as a dict.
Useful for debugging and introspection.
"""
return {
name: getattr(self, name)
for name in dir(self)
if name.isupper() and not name.startswith("_")
}
@classmethod
def get_instance(cls) -> HopConfig:
"""Get or create the global singleton instance."""
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def set_instance(cls, instance: HopConfig) -> None:
"""Set the global singleton instance (useful for testing)."""
cls._instance = instance
@classmethod
def reset_instance(cls) -> None:
"""Reset the global singleton (useful for testing)."""
cls._instance = None
# Global singleton instance (backward compatibility)
config = HopConfig.get_instance()
# Export commonly used values for backward compatibility
HOP3_ROOT = config.HOP3_ROOT
APP_ROOT = config.APP_ROOT
BACKUP_ROOT = config.BACKUP_ROOT
# ... etc for all other constants
# Note: These module-level exports are deprecated and will be removed in a future version
# New code should use: from hop3.config import config
Migration Strategy¶
Phase 1: Add New System (Backward Compatible)
- Add
HopConfigclass toconfig.py - Keep existing module-level constants for compatibility
- Update constants to reference config instance:
Phase 2: Migrate Core Components
-
Update
Appmodel to accept optional config: -
Update other core classes similarly
Phase 3: Update Tests
Replace monkeypatching with clean config injection:
# OLD (monkeypatching)
@pytest.fixture
def test_client(tmp_path, monkeypatch):
monkeypatch.setattr(hop3.config, "HOP3_ROOT", tmp_path)
monkeypatch.setattr(hop3.config, "APP_ROOT", tmp_path / "apps")
monkeypatch.setattr(hop3.orm.app.c, "HOP3_ROOT", tmp_path)
monkeypatch.setattr(hop3.orm.app.c, "APP_ROOT", tmp_path / "apps")
# ...
# NEW (clean injection)
@pytest.fixture
def test_config(tmp_path):
"""Provide test configuration with tmp_path."""
return HopConfig(hop3_root=tmp_path)
@pytest.fixture
def test_client(test_config):
"""Create test client with custom config."""
# Set as global instance for components that use get_instance()
HopConfig.set_instance(test_config)
# Components that accept config will use test_config
app = create_app(config=test_config)
client = TestClient(app)
yield client
# Cleanup
HopConfig.reset_instance()
Phase 4: Deprecate Module-Level Constants
- Add deprecation warnings to module-level constants
- Update documentation to use
configobject - Migrate remaining code
Phase 5: Remove Module-Level Constants
- Remove deprecated constants
- Update imports across codebase
Alternative: Minimal Refactoring¶
If full refactoring is too large, a simpler approach:
# Simpler version - just make paths properties of a class
class _ConfigPaths:
"""Lazy evaluation of config paths."""
def __init__(self):
self._loader = ConfigLoader()
self._hop3_root_override = None
def set_hop3_root(self, path: Path) -> None:
"""Override HOP3_ROOT (for testing)."""
self._hop3_root_override = path
@property
def HOP3_ROOT(self) -> Path:
if self._hop3_root_override:
return self._hop3_root_override
return self._loader.get_path("HOP3_ROOT", "/home/hop3")
@property
def APP_ROOT(self) -> Path:
return self.HOP3_ROOT / "apps" # Auto-updates when HOP3_ROOT changes!
# ... other paths
# Global instance
config = _ConfigPaths()
# Tests just call:
config.set_hop3_root(tmp_path)
Examples and Interactions¶
Example 1: Production Usage¶
# Application code
from hop3.config import config
def setup_application(app_name: str):
app_dir = config.APP_ROOT / app_name
app_dir.mkdir(parents=True, exist_ok=True)
# Config values are always fresh
if config.HOP3_DEBUG:
print(f"Creating app at {app_dir}")
Example 2: Testing with Custom Config¶
# Test code
def test_app_creation(tmp_path):
# Create test config
test_config = HopConfig(hop3_root=tmp_path)
# Use in tests
assert test_config.APP_ROOT == tmp_path / "apps"
assert test_config.BACKUP_ROOT == tmp_path / "backups"
# All derived paths auto-update
app_path = test_config.APP_ROOT / "test-app"
assert str(app_path).startswith(str(tmp_path))
Example 3: Dependency Injection¶
# Domain model with optional config
class App:
def __init__(self, name: str, config: HopConfig | None = None):
self.name = name
self._config = config or HopConfig.get_instance()
@property
def app_path(self) -> Path:
return self._config.APP_ROOT / self.name
# Production: uses global config
app = App("myapp")
# Testing: uses custom config
test_config = HopConfig(hop3_root=tmp_path)
app = App("myapp", config=test_config)
Example 4: Before/After Comparison¶
Current System (Before):
# config.py
HOP3_ROOT = config.get_path("HOP3_ROOT", "/home/hop3")
APP_ROOT = HOP3_ROOT / "apps" # Computed once at import
# test
def test_app_create(tmp_path, monkeypatch):
monkeypatch.setattr(hop3.config, "HOP3_ROOT", tmp_path)
monkeypatch.setattr(hop3.config, "APP_ROOT", tmp_path / "apps")
monkeypatch.setattr(hop3.orm.app.c, "HOP3_ROOT", tmp_path)
monkeypatch.setattr(hop3.orm.app.c, "APP_ROOT", tmp_path / "apps")
# Still might not work due to import timing!
New System (After):
# config.py
config = HopConfig.get_instance()
# test
def test_app_create(tmp_path):
test_config = HopConfig(hop3_root=tmp_path)
HopConfig.set_instance(test_config)
# All code using config.APP_ROOT will see tmp_path/apps
# No monkeypatching needed!
Consequences¶
Benefits¶
- Eliminates Monkeypatching: Tests use clean dependency injection
- Lazy Evaluation: Derived paths auto-update when base paths change
- Clear Dependencies: Easy to see what config values are used
- Type Safe: Properties have clear return types
- Testable: Each component can have its own config instance
- Flexible: Supports multiple config sources
- Introspectable: Can dump all config values for debugging
- Backward Compatible: Existing code continues to work during migration
Drawbacks¶
- Migration Effort: Need to update many files across codebase
- Breaking Change: Eventually removes module-level constants
- Slight Overhead: Property access instead of direct attribute access (negligible)
- Learning Curve: Team needs to learn new pattern
- Singleton Complexity: Global singleton can be tricky in some edge cases
- More Lines of Code: Properties are more verbose than constants
Lessons Learned¶
From Monkeypatching the Module-Level Constants¶
The dashboard UI tests (ADR 026) exposed the pain of monkeypatching this configuration:
- Several distinct locations must be patched in lockstep.
- Patching is insufficient on its own — App.create() must also be mocked.
- Results depend on import order, so tests break when it changes.
- "I don't like monkeypatching the environment."
From Other Projects¶
Django: Uses a similar settings object with lazy evaluation
Flask: Uses app context with config
FastAPI: Uses dependency injection
def get_config() -> Config:
return Config()
@app.get("/")
def index(config: Config = Depends(get_config)):
# Tests override the dependency
Alternatives¶
Alternative 1: Keep Current System, Add Helper Functions¶
# Minimal change - just add test helpers
def with_test_config(hop3_root: Path):
"""Context manager for test config."""
import hop3.config
original_root = hop3.config.HOP3_ROOT
hop3.config.HOP3_ROOT = hop3_root
hop3.config.APP_ROOT = hop3_root / "apps"
# ... update all derived paths
try:
yield
finally:
hop3.config.HOP3_ROOT = original_root
# ... restore all paths
Pros: Minimal change, no migration needed Cons: Still uses globals, still brittle, doesn't solve root problem
Alternative 2: Environment Variable Only¶
# Just use environment variables everywhere
@pytest.fixture
def test_env(tmp_path, monkeypatch):
monkeypatch.setenv("HOP3_ROOT", str(tmp_path))
# Reload all modules that use HOP3_ROOT
...
Pros: Simple, standard approach Cons: Requires module reloading, still brittle, environment pollution
Alternative 3: Full Dependency Injection Framework¶
Use a DI framework like dependency-injector:
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
config = providers.Singleton(HopConfig)
app_service = providers.Factory(AppService, config=config)
Pros: Industry standard, very flexible Cons: Heavy dependency, steep learning curve, over-engineered for our needs
Rejected: Too complex for Hop3's needs
Alternative 4: Pydantic Settings¶
Use Pydantic's BaseSettings:
from pydantic_settings import BaseSettings
class HopConfig(BaseSettings):
hop3_root: Path = Path("/home/hop3")
@property
def app_root(self) -> Path:
return self.hop3_root / "apps"
class Config:
env_prefix = "HOP3_"
Pros: Type validation, environment variable support Cons: External dependency, validation overhead
Consideration: Could be used as the ConfigLoader implementation
Prior Art¶
Django Settings¶
Django uses a lazy settings object:
# django/conf/__init__.py
class LazySettings:
def __getattr__(self, name):
if self._wrapped is empty:
self._setup()
return getattr(self._wrapped, name)
settings = LazySettings()
Our design borrows this lazy evaluation concept.
Flask Config¶
Flask uses a dict-like config object:
Simpler but less type-safe than our approach.
Pytest Fixtures¶
Pytest's fixture system is our inspiration for dependency injection:
@pytest.fixture
def app_config(tmp_path):
return HopConfig(hop3_root=tmp_path)
def test_something(app_config):
assert app_config.APP_ROOT == ...
Resolved Design Questions¶
-
Singleton vs Instance: Production code uses the singleton; tests pass explicit instances.
-
Property vs Method: Config values are properties for simple values; methods are used only when computation is expensive.
-
Caching: No caching — properties are cheap and callers want fresh values.
-
Thread Safety: Config access is not made thread-safe; config is read-only after initialization in practice.
-
Config Validation: Only critical values are validated on initialization (e.g.
HOP3_ROOTexists). -
Environment Variable Override: Environment variables always override file config, following 12-factor app principles.
-
Backward Compatibility Period: Module-level constants are supported for two minor versions before removal (deprecate, then remove).
Future Work¶
Enhanced Configuration Features¶
- Config Validation: Add schema validation using Pydantic
- Config Profiles: Support dev/staging/prod profiles
- Dynamic Reload: Support reloading config without restart
- Config Merging: Layer multiple config sources (defaults < file < env < override)
- Config Documentation: Auto-generate config reference from properties
Testing Improvements¶
- Config Fixtures Library: Shared fixtures for common test scenarios
- Config Factories: Factory functions for creating test configs
- Config Assertions: Custom assertions for config validation
- Config Mocking: Helper utilities for mocking specific config values
Developer Experience¶
- CLI Tool:
hop3 config showto display current config - Config Validation:
hop3 config validateto check config file - Config Export:
hop3 config exportto generate config template - Type Stubs: Generate
.pyifiles for better IDE support
Related¶
- ADR 026: Dashboard UI Test Classification — highlighted the monkeypatching pain
- ADR 001-003: Original config system ADRs
- Testing Strategy (
docs/src/dev/testing-strategy.md)
References¶
- 12-Factor App Configuration: https://12factor.net/config
- Django Settings: https://docs.djangoproject.com/en/stable/topics/settings/
- Flask Configuration: https://flask.palletsprojects.com/en/stable/config/
- Pydantic Settings: https://docs.pydantic.dev/latest/concepts/pydantic_settings/
- Martin Fowler - Dependency Injection: https://martinfowler.com/articles/injection.html
- Python Singleton Patterns: https://python-patterns.guide/gang-of-four/singleton/
Related ADRs: ADR 001: Config Files for Hop3, ADR 002: Detailed hop3.toml Format, ADR 003: Config Parsing and Validation