We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Edge of the System step 2 of 12
Typed settings: parse the environment once, report every problem
Configuration read lazily and untyped fails at 03:00 on the one code path
nobody exercised, with a KeyError and no indication which of forty variables
is missing. The fix is structural: parse the whole environment exactly once,
at startup, into a typed immutable object, and fail loudly with everything
that is wrong.
def load_settings(env: Mapping[str, str]) -> Settings: ...
Note the parameter. load_settings() reaching into os.environ is untestable
and picks up your laptop’s environment in CI; the injected mapping is the whole
difference between a function you can test and one you cannot.
Settings is given:
| field | env var | type | default |
|---|---|---|---|
service_name |
SERVICE_NAME |
str |
required |
port |
PORT |
int |
8080 |
debug |
DEBUG |
bool |
False |
environment |
ENVIRONMENT |
Literal["dev", "staging", "prod"] |
"dev" |
timeout_seconds |
TIMEOUT_SECONDS |
float |
5.0 |
Why the type checker forces you to do this properly
--strict rejects Settings(port=env["PORT"]) outright, because str is not
int. That is the point. Every environment variable is a string; the checker
will not let you pretend otherwise, so the coercion becomes an explicit,
reviewable, testable step instead of an implicit one that fails in production.
The bool trap
debug = bool(os.environ.get("DEBUG")) # DEBUG=false -> True
Every non-empty string is truthy. "false", "0", "no" — all True. This is
one of the most common bugs in Python configuration and it silently ships debug
mode to production.
Accept, case-insensitively and after stripping: 1 true yes on as true,
0 false no off as false. Reject anything else — "maybe" is an error, not
a guess. Guessing is how a typo becomes a silent behaviour change.
Report every problem, not the first one
If PORT and TIMEOUT_SECONDS are both wrong, an operator must learn that in
one deploy, not two. Collect FieldErrors in a list and raise a single
ExceptionGroup("invalid settings", errors) at the end.
Each FieldError must chain the underlying ValueError:
try:
return int(raw)
except ValueError as exc:
error = FieldError(name, "expected a base-10 integer")
error.__cause__ = exc # exactly what `raise ... from exc` does
errors.append(error)
raise X from exc is sugar for setting __cause__ and raising. When you are
collecting rather than raising, set it yourself — otherwise the traceback
loses the only line that says what actually failed to parse. A missing required
variable has no underlying exception, so its __cause__ stays None; the
driver reports "NoneType" for that case.
Narrowing to a Literal without cast
ENVIRONMENT arrives as a str and the field is
Literal["dev", "staging", "prod"]. cast would work and would also lie — it
asserts something you have not checked. The honest version:
for candidate in _ENVIRONMENTS: # tuple[Environment, ...]
if lowered == candidate:
return candidate # already has the narrow type
raise ValueError(...)
One more --strict trap waits for you: the default value must carry the
narrow annotation too. An unannotated "dev" widens the inferred type back to
str and the Settings(...) call fails to type-check.
Unknown variables are NOT an error
Unlike a config file — where an unrecognised key means a typo (see
“Parse, don’t validate”) — the environment is a shared namespace containing
PATH, HOME and a hundred other things. Ignore what you do not recognise.
The asymmetry is deliberate and worth remembering.
The part this problem cannot test, and you still have to know
Secrets do not belong in the repo, the image, a log line or an exception
message. Prefer file-mounted or broker-issued credentials over environment
variables: an env var is visible in /proc/<pid>/environ, is inherited by every
child process you spawn, and lands in crash dumps. pydantic-settings supports
exactly this with secrets_dir (with the environment taking priority), and
SecretStr keeps the value out of reprs. If you stay dependency-free, a small
frozen wrapper with a redacting __repr__ does the same job in eight lines.
Three defensible stacks, and the real trade-off between them:
-
frozen dataclass + hand-written
from_env— zero dependencies, fully explicit, every coercion visible in review. What you are writing here. -
pydantic-settings —
BaseSettings,.envfiles,secrets_dir,SecretStr, layered precedence for free; a hard dependency and a second validation model in your process. - environ-config / attrs — lighter than pydantic, declarative, and the library is already there if you use attrs elsewhere.
Any of the three is fine. Reading forty os.environ.get calls scattered through
the codebase is not.
Loading visualization…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.