Extract Functions, One Step at a Time

The Course
•
September 18, 2026
•
8 min read

With the safety net in place, you can change the script with confidence, as long as you change it in small steps. The rule for the whole lesson is:

Make one small change. Run the tests. Repeat.

If the tests fail, the cause is in the last change, and the last change was small. You never have to hunt through an hour of edits for the one that broke the output.

The move: Extract Function

Nearly all of today is one move, repeated:

  1. Find a few lines that do one thing you can name.
  2. Move them into a function with that name.
  3. Whatever the lines read becomes a parameter. Whatever they produce becomes the return value.
  4. Call the function where the lines used to be.
  5. Run the tests.

Here’s the first one. Reading the file is three lines at the top of the script plus the blank-line check inside the loop. Together they do one nameable thing, “get the non-blank lines of a file”:

# before
f = open("sample.log")
data = f.read()
f.close()
...
for l in data.split("\n"):
    if l.strip() == "":
        continue
    ...
# after
def read_lines(path):
    """Return the non-blank lines of a log file."""
    with open(path) as file:
        return [line for line in file.read().splitlines() if line.strip()]

...
for l in read_lines("sample.log"):
    ...

Run the tests. They pass, so read_lines did exactly what the old lines did, for this file at least. It’s also the first place a reader can learn what this part does without reading how.

Name the magic numbers

w[2] and w[3][:-1] appear in the loop with no explanation. A function with a name is the explanation:

def level_of(line):
    """The level is the third word of a line."""
    return line.split()[2]


def source_of(line):
    """The source is the fourth word, minus its last character (the colon)."""
    return line.split()[3][:-1]

source_of still uses [:-1]. rstrip(":") would be nicer, but it’s a different behaviour: they disagree on a word with no colon. That’s a fix, and fixes come after the refactor, as their own step with their own test. For now, keep it identical and note it.

The counting loops

The loop is doing two jobs, counting levels and counting error sources. Give each its own function, with the whole list as the parameter and a dict as the result:

def count_levels(lines):
    counts = {}
    for line in lines:
        level = level_of(line)
        counts[level] = counts.get(level, 0) + 1
    return counts

Splitting one loop into two means reading the lines twice. For a log that fits in memory that costs nothing worth measuring, and in return each function can be tested alone. (Day 2 comes back to this trade-off for logs that don’t fit in memory.)

A main function, and the main guard

What’s left at the top level is the part that decides what happens: read this file, print these three lines. Put that in a main function too, behind the guard you met in the beginner course:

def main():
    lines = read_lines("sample.log")
    print("Lines read:", len(lines))
    print("Levels:", count_levels(lines))
    print("Errors by source:", errors_by_source(lines))


if __name__ == "__main__":
    main()

This is what makes the file testable piece by piece. A test does import report to call count_levels. Without the guard, that import runs the whole script, and it reads a file and prints a report in the middle of your test run. With it, importing only defines the functions. The safety net still works, because it runs the file with run_name="__main__", just like python report.py.

Bug hunt: the test that got the wrong answer

Here’s someone’s refactor, halfway done. The safety net passes. They’ve added two unit tests with a two-line input, and one of them fails:

Halfway through a refactor Python
"""report.py, halfway through the refactor.""" def read_lines(path): with open(path) as file: return [line for line in file.read().splitlines() if line.strip()] def level_of(line): return line.split()[2] def count_levels(lines): counts = {} for line in lines: level = level_of(line) counts[level] = counts.get(level, 0) + 1 return counts def errors_by_source(log_lines): counts = {} for line in lines: if level_of(line) == "ERROR": source = line.split()[3][:-1] counts[source] = counts.get(source, 0) + 1 return counts lines = read_lines("sample.log") print("Lines read:", len(lines)) print("Levels:", count_levels(lines)) print("Errors by source:", errors_by_source(lines)) import io import runpy import unittest from contextlib import redirect_stdout from report import count_levels, errors_by_source EXPECTED = """\ Lines read: 6 Levels: {'INFO': 3, 'WARNING': 1, 'ERROR': 2} Errors by source: {'payments': 1, 'search': 1} """ LINES = [ "2026-09-01 09:14:02 INFO auth: user=ada action=login", "2026-09-01 09:15:30 ERROR payments: order=1043 msg=\"card declined\"", ] def run_script(path): output = io.StringIO() with redirect_stdout(output): runpy.run_path(path, run_name="__main__") return output.getvalue() class TestReport(unittest.TestCase): def test_output_is_unchanged(self): self.assertEqual(run_script("report.py"), EXPECTED) class TestPieces(unittest.TestCase): def test_count_levels(self): self.assertEqual(count_levels(LINES), {"INFO": 1, "ERROR": 1}) def test_errors_by_source(self): self.assertEqual(errors_by_source(LINES), {"payments": 1}) import sys import unittest import test_report suite = unittest.defaultTestLoader.loadTestsFromModule(test_report) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) 2026-09-01 09:14:02 INFO auth: user=ada action=login 2026-09-01 09:14:07 WARNING payments: order=1042 amount=19.99 retry=1 2026-09-01 09:15:30 ERROR payments: order=1043 msg="card declined" 2026-09-01 09:16:11 INFO auth: user=alan action=logout 2026-09-01 09:17:45 ERROR search: query="python books" msg="index offline" 2026-09-01 09:18:02 INFO search: query=dune results=4

Reproduce. Already reproduced: test_errors_by_source fails, and the input is just two lines.

Inspect. Two things in the output don’t add up. First, the failure itself: two lines went in, one of them an error from payments, and the answer mentions search, which isn’t in the input at all. It is in sample.log, though. Second, the report is printed at the top of the output, before any test ran. The tests never call the script, so what printed it?

Narrow. The second clue explains the first. from report import ... runs report.py from top to bottom, and there’s no main guard, so the script part runs too. That creates a module-level variable lines holding every line of sample.log. Now read errors_by_source carefully:

def errors_by_source(log_lines):
    counts = {}
    for line in lines:          # not log_lines

The parameter is log_lines, but the loop reads lines. There’s no local lines, so Python looks in the module, finds the one the script created, and quietly counts sample.log instead of its argument. The safety net couldn’t catch it, because in the full script the global and the argument happen to be the same list.

Pin it. test_errors_by_source already is the regression test. It passes a list that is not sample.log, which is exactly what catches a function reading the wrong data.

Fix. Two changes, and both matter:

  1. Loop over the parameter: for line in log_lines, or rename the parameter to lines.
  2. Move the script part into main() behind the main guard, so importing the module creates no lines variable for a function to find by accident.

After the first fix alone the test passes. The second makes this whole class of bug impossible: a function with no global to fall back on fails loudly with NameError instead of silently using the wrong data.

A function should read only its parameters

A function that reads a module-level variable gives different answers depending on what happened before it was called. Pass data in through parameters and out through the return value, and each function can be tested with any input you like.

Your turn

Exercise

Refactor report.py

Here’s the original script again, with its safety net and a set of unit tests for the functions it should have. The tests are the spec: they call report.read_lines, report.level_of, report.source_of, report.count_levels and report.errors_by_source, and they check that importing report prints nothing.

Right now one test passes (the safety net) and five fail. Refactor report.py step by step, running the tests after every step, until all six pass. The safety net must pass at every step along the way. If it ever fails, undo your last step rather than pushing on.

# report.py - counts what happened in a log. It works. Nobody dares touch it. f = open("sample.log") data = f.read() f.close() c = {} e = {} n = 0 for l in data.split("\n"): if l.strip() == "": continue n = n + 1 w = l.split() if w[2] in c: c[w[2]] = c[w[2]] + 1 else: c[w[2]] = 1 if w[2] == "ERROR": s = w[3][:-1] if s in e: e[s] = e[s] + 1 else: e[s] = 1 print("Lines read:", n) print("Levels:", c) print("Errors by source:", e) import importlib import io import runpy import unittest from contextlib import redirect_stdout import report EXPECTED = """\ Lines read: 6 Levels: {'INFO': 3, 'WARNING': 1, 'ERROR': 2} Errors by source: {'payments': 1, 'search': 1} """ LINES = [ "2026-09-01 09:14:02 INFO auth: user=ada action=login", "2026-09-01 09:15:30 ERROR payments: order=1043 msg=\"card declined\"", "2026-09-01 09:17:45 ERROR search: query=\"python books\"", ] def run_script(path): """Run a script as if from the command line, and return what it printed.""" output = io.StringIO() with redirect_stdout(output): runpy.run_path(path, run_name="__main__") return output.getvalue() class TestReport(unittest.TestCase): def test_output_is_unchanged(self): self.assertEqual(run_script("report.py"), EXPECTED) def test_importing_prints_nothing(self): output = io.StringIO() with redirect_stdout(output): importlib.reload(report) self.assertEqual(output.getvalue(), "") class TestPieces(unittest.TestCase): def test_read_lines_skips_blank_lines(self): self.assertEqual(len(report.read_lines("sample.log")), 6) def test_level_and_source(self): self.assertEqual(report.level_of(LINES[0]), "INFO") self.assertEqual(report.source_of(LINES[1]), "payments") def test_count_levels(self): self.assertEqual(report.count_levels(LINES), {"INFO": 1, "ERROR": 2}) def test_errors_by_source(self): self.assertEqual(report.errors_by_source(LINES), {"payments": 1, "search": 1}) import sys import unittest import test_report suite = unittest.defaultTestLoader.loadTestsFromModule(test_report) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) 2026-09-01 09:14:02 INFO auth: user=ada action=login 2026-09-01 09:14:07 WARNING payments: order=1042 amount=19.99 retry=1 2026-09-01 09:15:30 ERROR payments: order=1043 msg="card declined" 2026-09-01 09:16:11 INFO auth: user=alan action=logout 2026-09-01 09:17:45 ERROR search: query="python books" msg="index offline" 2026-09-01 09:18:02 INFO search: query=dune results=4
Hint 1: the idea

The tests are the spec: read test_report.py first to learn each function’s name, what it takes and what it returns. Then move the code that already does each job into a function with that name — one function per step, running the tests after each. You’re moving code, not writing new logic.

Hint 2: a first step

Start with read_lines(path): it takes a file name and returns the non-blank lines — the part of the script before the counting loop. Write it, use it where the reading used to happen, and run: the safety net should still pass, and one more test should turn green.

Show one solution
"""Count what happened in a log: the same report, rebuilt from small functions."""


def read_lines(path):
    """Return the non-blank lines of a log file."""
    with open(path) as file:
        return [line for line in file.read().splitlines() if line.strip()]


def level_of(line):
    """The level is the third word of a line."""
    return line.split()[2]


def source_of(line):
    """The source is the fourth word, minus its last character (the colon)."""
    return line.split()[3][:-1]


def count_levels(lines):
    counts = {}
    for line in lines:
        level = level_of(line)
        counts[level] = counts.get(level, 0) + 1
    return counts


def errors_by_source(lines):
    counts = {}
    for line in lines:
        if level_of(line) == "ERROR":
            source = source_of(line)
            counts[source] = counts.get(source, 0) + 1
    return counts


def main():
    lines = read_lines("sample.log")
    print("Lines read:", len(lines))
    print("Levels:", count_levels(lines))
    print("Errors by source:", errors_by_source(lines))


if __name__ == "__main__":
    main()

A good order to get there is the one in this lesson: read_lines first, then level_of and source_of, then the two counting functions, then main and the guard. The last step is the one that turns test_importing_prints_nothing green.

Notice what didn’t change: the output, character for character, including [:-1]. Every improvement here is to the structure. The next lesson makes the first deliberate change in behaviour.

What you can now do

  • Extract a function: name it, pass in what it reads, return what it produces, and run the tests.
  • Change code in steps small enough that a failing test points at one step.
  • Use main() and the main guard so a module can be imported without side effects.
  • Recognise a function reading a global instead of its parameter, and prevent it.

Next: splitting the script into modules, a trap in default arguments, and the Day 1 checkpoint.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.