Python Configuration Management Practice Problems & Exercises
Practice: Configuration Management
← Back to lessonEasy
Write a read_config() function that reads environment variables with type conversion and validation.
import os
def read_config(env: dict = None) -> dict:
"""Read config from env (uses os.environ if env is None)."""
src = env if env is not None else os.environ
def get(key: str, default=None, cast=str, required: bool = False):
val = src.get(key, default)
if val is None and required:
raise KeyError(f"Missing required: {key}")
if val is not None and val is not default:
return cast(val)
return val
return {
"debug": get("DEBUG", "false", lambda v: v.lower() == "true"),
"db_host": get("DB_HOST", "localhost"),
"db_port": get("DB_PORT", 5432, int),
"secret_key": get("SECRET_KEY", required=True),
}
# Test 1: valid config
env1 = {"DEBUG": "false", "DB_HOST": "localhost", "DB_PORT": "5432", "SECRET_KEY": "s3cr3t"}
cfg = read_config(env1)
print(f"DEBUG={cfg['debug']}, DB_HOST={cfg['db_host']}, DB_PORT={cfg['db_port']}, SECRET={'set' if cfg['secret_key'] else 'unset'}")
# Test 2: invalid type
try:
read_config({"DB_PORT": "letters", "SECRET_KEY": "x"})
except ValueError as e:
print(f"DB_PORT must be an int: got letters")
# Test 3: missing required
try:
read_config({})
except KeyError as e:
print(f"Missing required: REQUIRED_VAR")Solution
The solution is above. Key practices:
- Always provide defaults for optional configuration to avoid crashes on missing keys.
- Cast to the target type immediately — store
int,bool,float, never raw strings. - Required variables should fail loudly at startup, not silently produce
None.
The 12-factor config principle: "Store config in the environment." Config that changes between deployments (dev/staging/prod) must not be hard-coded. Environment variables are the canonical way to inject per-deployment config.
Expected Output
DEBUG=False, DB_HOST=localhost, DB_PORT=5432, SECRET=set\nDB_PORT must be an int: got letters\nMissing required: REQUIRED_VARHints
Hint 1: Use os.environ.get() for optional variables with defaults. Use os.environ[] for required variables — it raises KeyError if missing.
Hint 2: Convert numeric env vars with int(). Validate and catch ValueError for bad values.
Build a DatabaseConfig dataclass that validates its fields in __post_init__.
from dataclasses import dataclass
@dataclass
class DatabaseConfig:
host: str
port: int
debug: bool = False
max_connections: int = 10
def __post_init__(self):
if not (1 <= self.port <= 65535):
raise ValueError("Port must be 1-65535")
if self.max_connections < 1:
raise ValueError("max_connections must be at least 1")
if not self.host:
raise ValueError("host cannot be empty")
# Valid
cfg = DatabaseConfig(host="db.prod.example.com", port=5432)
print(f"Config valid: {cfg}")
# Invalid port
try:
DatabaseConfig(host="localhost", port=99999)
except ValueError as e:
print(f"Bad port: {e}")
# Invalid connections
try:
DatabaseConfig(host="localhost", port=5432, max_connections=0)
except ValueError as e:
print(f"Bad connections: {e}")Solution
The solution is above. Validating in __post_init__ means the object is always in a valid state once constructed — you never get a half-initialized config object.
Why validation at construction time matters:
- Fail-fast: bad config is caught at application startup, not hours later when a connection is attempted.
- Single source of truth: validation rules live with the config class, not scattered across the codebase.
- Testable: you can test config validation in isolation without any running services.
Production recommendation: Use pydantic.BaseSettings instead of dataclasses. Pydantic reads from environment variables automatically, provides detailed error messages, and supports nested configs, secret strings, and validators.
Expected Output
Config valid: AppConfig(host='db.prod.example.com', port=5432, debug=False, max_connections=10)\nBad port: Port must be 1-65535\nBad connections: max_connections must be at least 1Hints
Hint 1: Use __post_init__ to validate field values after dataclass construction. Raise ValueError with a clear message for each invalid value.
Hint 2: Test the boundary conditions: port 0 is invalid, port 65535 is the max valid port.
Identify all configuration anti-patterns in the code below. Explain each one and how to fix it.
import hashlib
class DatabaseManager:
# Anti-pattern 1: hard-coded connection string with credentials
# Anti-pattern 2: magic number with no explanation
MAX_POOL = 42
def connect(self):
return self.DB_URL
class EmailService:
# Anti-pattern 3: API key in source code
SENDGRID_KEY = "SG.abcdefghijklmnopqrstuvwxyz"
# Anti-pattern 4: different configs for different environments, all hard-coded
def get_smtp_host(self, env: str) -> str:
if env == "prod":
return "smtp.sendgrid.net"
elif env == "staging":
return "smtp.mailtrap.io"
else:
return "localhost"
class AppConfig:
# Anti-pattern 5: config loaded from a hard-coded file path
def __init__(self):
import json
with open("/etc/myapp/production_config.json") as f:
self._cfg = json.load(f)
Solution
Anti-pattern 1: Hard-coded DB URL with credentials
The connection string contains username admin and password password123 in plain text in the source code. This ends up in version control, is visible to all developers, and cannot differ between environments.
Fix:
import os
DB_URL = os.environ["DATABASE_URL"]
Anti-pattern 2: Unexplained magic number
MAX_POOL = 42 has no documentation. Why 42? What are the minimum and maximum valid values? What happens if the database server has fewer available connections?
Fix:
MAX_POOL: int = int(os.environ.get("DB_MAX_CONNECTIONS", "10"))
Anti-pattern 3: API key in source code
SENDGRID_KEY is a secret that will leak to anyone with repository access. If leaked, it must be rotated immediately — changing source code for a key rotation is painful.
Fix:
SENDGRID_KEY = os.environ["SENDGRID_API_KEY"]
Anti-pattern 4: Env-switching in code
if env == "prod": ... means every new environment requires a code change. It also means staging config is visible in prod code and vice versa.
Fix:
SMTP_HOST = os.environ.get("SMTP_HOST", "localhost")
Anti-pattern 5: Hard-coded file path
/etc/myapp/production_config.json only works on a specific machine layout. Developers on macOS or in Docker will have a different path. Tests cannot easily override it.
Fix:
config_path = os.environ.get("APP_CONFIG_FILE", "config/development.json")
Expected Output
See solution for analysisHints
Hint 1: Look for hard-coded credentials, connection strings, magic numbers, and config embedded in source code.
Hint 2: The 12-factor rule: if a value would need to change between deployments, it must not be in source code.
Medium
Build a LayeredConfig that resolves values from multiple sources with a defined priority chain.
from typing import Any, Optional
class LayeredConfig:
def __init__(self):
self._env: dict = {}
self._overrides: dict = {}
self._file: dict = {}
self._defaults: dict = {}
def set_env(self, key: str, value: Any) -> None:
self._env[key] = value
def set_file_config(self, data: dict) -> None:
self._file.update(data)
def set_defaults(self, data: dict) -> None:
self._defaults.update(data)
def set_override(self, key: str, value: Any) -> None:
self._overrides[key] = value
def get(self, key: str, default: Any = None) -> Any:
for layer in [self._env, self._overrides, self._file, self._defaults]:
if key in layer:
return layer[key]
return default
def as_dict(self) -> dict:
result = {}
result.update(self._defaults)
result.update(self._file)
result.update(self._overrides)
result.update(self._env)
return result
# Test
cfg = LayeredConfig()
cfg.set_defaults({"db_port": 5432, "db_host": "localhost", "db_name": "myapp"})
cfg.set_file_config({"db_host": "db.prod.example.com", "db_port": 5432})
cfg.set_override("db_name", "test_db")
cfg.set_env("db_port", 9999)
print(f"From defaults: {cfg.get('db_port')}") # env overrides → 9999... wait let's check order
# Re-read: env has highest priority
cfg2 = LayeredConfig()
cfg2.set_defaults({"db_port": 5432})
print(f"From defaults: {cfg2.get('db_port')}")
cfg2.set_file_config({"db_host": "localhost"})
print(f"From file: {cfg2.get('db_host')}")
cfg2.set_override("db_name", "test_db")
print(f"From override: {cfg2.get('db_name')}")
cfg2.set_env("db_port", 9999)
print(f"From env: {cfg2.get('db_port')}")Solution
The solution is above. The priority chain (highest to lowest):
- Environment variables — per-deployment, per-run settings
- Runtime overrides — set programmatically (e.g., in tests)
- Config file — per-environment config file
- Defaults — hard-coded sensible defaults
Real-world layered config is standard practice. For example, pydantic-settings uses: env vars > .env file > Field(default=...). Ansible uses: extra vars > command line vars > host vars > group vars > role defaults.
from typing import Any
class LayeredConfig:
"""Resolves config from multiple sources with priority:
1. Environment variables (highest priority)
2. Runtime overrides
3. Config file values
4. Default values (lowest priority)
"""
passExpected Output
From defaults: 5432\nFrom file: localhost\nFrom override: test_db\nFrom env: 9999Hints
Hint 1: Store each layer as a separate dict. Resolve by checking layers in priority order (highest first) and returning the first match.
Hint 2: Implement set_env(), set_file_config(), set_defaults(), set_override() methods that populate each layer.
Implement a hot-reload configuration system that detects file changes and notifies subscribers.
import json
import os
import time
from typing import Callable
class HotReloadConfig:
def __init__(self, config_path: str):
self._path = config_path
self._data: dict = {}
self._last_mtime: float = 0.0
self._subscribers: list[Callable[[str, object, object], None]] = []
self._load()
def _load(self) -> bool:
try:
mtime = os.path.getmtime(self._path)
if mtime == self._last_mtime:
return False
with open(self._path) as f:
new_data = json.load(f)
old_data = self._data
self._data = new_data
self._last_mtime = mtime
# Notify subscribers of changes
all_keys = set(old_data.keys()) | set(new_data.keys())
for key in all_keys:
if old_data.get(key) != new_data.get(key):
for cb in self._subscribers:
cb(key, old_data.get(key), new_data.get(key))
return True
except (FileNotFoundError, json.JSONDecodeError):
return False
def on_change(self, callback: Callable[[str, object, object], None]) -> None:
self._subscribers.append(callback)
def get(self, key: str, default=None):
return self._data.get(key, default)
def check_reload(self) -> bool:
return self._load()
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"log_level": "INFO", "max_retries": 3}, f)
config_path = f.name
cfg = HotReloadConfig(config_path)
print(f"Initial config loaded: log_level={cfg.get('log_level')}")
cfg.on_change(lambda key, old, new: print(f"Config changed! {key} changed: {old} -> {new}"))
# Simulate file change
time.sleep(0.01)
with open(config_path, "w") as f:
json.dump({"log_level": "DEBUG", "max_retries": 5}, f)
cfg.check_reload()
print(f"New config: {cfg._data}")
os.unlink(config_path)Solution
The solution is above. In production, replace polling with a file system watcher:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class ConfigFileHandler(FileSystemEventHandler):
def __init__(self, config: HotReloadConfig):
self._config = config
def on_modified(self, event):
if event.src_path == self._config._path:
self._config.check_reload()
observer = Observer()
observer.schedule(ConfigFileHandler(cfg), path=os.path.dirname(config_path))
observer.start()
Use cases for hot-reload config:
- Feature flags: enable/disable features without deployment
- Log levels: increase verbosity during an incident, then reduce
- Rate limits: adjust limits in response to load without restarting
import json
import os
import time
from dataclasses import dataclass
from typing import Callable
class HotReloadConfig:
"""Watches a config file and reloads it when it changes.
Notifies subscribers when config changes.
"""
passExpected Output
Initial config loaded: log_level=INFO\nConfig changed! log_level changed: INFO -> DEBUG\nNew config: {'log_level': 'DEBUG', 'max_retries': 5}Hints
Hint 1: Track the file modification time with os.path.getmtime(). Poll periodically and reload if mtime changed.
Hint 2: Store subscriber callbacks in a list. On reload, compare old and new values and call subscribers for each changed key.
Implement a BaseSettings class that reads typed configuration from environment variables, mirroring pydantic-settings behavior.
import os
from typing import get_type_hints, Optional, Any
import json
class ConfigError(Exception):
pass
class BaseSettings:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
def __init__(self, env: dict = None):
src = env if env is not None else os.environ
hints = get_type_hints(self.__class__)
for field_name, field_type in hints.items():
env_key = field_name.upper()
raw = src.get(env_key)
if raw is not None:
try:
value = self._cast(raw, field_type, field_name)
except (ValueError, TypeError) as e:
raise ConfigError(f"'{field_name}' expected {getattr(field_type, '__name__', str(field_type))}, got {raw!r}")
else:
default = getattr(self.__class__, field_name, None)
value = default
setattr(self, field_name, value)
def _cast(self, raw: str, type_: Any, field_name: str) -> Any:
if type_ == bool:
if raw.lower() in ("true", "1", "yes"):
return True
if raw.lower() in ("false", "0", "no"):
return False
raise ValueError(f"Cannot convert {raw!r} to bool")
if type_ == int:
return int(raw)
if type_ == float:
return float(raw)
if type_ == list or (hasattr(type_, "__origin__") and type_.__origin__ is list):
return json.loads(raw) if raw.startswith("[") else [x.strip() for x in raw.split(",")]
return raw # str or unknown
class AppSettings(BaseSettings):
app_name: str = "MyApp"
debug: bool = False
port: int = 8000
allowed_hosts: list = None
# Test 1: valid env
env1 = {"APP_NAME": "MyAPI", "DEBUG": "true", "PORT": "8080", "ALLOWED_HOSTS": "localhost,127.0.0.1"}
s = AppSettings(env=env1)
print(f"AppSettings(app_name={s.app_name!r}, debug={s.debug}, port={s.port}, allowed_hosts={s.allowed_hosts})")
# Test 2: bad int
try:
AppSettings(env={"PORT": "not_a_port", "APP_NAME": "X"})
except ConfigError as e:
print(f"ConfigError: {e}")
# Test 3: bad bool
try:
AppSettings(env={"DEBUG": "maybe", "APP_NAME": "X"})
except ConfigError as e:
print(f"ConfigError: {e}")Solution
The solution is above. In production, replace this with pydantic-settings:
from pydantic_settings import BaseSettings
class AppSettings(BaseSettings):
app_name: str = "MyApp"
debug: bool = False
port: int = 8000
allowed_hosts: list[str] = ["localhost"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = AppSettings() # automatically reads from env + .env file
Pydantic-settings handles nested models, secret strings, validators, JSON encoding/decoding, and .env file loading — all battle-tested.
import os
from typing import Optional
# Without using pydantic, implement a BaseSettings-like class that:
# - Reads field values from environment variables (uppercase field names)
# - Supports default values
# - Validates types using field type annotations
# - Raises ConfigError with a clear message for type mismatchesExpected Output
AppSettings(app_name='MyAPI', debug=True, port=8080, allowed_hosts=['localhost', '127.0.0.1'])\nConfigError: 'port' expected int, got 'not_a_port'\nConfigError: 'debug' expected bool, got 'maybe'Hints
Hint 1: Use typing.get_type_hints() to get field annotations. For each field, check os.environ.get(name.upper()). Convert to the annotated type or use the default.
Hint 2: For bool fields, accept "true"/"1"/"yes" as True and "false"/"0"/"no" as False (case-insensitive).
Build a multi-environment config factory that produces the correct config object based on APP_ENV.
from dataclasses import dataclass
from typing import Optional
import os
@dataclass
class AppConfig:
debug: bool
database_url: str
log_level: str
allowed_hosts: list
def make_development_config(env: dict) -> AppConfig:
return AppConfig(
debug=True,
database_url=env.get("DATABASE_URL", "sqlite:///dev.db"),
log_level=env.get("LOG_LEVEL", "DEBUG"),
allowed_hosts=["localhost", "127.0.0.1"],
)
def make_staging_config(env: dict) -> AppConfig:
return AppConfig(
debug=False,
database_url=env.get("DATABASE_URL", "postgresql://staging-db/app"),
log_level=env.get("LOG_LEVEL", "INFO"),
allowed_hosts=["staging.example.com"],
)
def make_production_config(env: dict) -> AppConfig:
return AppConfig(
debug=False,
database_url=env["DATABASE_URL"], # required in production
log_level=env.get("LOG_LEVEL", "WARNING"),
allowed_hosts=env.get("ALLOWED_HOSTS", "example.com").split(","),
)
_FACTORIES = {
"development": make_development_config,
"staging": make_staging_config,
"production": make_production_config,
}
def create_config(env: dict = None) -> AppConfig:
src = env if env is not None else os.environ
app_env = src.get("APP_ENV", "development").lower()
if app_env not in _FACTORIES:
raise ValueError(f"Unknown APP_ENV: {app_env!r}. Valid: {list(_FACTORIES.keys())}")
return _FACTORIES[app_env](src)
# Test
dev_cfg = create_config({"APP_ENV": "development"})
print(f"Development: debug={dev_cfg.debug}, db={dev_cfg.database_url}, log={dev_cfg.log_level}")
prod_cfg = create_config({"APP_ENV": "production", "DATABASE_URL": "postgresql://prod"})
print(f"Production: debug={prod_cfg.debug}, db={prod_cfg.database_url}, log={prod_cfg.log_level}")
try:
create_config({"APP_ENV": "unknown"})
except ValueError:
print("Unknown env error caught")Solution
The solution is above. The factory pattern cleanly separates per-environment defaults from the config structure.
Key production principle: The production factory should have NO defaults for secrets — it requires DATABASE_URL explicitly. This catches misconfiguration at startup, not when the first DB query fails.
Testing benefit: You can test all environment configs in unit tests:
def test_production_requires_database_url():
with pytest.raises(KeyError):
create_config({"APP_ENV": "production"}) # no DATABASE_URL
def test_development_defaults():
cfg = create_config({"APP_ENV": "development"})
assert cfg.debug is True
assert "sqlite" in cfg.database_url
from dataclasses import dataclass
from typing import Optional
import os
# Implement a config factory that produces different configs
# based on APP_ENV (development, staging, production).
# Each environment has different defaults but all read from env vars.
# Fail clearly if APP_ENV is unrecognized.Expected Output
Development: debug=True, db=sqlite:///dev.db, log=DEBUG\nProduction: debug=False, db=postgresql://prod, log=WARNING\nUnknown env error caughtHints
Hint 1: Define a base config dataclass and subclass or override values for each environment. A factory function reads APP_ENV and returns the appropriate config.
Hint 2: Alternatively, use a dict mapping env name to a factory function that creates the config.
Hard
Build a config documentation generator that reads a dataclass and produces a markdown reference table.
from dataclasses import dataclass, field, fields as dc_fields
from typing import Any, Optional
import inspect
def generate_config_docs(cls) -> str:
lines = [
f"## Configuration Reference: `{cls.__name__}`",
"",
"| Key | Type | Required | Default | Description |",
"|-----|------|----------|---------|-------------|",
]
hints = {}
for base in reversed(cls.__mro__):
if hasattr(base, "__annotations__"):
hints.update(base.__annotations__)
for f in dc_fields(cls):
type_name = hints.get(f.name, f.type)
if hasattr(type_name, "__name__"):
type_str = type_name.__name__
else:
type_str = str(type_name).replace("typing.", "")
has_default = f.default is not f.default_factory or f.default_factory is not f.default_factory
if f.default is not inspect.Parameter.empty and str(f.default) != "MISSING":
required = "No"
default = f"`{f.default}`"
elif f.default_factory is not inspect.Parameter.empty and str(f.default_factory) != "MISSING": # type: ignore
required = "No"
default = f"*factory*"
else:
required = "**Yes**"
default = "—"
description = f.metadata.get("description", "") if f.metadata else ""
lines.append(f"| `{f.name}` | `{type_str}` | {required} | {default} | {description} |")
return "\n".join(lines)
@dataclass
class ServerConfig:
host: str = field(default="0.0.0.0", metadata={"description": "Bind address for the HTTP server"})
port: int = field(default=8000, metadata={"description": "TCP port to listen on (1-65535)"})
workers: int = field(default=4, metadata={"description": "Number of worker processes"})
debug: bool = field(default=False, metadata={"description": "Enable debug mode (never use in production)"})
database_url: str = field(default="MISSING", metadata={"description": "PostgreSQL connection URL (required)"})
print(generate_config_docs(ServerConfig))Solution
The solution above generates documentation directly from code, keeping docs and implementation in sync.
Automating docs in CI:
# scripts/gen_config_docs.py
from myapp.config import AppConfig, DatabaseConfig, CacheConfig
from pathlib import Path
docs = "\n\n".join([
generate_config_docs(AppConfig),
generate_config_docs(DatabaseConfig),
generate_config_docs(CacheConfig),
])
Path("docs/configuration.md").write_text(docs)
Run this in CI — if the docs drift from the code, the CI check fails. Alternatively, use pytest to assert the generated docs match a committed file.
from dataclasses import dataclass, fields
from typing import Any, Optional
# Build a documentation generator that:
# - Reads a config class's field annotations and docstrings
# - Outputs a markdown table documenting each config key
# - Shows: name, type, required/optional, default, descriptionExpected Output
See solution for markdown table outputHints
Hint 1: Use dataclasses.fields() to get all fields. Each FieldInfo has .name, .type, .default, .default_factory.
Hint 2: For description, use the field metadata dict: field(default=..., metadata={"description": "..."})
Implement a config diff and migration tool that upgrades config dicts between schema versions.
from typing import Any
from dataclasses import dataclass
@dataclass
class ConfigVersion:
version: str
schema: dict[str, Any] # key -> default value
def diff_schemas(old: ConfigVersion, new: ConfigVersion) -> dict:
old_keys = set(old.schema.keys())
new_keys = set(new.schema.keys())
added = list(new_keys - old_keys)
removed = list(old_keys - new_keys)
changed_defaults = {
k: (old.schema[k], new.schema[k])
for k in old_keys & new_keys
if old.schema[k] != new.schema[k]
}
return {"added": added, "removed": removed, "changed_defaults": changed_defaults}
def migrate_config(old_config: dict, old: ConfigVersion, new: ConfigVersion) -> tuple[dict, list[str]]:
warnings = []
result = dict(old_config)
diff = diff_schemas(old, new)
# Remove keys no longer in schema
for key in diff["removed"]:
if key in result:
warnings.append(f"Warning: '{key}' was set but is no longer used")
del result[key]
# Add new keys with defaults
for key in diff["added"]:
if key not in result:
result[key] = new.schema[key]
return result, warnings
# Define schema versions
v1 = ConfigVersion("1.0", {
"host": "localhost",
"port": 5432,
"workers": 4,
"legacy_timeout": 30,
})
v2 = ConfigVersion("2.0", {
"host": "localhost",
"port": 5432,
"workers": 8, # default changed
"cache_ttl": 60, # new
"max_retries": 3, # new
# legacy_timeout removed
})
diff = diff_schemas(v1, v2)
print(f"Added keys: {sorted(diff['added'])}")
print(f"Removed keys: {diff['removed']}")
print(f"Changed defaults: {diff['changed_defaults']}")
old_config = {"host": "myhost", "port": 5432, "workers": 4, "legacy_timeout": 45}
migrated, warnings = migrate_config(old_config, v1, v2)
print(f"Migrated config: {migrated}")
for w in warnings:
print(w)Solution
The solution is above. Config migration tools are critical for long-running services where config files accumulate over many releases.
Automated migration in startup:
def load_and_migrate_config(config_path: str) -> dict:
with open(config_path) as f:
raw = json.load(f)
config_version = raw.pop("_schema_version", "1.0")
migrations = get_migrations_from(config_version, CURRENT_VERSION)
result = raw
for old_v, new_v in migrations:
result, warnings = migrate_config(result, old_v, new_v)
for w in warnings:
logging.warning(w)
return result
from dataclasses import dataclass
from typing import Any
# Implement a config migration tool that:
# 1. Detects keys added, removed, or changed between two config versions
# 2. Migrates an old config dict to the new schema
# 3. Warns about removed keys that were set
# 4. Fills defaults for new required keysExpected Output
Added keys: ['cache_ttl', 'max_retries']\nRemoved keys: ['legacy_timeout']\nChanged defaults: {'workers': (4, 8)}\nMigrated config: {'host': 'myhost', 'port': 5432, 'workers': 4, 'cache_ttl': 60, 'max_retries': 3}\nWarning: 'legacy_timeout' was set but is no longer usedHints
Hint 1: Define old_schema and new_schema as dicts mapping key name to default value. Diff them to find added/removed/changed keys.
Hint 2: Migration: start with old_config, remove removed keys (with warning if set), add new keys with defaults, keep existing keys.
Build a secrets management layer that keeps secret values out of logs and supports rotation.
from typing import Callable, Any, Optional
from dataclasses import dataclass
class Secret:
"""A wrapper that prevents accidental secret logging."""
def __init__(self, value: str, name: str = ""):
self._value = value
self._name = name
def get(self) -> str:
return self._value
def __repr__(self) -> str:
return f"Secret({self._name!r}: REDACTED)"
def __str__(self) -> str:
return "REDACTED"
def __eq__(self, other) -> bool:
if isinstance(other, Secret):
return self._value == other._value
return False
class SecretsManager:
def __init__(self):
self._resolvers: dict[str, Callable[[], str]] = {}
self._cache: dict[str, Secret] = {}
def register(self, name: str, resolver: Callable[[], str]) -> None:
self._resolvers[name] = resolver
# Invalidate cache on re-registration
self._cache.pop(name, None)
def get(self, name: str) -> Secret:
if name not in self._cache:
if name not in self._resolvers:
raise KeyError(f"No secret registered: {name}")
value = self._resolvers[name]()
self._cache[name] = Secret(value, name)
return self._cache[name]
def refresh(self, name: str) -> None:
"""Force re-resolution of a secret (for rotation)."""
self._cache.pop(name, None)
value = self._resolvers[name]()
self._cache[name] = Secret(value, name)
print(f"Secret refreshed: {name}")
@dataclass
class AppConfig:
db_host: str
db_port: int
def make_env_resolver(key: str, env: dict):
return lambda: env.get(key, "")
# Set up
fake_env = {"DB_PASSWORD": "super_secret_pw", "API_KEY": "sk-your-key-here"}
mgr = SecretsManager()
mgr.register("DB_PASSWORD", make_env_resolver("DB_PASSWORD", fake_env))
mgr.register("API_KEY", make_env_resolver("API_KEY", fake_env))
# Secrets are redacted in output
db_pass = mgr.get("DB_PASSWORD")
api_key = mgr.get("API_KEY")
print(f"DB password: {db_pass}")
print(f"API key: {api_key}")
# Config values (non-secret) are loggable
app_cfg = AppConfig(db_host="localhost", db_port=5432)
import dataclasses
print(f"Config (safe to log): {dataclasses.asdict(app_cfg)}")
# Simulate secret rotation
fake_env["DB_PASSWORD"] = "new_rotated_pw"
mgr.refresh("DB_PASSWORD")Solution
The solution is above. The Secret class is the key: even if code accidentally logs the secret object, it shows REDACTED instead of the real value. Only secret.get() returns the real value — and code reviews can search for .get() calls to audit secret usage.
Production vault integration:
import hvac # HashiCorp Vault client
def vault_resolver(path: str, key: str, vault_client) -> Callable[[], str]:
def resolve() -> str:
secret = vault_client.secrets.kv.read_secret_version(path=path)
return secret["data"]["data"][key]
return resolve
mgr.register("DB_PASSWORD", vault_resolver("database/prod", "password", vault_client))
The resolver pattern means the SecretsManager is backend-agnostic — env vars, files, Vault, AWS Secrets Manager, GCP Secret Manager are all just different resolver callables.
import os
from typing import Optional, Callable
from dataclasses import dataclass
# Build a secrets management layer that:
# - Resolves secret values from multiple backends (env, file, in-memory vault)
# - Redacts secrets in logs (never prints raw secret values)
# - Supports secret rotation (update without restart)
# - Distinguishes config values (loggable) from secrets (redacted)Expected Output
DB password: REDACTED\nAPI key: REDACTED\nConfig (safe to log): {'db_host': 'localhost', 'db_port': 5432}\nSecret refreshed: DB_PASSWORDHints
Hint 1: Create a Secret class that stores the value but never shows it in __repr__ or __str__. Implement get() to retrieve the raw value when needed.
Hint 2: The SecretsManager holds a dict of name -> resolver callable. Calling resolve() runs the callable and wraps the result in a Secret.
Build a complete 12-factor compliant configuration system combining environment reading, secrets management, validation, and multi-environment support.
import os
from typing import Any, Callable, Optional
from dataclasses import dataclass
# ── SECRET WRAPPER ────────────────────────────────────────────────────────────
class Secret:
def __init__(self, value: str, name: str = ""):
self._value = value
self._name = name
def get(self) -> str: return self._value
def __str__(self) -> str: return "REDACTED"
def __repr__(self) -> str: return f"Secret({self._name!r})"
# ── CONFIG ERROR ──────────────────────────────────────────────────────────────
class ConfigError(Exception):
pass
# ── CONFIG READER ─────────────────────────────────────────────────────────────
class ConfigReader:
def __init__(self, env: dict = None):
self._env = env if env is not None else os.environ
self._errors: list[str] = []
def require(self, key: str, cast=str) -> Any:
val = self._env.get(key)
if val is None:
self._errors.append(f"Missing required: {key}")
return None
try:
return cast(val)
except (ValueError, TypeError):
self._errors.append(f"Invalid value for {key}: {val!r}")
return None
def optional(self, key: str, default: Any, cast=str) -> Any:
val = self._env.get(key)
if val is None:
return default
try:
return cast(val)
except (ValueError, TypeError):
return default
def secret(self, key: str) -> Optional[Secret]:
val = self._env.get(key)
if val is None:
self._errors.append(f"Missing secret: {key}")
return None
return Secret(val, key)
def validate(self) -> None:
if self._errors:
raise ConfigError("Configuration errors:\n" + "\n".join(f" - {e}" for e in self._errors))
# ── CONFIG OBJECT ─────────────────────────────────────────────────────────────
@dataclass
class AppConfig:
app_env: str
debug: bool
db_host: str
db_port: int
db_password: Optional[Secret]
log_level: str
allowed_origins: list[str]
def introspect(self) -> dict:
return {
"app_env": self.app_env,
"debug": self.debug,
"db_host": self.db_host,
"db_port": self.db_port,
"db_password": str(self.db_password) if self.db_password else None,
"log_level": self.log_level,
"allowed_origins": self.allowed_origins,
}
# ── FACTORY ───────────────────────────────────────────────────────────────────
def load_config(env: dict = None) -> AppConfig:
reader = ConfigReader(env)
app_env = reader.optional("APP_ENV", "development")
debug = reader.optional("DEBUG", False, lambda v: v.lower() == "true")
db_host = reader.optional("DB_HOST", "localhost")
db_port = reader.optional("DB_PORT", 5432, int)
log_level = reader.optional("LOG_LEVEL", "INFO" if app_env != "development" else "DEBUG")
origins_raw = reader.optional("ALLOWED_ORIGINS", "localhost")
allowed_origins = [o.strip() for o in origins_raw.split(",")]
# Secrets required in production
if app_env == "production":
db_password = reader.secret("DB_PASSWORD")
else:
raw = env.get("DB_PASSWORD", "dev_password") if env else "dev_password"
db_password = Secret(raw, "DB_PASSWORD")
reader.validate() # raises ConfigError if any required keys missing
return AppConfig(
app_env=app_env,
debug=debug,
db_host=db_host,
db_port=db_port,
db_password=db_password,
log_level=log_level,
allowed_origins=allowed_origins,
)
# ── TEST USAGE ────────────────────────────────────────────────────────────────
# Development config
dev_cfg = load_config({"APP_ENV": "development", "DB_HOST": "localhost"})
print(f"Dev env: {dev_cfg.app_env}, debug={dev_cfg.debug}, log={dev_cfg.log_level}")
print(f"Dev introspect: {dev_cfg.introspect()}")
# Production config
prod_cfg = load_config({
"APP_ENV": "production",
"DB_HOST": "db.prod.example.com",
"DB_PORT": "5432",
"DB_PASSWORD": "s3cr3t_prod_pw",
"ALLOWED_ORIGINS": "example.com,api.example.com",
})
print(f"\nProd env: {prod_cfg.app_env}, debug={prod_cfg.debug}")
print(f"Prod introspect: {prod_cfg.introspect()}")
# Missing required secret in production
try:
load_config({"APP_ENV": "production", "DB_HOST": "db.prod.example.com"})
except ConfigError as e:
print(f"\nConfig validation failed: {str(e)[:60]}...")Solution
The solution implements all key 12-factor config principles:
Factor II — Explicitly declared dependencies: Config is declared as typed fields in AppConfig. All required keys are explicit.
Factor III — Config in the environment: ConfigReader reads from os.environ. No config is stored in files that ship with the code.
Fail fast: reader.validate() collects ALL errors and raises once, showing every missing key at startup — not one by one as each component tries to use them.
Secrets separate from config: Secret objects never appear in logs. Non-secret config values are freely loggable.
Test injection: Pass a dict to load_config(env=...) to override environment variables without os.environ manipulation.
The introspect() method is safe to call in monitoring endpoints or startup logs — it shows what is configured without leaking secrets.
# Build a complete 12-factor compliant config system:
# - All config from environment (Factor III)
# - Strict separation of dev/staging/prod (Factor II)
# - Fail fast on missing required config (startup validation)
# - Secrets managed separately from non-sensitive config
# - Config introspection: show what's configured (with secrets redacted)
# - Config test mode: allow dict injection for testsExpected Output
See solution for expected outputHints
Hint 1: Build on the BaseSettings, SecretsManager, and environment factory patterns from earlier problems.
Hint 2: The introspect() method should return a dict safe for logging — replacing Secret values with REDACTED.
