All week the ORM grew to a plan, with tests written for each step. This page takes the plan away. A different library, a blank file, and the design decisions are yours: what the API looks like, what it refuses, when it refuses it, and what it promises.
The rules
Allowed: the hints, every lesson in all three courses, the Python documentation, and an AI assistant used for explanations and hints — checked, then followed by doing it yourself.
Not allowed: pasting in a finished library, from anywhere. Nobody is watching; the point is to find out what you can design on your own.
Take as many sittings as it needs, and run mypy --strict over it locally
when you’re done.
The brief
Every service reads settings from its environment: APP_PORT=8080, and so on.
Each team writes the same fragile code — int(os.environ["APP_PORT"]) — and
finds out about a typo in production, one error at a time. You’re writing the
small library that ends that.
A settings library
Requirements
- A user declares settings as a class: typed fields, optional defaults, optional checks, and a prefix shared by the class’s variables.
load(env)takes any mapping of text to text — such asos.environ— and returns an instance whose attributes have the declared types.- Text is converted to the declared type. Note that
bool("false")isTruein Python: a boolean setting must understand words liketrue,false,yesandno. - A missing setting with no default, a value that can’t be converted, and a value its check refuses are all problems. So is a variable that starts with the prefix but matches no setting — that’s almost always a typo.
- Every problem is reported at once, in one error that is a
ValueErrorand carries the list of problems. And it’s all or nothing: whenloadraises, no half-built object exists. - Loaded settings are read-only.
- A mistake in the settings class itself — a default that fails its own check — is caught when the class is defined, not when it’s first loaded.
- Write tests for each rule, and a short limitations section in the module docstring: what this library deliberately doesn’t do.
A declaration to support — yours can differ in the details:
class ServerSettings(Settings, prefix="APP_"):
host = Setting(str, default="localhost")
port = Setting(int, default=8000, check=lambda port: 0 < port < 65536)
debug = Setting(bool, default=False)
workers = Setting(int, check=lambda n: n >= 1)Sample output for GOOD and BAD in the playground. Your wording can
differ; which problems you find shouldn’t.
ServerSettings(host='0.0.0.0', port=8080, debug=False, workers=4)
4 problems:
APP_PORT: 80800 is not allowed
APP_DEBUG: 'maybe' is not a bool
APP_WORKERS: missing
APP_WORKRES: unknown settingimport sys
import unittest
from collections.abc import Callable, Mapping
from typing import Any, Self
GOOD = {"APP_HOST": "0.0.0.0", "APP_PORT": "8080", "APP_WORKERS": "4", "PATH": "/usr/bin"}
BAD = {
"APP_HOST": "0.0.0.0",
"APP_PORT": "80800",
"APP_DEBUG": "maybe",
"APP_WORKRES": "4",
"PATH": "/usr/bin",
}
# Your library, your ServerSettings, your tests.
#
# To run a TestCase in this playground:
# suite = unittest.defaultTestLoader.loadTestsFromTestCase(YourTests)
# unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)Hint 1: the idea
You’ve built every piece of this already, in another costume. A typed field
that knows its own name is a descriptor with __set_name__ (Day 2), and a
read-only one is a descriptor whose __set__ refuses. Checking the class when
it’s defined is __init_subclass__ (Day 2). “Every problem at once, and all or
nothing” is Day 4: collect problems in a list while nothing is built yet, and
raise one error carrying all of them.
Hint 2: a first step
Start with the smallest vertical slice: a Setting descriptor, a Settings
base whose __init_subclass__ collects the fields, and a load that handles
only str settings with no problems at all. Test that load(GOOD).host works.
Then conversions, then problems, one rule and one test at a time. Keep asking
Day 3’s question of load’s return type: what should
ServerSettings.load(...) be to a type checker?
One way to do it — open after you've finished
Compare with yours after you have something working. It’s one reasonable design, not the answer.
MISSING = object()
TRUE = {"true", "1", "yes", "on"}
FALSE = {"false", "0", "no", "off"}
class SettingsError(ValueError):
"""Every problem found while loading, reported together."""
def __init__(self, problems: list[str]) -> None:
self.problems = list(problems)
count = len(self.problems)
listed = "\n".join(f" {problem}" for problem in self.problems)
super().__init__(f"{count} problem{'s' if count != 1 else ''}:\n{listed}")
class DefinitionError(TypeError):
"""A mistake in a Settings class itself, found when the class is defined."""
def convert(kind: type, text: str) -> Any:
"""Text from the environment -> the declared type. bool("false") is True,
so bool gets words, not Python's truthiness."""
if kind is bool:
word = text.strip().lower()
if word in TRUE:
return True
if word in FALSE:
return False
raise ValueError(f"{text!r} is not a bool")
try:
return kind(text)
except ValueError:
raise ValueError(f"{text!r} is not a {kind.__name__}") from None
class Setting:
"""One typed setting. Read-only once loaded."""
def __init__(self, kind: type, *, default: Any = MISSING,
check: Callable[[Any], bool] | None = None) -> None:
self.kind, self.default, self.check = kind, default, check
def __set_name__(self, owner: type, name: str) -> None:
self.name, self.slot = name, f"_{name}"
def __get__(self, instance: object | None, owner: type | None = None) -> Any:
if instance is None:
return self
return instance.__dict__[self.slot]
def __set__(self, instance: object, value: Any) -> None:
raise AttributeError(f"{self.name} is read-only; load new settings instead")
def allows(self, value: Any) -> bool:
return self.check is None or self.check(value)
class Settings:
prefix = ""
fields: dict[str, Setting] = {}
def __init_subclass__(cls, prefix: str = "", **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
cls.prefix = prefix
cls.fields = {name: value
for klass in reversed(cls.__mro__)
for name, value in vars(klass).items()
if isinstance(value, Setting)}
for name, setting in cls.fields.items():
if setting.default is not MISSING and not setting.allows(setting.default):
raise DefinitionError(
f"{cls.__name__}.{name}: default {setting.default!r} fails its own check")
@classmethod
def load(cls, env: Mapping[str, str]) -> Self:
"""Build settings from env, or raise SettingsError listing every problem."""
values: dict[str, Any] = {}
problems: list[str] = []
for name, setting in cls.fields.items():
key = cls.prefix + name.upper()
if key not in env:
if setting.default is MISSING:
problems.append(f"{key}: missing")
else:
values[name] = setting.default
continue
try:
value = convert(setting.kind, env[key])
except ValueError as error:
problems.append(f"{key}: {error}")
continue
if not setting.allows(value):
problems.append(f"{key}: {value!r} is not allowed")
continue
values[name] = value
known = {cls.prefix + name.upper() for name in cls.fields}
if cls.prefix:
problems += [f"{key}: unknown setting" for key in sorted(env)
if key.startswith(cls.prefix) and key not in known]
if problems:
raise SettingsError(problems) # nothing was built: all or nothing
settings = object.__new__(cls)
for name, value in values.items():
# Every value was converted and checked above; the descriptor is
# read-only, so its slot is written directly, once, here.
settings.__dict__[cls.fields[name].slot] = value
return settings
def __repr__(self) -> str:
shown = ", ".join(f"{name}={getattr(self, name)!r}" for name in self.fields)
return f"{type(self).__name__}({shown})"
class ServerSettings(Settings, prefix="APP_"):
host = Setting(str, default="localhost")
port = Setting(int, default=8000, check=lambda port: 0 < port < 65536)
debug = Setting(bool, default=False)
workers = Setting(int, check=lambda n: n >= 1)
class SettingsTests(unittest.TestCase):
def test_good_settings_load_with_their_types(self) -> None:
loaded = ServerSettings.load(GOOD)
self.assertEqual((loaded.host, loaded.port, loaded.debug, loaded.workers),
("0.0.0.0", 8080, False, 4))
def test_the_word_false_is_false(self) -> None:
self.assertIs(ServerSettings.load({**GOOD, "APP_DEBUG": "false"}).debug, False)
self.assertIs(ServerSettings.load({**GOOD, "APP_DEBUG": "YES"}).debug, True)
def test_every_problem_is_reported_at_once(self) -> None:
with self.assertRaises(SettingsError) as caught:
ServerSettings.load(BAD)
self.assertEqual(caught.exception.problems, [
"APP_PORT: 80800 is not allowed",
"APP_DEBUG: 'maybe' is not a bool",
"APP_WORKERS: missing",
"APP_WORKRES: unknown setting",
])
def test_a_settings_error_is_a_value_error(self) -> None:
with self.assertRaises(ValueError):
ServerSettings.load({})
def test_other_variables_are_ignored(self) -> None:
self.assertEqual(ServerSettings.load(GOOD).workers, 4) # PATH is in GOOD
def test_loaded_settings_are_read_only(self) -> None:
loaded = ServerSettings.load(GOOD)
with self.assertRaises(AttributeError):
loaded.port = 1
def test_a_bad_default_is_caught_when_the_class_is_defined(self) -> None:
with self.assertRaises(DefinitionError):
class Broken(Settings, prefix="X_"):
port = Setting(int, default=0, check=lambda port: port > 0)
print(ServerSettings.load(GOOD))
try:
ServerSettings.load(BAD)
except SettingsError as error:
print(error)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SettingsTests)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)Some choices worth comparing with yours:
- Problems are collected, not raised.
loadkeeps going after the first one, and only builds the object once there are none — so all or nothing falls out of the order of operations, with no cleanup needed. -> Selfonload. Annotated as returningSettings, every caller would lose the subclass:ServerSettings.load(env).workerswould be an error to mypy. Day 3’s rule, applied.- Writing the slots directly in
loadlooks like Day 3’s bug hunt — the fast path that skipped validation — and would be, anywhere else. Here every value has just been converted and checked, and the descriptor is read-only on purpose. The comment says so, because the next reader will wonder. - Two error types for two audiences.
SettingsErroris for whoever set the environment;DefinitionErroris for whoever wrote the class, and it’s aTypeErrorbecause it’s a mistake in code, not in data.
Judge your own work
A library is judged by the people who call it. Go back to yours and answer these in writing:
- What can a caller rely on? Write the guarantees as a list — types, all problems at once, all or nothing, read-only — and point at the test that pins each one. A guarantee with no test is a hope.
- When is each mistake caught? For each kind of mistake, is it found at definition time, load time, or never? Is that the earliest it could be found?
- Would your tests fail? Break each rule on purpose — report only the
first problem, accept
"false"asTrue— and watch the test go red. - What doesn’t it do? Read your limitations section as a stranger would. Does it name what they’ll trip over — nested settings, lists, secrets in error messages — before they do?
Your next project
Three directions, each a real library-design problem. No solutions.
Publish this. Give the settings library a pyproject.toml, type hints that
pass mypy --strict, a README with a limitations table and real output, and
install it into another project of yours. Using your own API is the fastest way
to find what’s awkward about it.
A retrying client with honest errors. Wrap an HTTP library in a small client with an exception hierarchy grouped by how callers will handle failures, a retry that only retries what retrying can fix, bounded concurrency, and tests that count calls rather than time them.
A query builder that can’t be misused. Build where() and order_by() that
return new objects, keep every value in placeholders, accept column names only
from an allow-list, and fail at definition time for unknown columns.