Two decisions, each of which quietly corrupts somebody’s configuration when it goes the other way.
line = 'password = a=b=c'
key, value = line.split('=') # ValueError: too many values
line.split('=', 1) # ['password ', ' a=b=c']
line.partition('=') # ('password ', '=', ' a=b=c')
# A hash is only a comment at the start of a line
url = 'url = https://example.com/#section'
url.split('#')[0] # -> 'url = https://example.com/'
# the anchor is gone, and nothing said so
- partition always returns three pieces
split('=', 1) gives one piece when there is no separator and two when there is, so the caller has to check the length before unpacking. partition gives exactly three every time — before, the separator itself, and after — and the middle one is empty when nothing matched. When you want 'split at the first occurrence' and a predictable shape, partition is the one that does not need a length check.
- Comment characters are ordinary characters everywhere else
Stripping from the first # anywhere in the line eats URL fragments, colour codes, passwords and anything else containing a hash — and it does it silently, producing a value that is almost right. Real INI parsers differ on whether inline comments exist at all, which is exactly why the contract has to say. Deciding 'only at the start' makes the rule one comparison and never surprising.
- The current section is the parser’s whole memory
Every line is interpreted in the light of the last section header seen, so one variable outside the loop turns a list of independent lines into a structure. Its starting value of None is doing real work too: it is what makes 'a setting before any section' detectable rather than something that quietly lands in a section called ''.
Walk the lines one at a time and decide what each one is: blank, comment, section header, or setting. Anything left over is an error. One variable outside the loop remembers which section you are in.
Strip each line first, then test in order: empty, starts with # or ;, starts with [ and ends with ], contains an =. Keep current as the dict for the section you are filling, starting at None so a setting arriving too early is detectable.
sections.setdefault(name, {}) is what makes a repeated header add to the existing section instead of replacing it — and it also creates the empty section for a header with no settings under it. Use partition('=') rather than split, so a value containing = survives.
def read_ini(text): sections = {} current = ____ for line in text.splitlines(): line = line.strip() if not line or line[0] in '____': continue if line.startswith('[') and line.endswith(']'): name = line[1:-1].strip().lower() current = sections.____(name, {}) continue if '=' not in line: raise ____(f’cannot read line: {line!r}') if current is ____: raise ValueError(f’setting before any section: {line!r}') key, _, value = line.____('=') current[key.strip().____()] = value.strip() return sections
def read_ini(text):
sections = {}
current = None
for line in text.splitlines():
line = line.strip()
if not line or line[0] in "#;":
continue
if line.startswith("[") and line.endswith("]"):
name = line[1:-1].strip().lower()
# setdefault, so a section named twice is added to rather
# than replaced — and an empty section still exists.
current = sections.setdefault(name, {})
continue
if "=" not in line:
raise ValueError(f"cannot read line: {line!r}")
if current is None:
raise ValueError(f"setting before any section: {line!r}")
key, _, value = line.partition("=")
current[key.strip().lower()] = value.strip()
return sections
Keeping the section’s dictionary rather than its name means the setting line is one assignment instead of a lookup followed by an assignment, and there is no way to write into a section that was never created. It works because setdefault hands back the same object that is in sections — writing through current and reading from sections are the same dictionary.
Blank, comment, header, setting, error — each test is only reached once the ones above it have failed, so each can be simpler than it looks. line[0] is safe because the empty case was handled first; the setting branch needs no bracket check because headers were taken already. A parser is mostly an ordered list of 'what else could this be'.
port is '8000' and debug is 'yes'. Converting here would mean guessing what each setting means, and guessing wrong for the one whose value happens to look numeric — a version number, a zip code, a leading-zero identifier. Parsing and interpreting are separate jobs; keep the parser ignorant and let the code that knows the schema convert.
Use configparser. It is in the standard library, it handles interpolation, a DEFAULT section, multi-line values, case rules and the several INI dialects in the wild, and it has been fixing edge cases for twenty years. Write this only to understand what a line-oriented parser with state looks like — or when you need a strict subset with exactly the rules you chose, which is occasionally worth it for a config file that is read by more than one language. For anything new, TOML is the better format and tomllib is built in from Python 3.11.
- Support a value continued on the next line when it is indented, and notice how much the loop has to change to look ahead.
- Keep the line number with each error and each setting, so a config validator downstream can point at the offending line.
- Add a DEFAULT section whose values are inherited by every other section, and decide whether inheritance happens at read time or at lookup time.