tomllib (3.11) parses TOML with no third-party dependency. It is a small
module with a small API and one large lesson attached.
import tomllib
with open("config.toml", "rb") as f: # note: BINARY mode
document = tomllib.load(f)
Binary mode is required. TOML is defined to be UTF-8, and the parser
handles decoding itself. Opening in text mode raises TypeError, which is
mildly annoying and entirely correct: it removes the possibility of the file
being decoded with the platform’s locale encoding, so a config file that works
on your laptop and fails in a container is one class of bug that simply cannot
happen here. tomllib.loads takes a str for when you already have the text.
There is no writing support, deliberately. If you need to emit TOML, use
tomli-w (plain) or tomlkit (preserves comments and formatting).
Two version notes: 3.14 gives TOMLDecodeError structured lineno and
colno attributes rather than only a message, and 3.15 upgrades the parser
to TOML 1.1.0 — so a document that is valid on 3.15 may fail to parse on 3.14.
If you support both, do not use 1.1.0 features.
The actual lesson
document = tomllib.load(f) # dict[str, Any]
Everything you take out of that dict is Any. Any is not “unknown” to a
type checker — it is “assume this is whatever you need it to be”, so it
propagates silently through your entire program and switches checking off
wherever it lands.
def get_port(document: dict[str, Any]) -> int:
return document["server"]["port"] # no error from the return itself
This is where --warn-return-any (included in --strict) earns its keep: it
is the one flag that fires on returning an Any from a function declared to
return something specific, and it is the cleanest demonstration in the whole
course of --strict catching a real bug rather than a style issue. The
function claims to return an int. Nothing has checked that. A config with
port = "8080" produces a str that flows into your socket call and fails
somewhere else entirely.
💡--warn-return-any catches return document["server"]["port"].
click to reveal
Why does cast(int, document["server"]["port"]) silence it, and why is that
worse than a bug?
cast is an unchecked assertion. It generates no runtime code at all and
simply instructs the checker to believe you. Silencing the warning that way
converts “the checker is telling me this is unverified” into “the checker has
been told to stop telling me”, with the same unverified value flowing onward.
It is worse than the original bug because the original at least left evidence.
A future reader sees cast(int, ...) and reasonably assumes someone verified
it — casts are how you communicate “I know something the checker does not”. A
cast that encodes a hope rather than a fact is a lie in the source.
The honest fix is a runtime check that narrows:
port = server.get("port")
if isinstance(port, bool) or not isinstance(port, int):
raise ConfigError("server.port", "expected integer")
After that isinstance, mypy knows port is an int because it has seen the
proof. Use cast only where you genuinely have information the checker
cannot — never to quiet a warning about untrusted data.
What the boundary function looks like
@dataclass(frozen=True, slots=True)
class ServerConfig:
host: str
port: int
workers: int = 4
debug: bool = False
def load_config(stream: BinaryIO) -> ServerConfig:
... # parse, validate each field, raise ConfigError with field + reason
The whole program past this point deals in ServerConfig, where port is
provably an int. The Any stops here, in one function, whose job is
precisely to convert untrusted data into a typed value or an error.
Note the isinstance(port, bool) check in the snippet above. bool is a
subclass of int in Python, so a TOML port = true passes isinstance(x, int)
and gives you True where you expected a port number. Any integer validation
at a boundary needs the bool exclusion.
💡Your ConfigError carries a field name and a reason. Why not
click to reveal
just let the KeyError or TypeError propagate?
Because the audience for a config error is an operator, not a developer.
KeyError: 'host' names a dict key, in a traceback through your parsing code,
with no indication of which file, which table, or what a valid value looks
like. The operator’s next step is to read your source. ConfigError("server.host", "missing")
names the thing they have to fix in the vocabulary of the file they wrote.
There is a structural reason too: a raw KeyError is indistinguishable from a
KeyError raised by a bug in your own parsing logic. Catching it at the call
site means catching both, so a typo in your code reports as a user
configuration problem. A dedicated exception type separates “the config is
wrong” from “the parser is wrong”, which is exactly the distinction the person
reading the log needs.