Validate Environment Config At Startup

A typed environment loader that fails fast on missing or malformed variables instead of surfacing None or wrong types deep in the code.

PythonCli Tooling

Code

import os
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Mapping, Optional


class ConfigError(ValueError):
    """Raised when environment configuration is invalid."""


@dataclass(frozen=True)
class EnvVar:
    name: str
    parser: Callable[[str], Any]
    required: bool = True
    default: Optional[Any] = None
    minimum: Optional[float] = None
    maximum: Optional[float] = None


@dataclass(frozen=True)
class Config:
    values: Dict[str, Any] = field(default_factory=dict)

    def __getitem__(self, key: str) -> Any:
        return self.values[key]


def load_config(schema: List[EnvVar], env: Optional[Mapping[str, str]] = None) -> Config:
    """Load and validate configuration from env, reporting all problems at once."""
    source = os.environ if env is None else env
    problems: List[str] = []
    values: Dict[str, Any] = {}

    for var in schema:
        raw = source.get(var.name)
        if raw is None or raw == "":
            if var.required:
                problems.append(f"{var.name}: required but missing")
            else:
                values[var.name] = var.default
            continue
        try:
            parsed = var.parser(raw)
        except (ValueError, TypeError) as exc:
            problems.append(f"{var.name}: cannot parse {raw!r} ({exc})")
            continue
        if isinstance(parsed, (int, float)):
            if var.minimum is not None and parsed < var.minimum:
                problems.append(f"{var.name}: {parsed} below minimum {var.minimum}")
                continue
            if var.maximum is not None and parsed > var.maximum:
                problems.append(f"{var.name}: {parsed} above maximum {var.maximum}")
                continue
        values[var.name] = parsed

    if problems:
        raise ConfigError("invalid configuration:\n  " + "\n  ".join(problems))
    return Config(values=values)