Skip to main content

Working across files

Level 3: Level 3: Patternsmedium18 minmodulesimportsstring-parsingtype-coercion

Build a config parser across modules, using a read-only helper and real test files.

Why one file stops being enough

A script that does everything in one file is easy to start and painful to grow. You cannot reuse a function without copying it, you cannot test a rule in isolation, and every edit risks breaking something unrelated. Splitting code into modules (one .py file per responsibility) fixes this. A config parser is a clean example: one module knows how to turn a string into the right type, another knows how to walk lines of text. Each part stays small, testable, and imported only where it is needed.

What import actually does

Check yourself
Two different modules in your project both run 'from app.coerce import coerce'. app/coerce.py has a print() at the top of the file, outside any function. How many times does that line print in one run of the program?

import is not a copy-paste of code into your file. When Python first runs from app.coerce import coerce, it does three things:

  1. Finds app/coerce.py, creates a module object, and registers it in sys.modules so a repeat import reuses it instead of re-running the file.
  2. Executes the file top to bottom, once, populating that module object.
  3. Binds the name coerce into the current file's namespace.

The two import forms differ in the name you get:

import app.coerce              # call it as app.coerce.coerce(...)
from app.coerce import coerce  # call it as coerce(...)

Here parse_config imports the read-only coerce helper and calls it. The type rule lives in exactly one place, so fixing it fixes every caller.

Coercing a raw string to a type

Check yourself
coerce has to decide whether text is integer-looking. What does '-3'.isdigit() return?

Config values arrive as strings, so coerce has to decide whether a value is really an integer:

>>> "  hi ".strip()
'hi'
>>> "-3".isdigit()               # the "-" makes this False
False
>>> "-3".lstrip("-").isdigit()   # strip the sign first, then test
True
>>> int("-3")
-3

The rule is: trim the string, strip a leading - before calling isdigit, return int(value) when it looks integer, otherwise return the trimmed string. That is your Apply warm-up.

Parsing key = value lines

A config file is lines of key = value. The loop guards blanks and comments, then splits once:

for line in text.splitlines():
    stripped = line.strip()
    if not stripped or stripped.startswith("#"):
        continue
    key, value = stripped.split("=", 1)   # maxsplit=1
    # store key.strip() -> coerce(value.strip())

Given "# db\nhost = localhost\nport = 8080", this produces {"host": "localhost", "port": 8080}.

Check yourself
A config file contains the line 'url = http://x/?a=1'. Your loop does key, value = stripped.split('=') with no maxsplit argument. What happens on that line?

Pitfalls

  • Splitting without maxsplit. "url = a=b".split("=") returns three parts, so key, value = ... raises ValueError: too many values to unpack (expected 2). Passing 1 splits on the first = only and keeps any = inside the value intact.
  • Forgetting to trim the key. "host = localhost".split("=", 1) gives ["host ", " localhost"]. The value passes through coerce, which trims it, but the key does not. Store "host " (with the trailing space) as the key and a later config["host"] lookup raises KeyError instead of returning the value. Call .strip() on the key before storing it.

Interview nuance: bounded splitting is the detail interviewers probe when you parse text. stripped.split("=", 1) splits on the first = only, so a value that itself contains = (a URL like url = a=b, a base64 token, a connection string) stays intact, while a bare split("=") raises ValueError the instant a value holds a second delimiter. Pair that with deciding a value's type from the raw string (trim it, strip a leading sign, then test isdigit) and you are doing real boundary parsing: turning loose text into typed data without trusting its shape.

Check yourself

Sort each config line by what parse_config does with it.

name = Ada
# retries = 5
a line containing only spaces
DEBUG
url = a=b
port = 8080

Apply

Your turn

The task this lesson builds to.

Warm-up: implement coerce(raw).

Return an int when raw is integer-looking (all digits, optionally with a leading -), otherwise return the trimmed string. Examples: "42"42, "-3"-3, " hi ""hi".

3 hints and 4 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Now build the real thing. Implement parse_config(text) in app/config.py so it parses key = value lines into a dict, skipping blanks and # comments, splitting on the first =, trimming whitespace, and running each value through the read-only coerce helper. Open the visible test to see the expected behaviour; some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.