On Your Own: A Fresh Brief

The Course
•
September 24, 2026
•
9 min read

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.

On your own

A settings library

Requirements

  1. A user declares settings as a class: typed fields, optional defaults, optional checks, and a prefix shared by the class’s variables.
  2. load(env) takes any mapping of text to text — such as os.environ — and returns an instance whose attributes have the declared types.
  3. Text is converted to the declared type. Note that bool("false") is True in Python: a boolean setting must understand words like true, false, yes and no.
  4. 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.
  5. Every problem is reported at once, in one error that is a ValueError and carries the list of problems. And it’s all or nothing: when load raises, no half-built object exists.
  6. Loaded settings are read-only.
  7. 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.
  8. 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 setting
settings.py Python
import 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. load keeps 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.
  • -> Self on load. Annotated as returning Settings, every caller would lose the subclass: ServerSettings.load(env).workers would be an error to mypy. Day 3’s rule, applied.
  • Writing the slots directly in load looks 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. SettingsError is for whoever set the environment; DefinitionError is for whoever wrote the class, and it’s a TypeError because 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:

  1. 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.
  2. 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?
  3. Would your tests fail? Break each rule on purpose — report only the first problem, accept "false" as True — and watch the test go red.
  4. 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.

Last updated on September 24, 2026

Was this article helpful?

Your response is saved on this device.