Configuration: defaults, files and environment
Page sizes, currencies and connection strings differ between your laptop, the test server and production. Configuration keeps them out of the code: defaults, overridden by a file, overridden by environment variables, checked once.
- Merge defaults, a settings file and environment overrides in a fixed precedence
- Convert environment text to the right types, ignoring unrelated variables
- Validate once into a frozen dataclass that the rest of the program receives
Hard-coded values (PAGE_SIZE = 50 buried in a function) force a code change for every environment. Instead, collect settings in layers, each overriding the one before: built-in defaults, then a settings file (JSON or TOML) for this deployment, then environment variables for the machine or container (often secrets and per-server values). Environment variables are always text, so they must be converted to the type each setting expects. Read the environment as a dictionary passed in (os.environ in production), so tests can pass a plain dictionary (the CLI & Automation module, the Advanced OOP & Design module).
Run each example to see what it prints. Running examples doesn't count toward progress.
import json DEFAULTS = {"page_size": 50, "currency": "USD", "debug": False} def from_env(environ, prefix, defaults): overrides = {} for key, text in environ.items(): if not key.startswith(prefix): continue name = key[len(prefix):].lower() if name not in defaults: continue default = defaults[name] if isinstance(default, bool): overrides[name] = text.lower() in ("1", "true", "yes") elif isinstance(default, int): overrides[name] = int(text) else: overrides[name] = text return overrides file_settings = json.loads('{"page_size": 100, "currency": "GBP"}') environ = {"SHOP_CURRENCY": "EUR", "SHOP_DEBUG": "true", "PATH": "/usr/bin", "SHOP_THEME": "dark"} settings = {**DEFAULTS, **file_settings, **from_env(environ, "SHOP_", DEFAULTS)} print(settings)
Validate the merged settings once, at start-up, and hand the rest of the program an object, not a loose dictionary. A dataclass with frozen=True (the Data Models & Typing module introduced dataclasses) can't be changed after it is created, so no part of the program can quietly modify a setting another part relies on, and __post_init__ is the natural place to reject bad values with a clear message.
from dataclasses import dataclass, FrozenInstanceError @dataclass(frozen=True) class Settings: page_size: int currency: str debug: bool def __post_init__(self): if not 1 <= self.page_size <= 500: raise ValueError(f"page_size must be 1-500, got {self.page_size}") if self.currency not in ("USD", "EUR", "GBP"): raise ValueError(f"unsupported currency {self.currency!r}") settings = Settings(**{"page_size": 100, "currency": "EUR", "debug": True}) print(settings) try: settings.page_size = 5 except FrozenInstanceError as error: print(type(error).__name__) try: Settings(page_size=0, currency="USD", debug=False) except ValueError as error: print(error)
When to use it: for anything that differs between environments or that operators should change without a code release: limits, URLs, feature switches, credentials (credentials only from the environment or a secrets store, never from the repository). Mistakes to avoid: reading os.environ all over the program (read once, pass the settings object); forgetting that environment values are strings ("false" is truthy!); silently ignoring invalid values; and mutating the defaults dictionary while merging.
- Layers: defaults < settings file < environment; later layers win.
- Environment values are text: convert by type, and ignore unrelated variables.
- Validate once into a frozen object and pass it to the code that needs it.
- Next: keeping the core independent of storage and services.