Traceback (most recent call last):
File "settings.py", line 18, in <module>
print(timeout_seconds(["# network", "timeout = 30", "retries = 3"]))
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "settings.py", line 15, in timeout_seconds
return settings["timeout"]
~~~~~~~~^^^^^^^^^^^
KeyError: 'timeout'Nothing to do with settings. A price list read from a file, with the same kind of invisible mistake, so you can see how to catch it.
prices = {"tea ": 2.5, "coffee": 3.0} # one stray space, from the file
print(prices) # {'tea ': 2.5, 'coffee': 3.0}
print(list(prices)) # ['tea ', 'coffee'] <- the space shows up here
print("tea" in prices) # False
print(prices["tea"])
# KeyError: 'tea'
- The message only shows your side
KeyError: 'tea' tells you what was asked for. It cannot tell you what was nearly right, so the next step is always to look at the keys that exist.
- Print the keys with their quotes
print(list(d)) shows every key as a string in quotes, so a trailing space, a capital letter or a stray newline is visible. print(d[key]) in a loop would hide exactly that.
- Fix it where the key is made
The tempting fix is to ask for 'tea ' instead. The right one is to strip the name when it goes into the dict, so every lookup afterwards can use the name as people write it.
The KeyError shows 'timeout', which is what the code asked for. What did read_settings actually put in the dict? Print list(settings).
"timeout = 30".split("=") gives ['timeout ', ' 30']. int() forgives the space in ' 30'; a dictionary key does not forgive the one in 'timeout '.
Strip each line before checking for blanks and comments, strip the name before using it as a key, and use .get with a default of 10 for the lookup.
line = line.strip() if not line or line.startswith("#"): continue name, value = line.split("=") settings[name.____()] = int(value) ... return read_settings(lines).get("timeout", ____)
def read_settings(lines):
"""'timeout = 30' lines -> {'timeout': 30, ...}"""
settings = {}
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
name, value = line.split("=")
settings[name.strip()] = int(value)
return settings
def timeout_seconds(lines):
"""The timeout setting, in seconds."""
return read_settings(lines).get("timeout", 10)
split("=") cuts exactly at the equals sign and keeps everything else, including the space before it. The KeyError message could not show that, because the space was in the key that was stored, not the one requested.
Stripping the whole line is what lets an indented comment be recognised; stripping the name is what makes the key match. int() already ignores spaces around a number, so the value needs nothing.
A missing timeout is ordinary, so it gets an ordinary default. The ValueErrors from a broken line are left alone on purpose: those are mistakes in the file, and hiding them behind a default would start the service with a setting nobody chose.
Do not write a settings parser at all if a standard format will do. configparser reads name = value files, strips the spaces and handles comments; tomllib reads TOML and knows numbers from text. A hand-written parser is worth it only for a format you do not control.
- Make read_settings raise a ValueError that says which line number was broken.
- Allow values that are not numbers, like mode = fast, and decide how a caller should ask for an int.
- Treat names case-insensitively, so Timeout = 30 counts, and say where that decision belongs.