int(os.environ.get("PORT", "8080")) fails constantly in ways that waste your time: ValueError: invalid literal for int() with base 10: 'abc'. No variable name. No hint about what a valid value looks like. You grep the codebase for PORT to even find where the read happened.
specenv is a zero-runtime-dependency Python library for typed environment variable loading — casting, validation, schema grouping, prefix namespacing. All of that is useful, but none of it is the actual design decision worth writing about. The decision that shaped everything else was: every error must name the variable and say how to fix it, unconditionally, with no opt-out.
Decision 1: The error message is generated at the failure site, not templated afterward
It would be easy to build one generic EnvCastError(var_name, raw_value, target_type) and format a message from those three fields in __str__. specenv doesn't do that — each cast failure builds its own message inline, at the point where the specific failure is known:
if cast_type is int:
try:
return int(raw)
except ValueError:
raise EnvCastError(
f'Cannot cast {name}={raw!r} to int.\n'
f' → Set {name} to a valid integer (e.g. {name}=8080)'
) from None
if cast_type is bool:
...
raise EnvCastError(
f'Cannot cast {name}={raw!r} to bool.\n'
f' → Set {name} to one of: 1/0, true/false, yes/no, on/off'
)
The generic version would produce "Cannot cast PORT='abc' to int" and stop there. The inline version gets to add (e.g. PORT=8080) for ints, 1/0, true/false, yes/no, on/off for bools, a namespaced hint for prefixed variables — because at the point of failure, you know exactly what a correct value looks like for that type, and a generic formatter three calls up the stack doesn't. The cost is a few lines of duplication across _caster.py's type branches. That's a fair trade for every single error message being genuinely actionable instead of generically accurate.
Decision 2: Missing-and-required collapses to the same error type as bad-cast
if raw is None:
if var.required or var.default is _MISSING:
errors.append(EnvCastError(
f"Required variable {name} is not set.\n"
f" → Add {name} to your environment or .env file."
))
A separate EnvMissingError would be the conventional choice — missing and malformed are conceptually different failures. specenv treats them as the same EnvCastError deliberately: from the caller's perspective, both mean "I cannot produce a usable value for this variable," and code that wraps Schema.load() in a single try/except EnvCastError handles both without needing to know there are two exception types to catch. The library only introduces a second exception class, EnvValidationError, for a genuinely different failure mode — a value that cast successfully but failed a caller-supplied predicate. That's the right place to split: not "how did it fail" but "does the caller need to react differently."
Decision 3: Multi-field validation batches errors instead of failing on the first one
Schema.load() walks every declared Var field and, when several are broken at once, doesn't stop at the first failure:
errors: list[Exception] = []
for klass in cls.__mro__:
for attr_name, val in vars(klass).items():
...
try:
value = cast_value(name, raw, var.cast_type)
except EnvCastError as exc:
errors.append(exc)
continue
...
if errors:
if len(errors) == 1:
raise errors[0]
combined = "\n".join(str(e) for e in errors)
raise EnvCastError(f"Multiple configuration errors:\n{combined}")
Fail-on-first-error is the obvious implementation and the wrong one for this use case: environment misconfiguration on a fresh deploy is rarely a single missing variable. It's usually three or four, because someone copied a .env.example that's stale. Failing on the first one means the deploy loop is: fix PORT, redeploy, discover DB_URL is also missing, redeploy, discover TIMEOUT doesn't parse. Batching every failure into one report means one redeploy fixes everything the schema can see wrong. The single-error case still raises that error directly rather than wrapping it — no "Multiple configuration errors: " noise when there's genuinely only one thing wrong.
Decision 4: Var is a descriptor, and the schema instance is built with __new__, not __init__
class Var:
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
def __get__(self, obj: Any, objtype: type | None = None) -> Any:
if obj is None:
return self
try:
return obj.__dict__[self.name]
except KeyError:
if self.default is not _MISSING:
return self.default
raise AttributeError(self.name) from None
instance = cls.__new__(cls)
...
instance.__dict__[attr_name] = value
Using the descriptor protocol (__set_name__ / __get__ / __set__) rather than a metaclass or __init_subclass__ hook keeps Schema subclasses looking like plain dataclasses at the point of declaration — PORT = Var(int, default=8080) reads exactly like a type annotation with a default, no special base-class machinery visible in the subclass body. Building the populated instance via cls.__new__(cls) instead of calling __init__ sidesteps a chicken-and-egg problem: if Schema.__init__ tried to read the environment itself, subclasses could never add their own __init__ for other purposes, and testing with an injected env dict would mean either mutating os.environ around the constructor or threading the dict through a constructor argument that every subclass would need to forward. load() as a classmethod that builds the instance directly keeps the constructor free for subclasses to use however they want, and keeps the "where does this data come from" question answered in exactly one place.
What I'd take away
- Generate the specific error at the point where you know the specific failure, not a generic one afterward. The few lines of duplication across cast branches buy every message a concrete, correct fix suggestion instead of a generic restatement of the problem.
-
Split exception types by how the caller needs to react, not by how the failure occurred. Missing and malformed both mean "no usable value" to a caller wrapping
load()in onetry/except— validation failure is genuinely different, so it gets its own type. - Batch validation errors when the realistic failure mode is "several things are wrong at once." Fail-fast is the right default almost everywhere except config loading at deploy time, where it just means more redeploy round-trips to discover the same list of problems one at a time.
The full library — casting, Schema/Var, namespacing, the test suite — is at github.com/abhijatchaturvedi/envspec, and on PyPI as pip install specenv. Try triggering three failures in the same schema at once — a missing required var, a bad int, and a validation failure — and you'll get one combined report instead of three separate deploy attempts.
Top comments (0)