Modules, Defaults and the Day 1 Checkpoint

The Course
•
September 18, 2026
•
8 min read

report.py now has two kinds of code in it. Most of it is rules: what a level is, how to count errors. One function, main, is the program: which file to read and what to print. They change for different reasons. The rules change when the log format changes. The program changes when someone wants a different report.

Rules in one module, the program in another

So give them a file each. The rules move to analyzer.py, and report.py shrinks to the program:

"""Print a summary of a log file: the Day 1 script, rebuilt from tested parts."""
from analyzer import count_levels, errors_by_source, read_lines


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


if __name__ == "__main__":
    main()

Now the unit tests import analyzer, and the safety net keeps running report.py. Anything else that wants to count log levels, like a web dashboard or tomorrow’s parser, can import analyzer without dragging a report along with it. main also gained a path parameter with a default, so the file name is no longer baked in.

The first deliberate fix

The refactor is done, and the safety net proved the output never changed. Now is the time for the [:-1] in source_of. It removes the last character whether or not it’s a colon, so a line with no colon after the source loses a letter.

A fix is a behaviour change, so it gets its own step, and the test comes first:

def test_source_without_a_colon(self):
    self.assertEqual(source_of("2026-09-01 10:00:00 ERROR   db timeout"), "db")

Run it before changing anything. It fails with 'd' != 'db'. That failure is the proof that the test detects the bug. Then change [:-1] to rstrip(":"), which only removes colons. The new test passes, and the safety net still passes, because every line in sample.log has its colon. You changed behaviour only where you meant to.

Bug hunt: the test that passes alone

Someone extended count_levels so it can add to counts you already have, which is handy for counting several days’ logs into one total. They used a default argument so the old calls keep working. Three tests, and one fails:

Adding to existing counts Python
import sys
import unittest


def count_levels(lines, counts={}):
    """Count levels, optionally adding to counts you already have."""
    for line in lines:
        level = line.split()[2]
        counts[level] = counts.get(level, 0) + 1
    return counts


MONDAY = ["2026-09-01 09:14:02 INFO    auth: user=ada"]
TUESDAY = ["2026-09-02 10:00:00 ERROR   db: timeout"]


class TestCountLevels(unittest.TestCase):
    def test_one_day(self):
        self.assertEqual(count_levels(MONDAY), {"INFO": 1})

    def test_adding_to_existing_counts(self):
        self.assertEqual(count_levels(TUESDAY, {"INFO": 1}), {"INFO": 1, "ERROR": 1})

    def test_another_day(self):
        self.assertEqual(count_levels(TUESDAY), {"ERROR": 1})


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestCountLevels)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)

Reproduce. test_one_day fails. Monday’s log has one INFO line, and the result also has an ERROR in it, from Tuesday’s log.

Inspect. Run the failing test on its own. Replace the suite = ... line with:

suite = unittest.TestSuite([TestCountLevels("test_one_day")])

It passes. A test that passes alone and fails with others is depending on something the others leave behind. unittest runs the tests in alphabetical order, so test_another_day ran first. What could it have left behind? The function holds no global variable. But it does have a default value:

print(count_levels.__defaults__)
count_levels(TUESDAY)
print(count_levels.__defaults__)

Before the call the default is ({},). After it, it’s ({'ERROR': 1},).

Narrow. A default value is created once, when def runs, and then shared by every call that doesn’t pass that argument. counts={} isn’t “a new empty dict each time”. It’s one particular dict, stored on the function, and counts[level] = ... changes it. Every call without counts adds to the same running total.

Pin it. The three tests already pin it, but only by accident of their order. Make it explicit: a test that calls the function twice and checks the second answer.

def test_calls_do_not_share_counts(self):
    count_levels(TUESDAY)
    self.assertEqual(count_levels(MONDAY), {"INFO": 1})

Fix. Use None as the default and create the dict inside the function, so every call gets a new one:

def count_levels(lines, counts=None):
    """Count levels, optionally adding to counts you already have."""
    if counts is None:
        counts = {}
    ...

This is the standard idiom, and you’ll see it everywhere: a mutable default (a list, dict or set) is almost always a bug.

Tests must not depend on their order

If a test passes alone but fails in the suite, or the other way round, something is shared between tests: a global, a class attribute, a file, a default argument. The beginner course’s setUp gives each test fresh state. The same rule applies to the code under test: it shouldn’t carry state from one call to the next unless that’s its whole purpose.

Day 1 checkpoint

Exercise

Refactor a sales script

A different script, the same job. orders.py prints a weekly sales summary from orders.txt. Refactor it into small functions, without changing its output.

The tests are the spec. They want:

  • parse_order(line) returning a tuple (date, customer, product, quantity, price), with quantity an int and price a float.
  • read_orders(path) returning the parsed orders, skipping blank lines.
  • revenue_by_customer(orders) returning {customer: total}.
  • best_seller(orders) returning (product, total_quantity).
  • Importing orders printing nothing, and running it printing exactly what it prints now.

Work the same way as this morning: one step, run the tests, repeat.

# orders.py - the weekly sales summary. Refactor me; the tests are the spec. f = open("orders.txt") rows = f.read().split("\n") f.close() tot = {} qty = {} for r in rows: if r.strip() == "": continue p = r.split(",") amt = int(p[3]) * float(p[4]) tot[p[1]] = tot.get(p[1], 0) + amt qty[p[2]] = qty.get(p[2], 0) + int(p[3]) best = None for k in qty: if best is None or qty[k] > qty[best]: best = k print("Revenue by customer:") for k in sorted(tot): print(" ", k, round(tot[k], 2)) print("Best seller:", best, qty[best]) import importlib import io import runpy import unittest from contextlib import redirect_stdout import orders EXPECTED = """\ Revenue by customer: ada 11.0 alan 20.0 grace 3.5 Best seller: pen 15 """ ORDERS = [ ("2026-09-01", "ada", "pen", 2, 0.5), ("2026-09-01", "alan", "pen", 1, 0.5), ("2026-09-02", "ada", "ink", 3, 2.0), ] def run_script(path): output = io.StringIO() with redirect_stdout(output): runpy.run_path(path, run_name="__main__") return output.getvalue() class TestScript(unittest.TestCase): def test_output_is_unchanged(self): self.assertEqual(run_script("orders.py"), EXPECTED) def test_importing_prints_nothing(self): output = io.StringIO() with redirect_stdout(output): importlib.reload(orders) self.assertEqual(output.getvalue(), "") class TestPieces(unittest.TestCase): def test_parse_order_converts_numbers(self): self.assertEqual(orders.parse_order("2026-09-01,ada,pen,2,0.50"), ("2026-09-01", "ada", "pen", 2, 0.5)) def test_read_orders_skips_blank_lines(self): self.assertEqual(len(orders.read_orders("orders.txt")), 5) def test_revenue_by_customer(self): self.assertEqual(orders.revenue_by_customer(ORDERS), {"ada": 7.0, "alan": 0.5}) def test_best_seller(self): self.assertEqual(orders.best_seller(ORDERS), ("pen", 3)) import sys import unittest import test_orders suite = unittest.defaultTestLoader.loadTestsFromModule(test_orders) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) 2026-09-01,ada,notebook,2,3.50 2026-09-01,alan,pen,10,0.80 2026-09-02,ada,pen,5,0.80 2026-09-02,grace,notebook,1,3.50 2026-09-03,alan,stapler,1,12.00
Hint 1: the idea

The same method as this morning: the spec is in the tests, and the safety net stays green at every step. The one real design decision is where text becomes numbers. Convert once, at the edge — when a line is parsed — and every function after that works with real ints and floats.

Hint 2: a first step

Write parse_order(line) and nothing else yet: split on commas, unpack the five parts, convert the quantity and price. Run the tests — its test should pass and the safety net should still pass. read_orders is then a short function that uses it.

Show one solution
"""The weekly sales summary, as small functions with one job each."""


def parse_order(line):
    """Turn 'date,customer,product,quantity,price' into a tuple with real numbers."""
    date, customer, product, quantity, price = line.split(",")
    return date, customer, product, int(quantity), float(price)


def read_orders(path):
    """Return every order in the file, skipping blank lines."""
    with open(path) as file:
        return [parse_order(line) for line in file.read().splitlines() if line.strip()]


def revenue_by_customer(orders):
    totals = {}
    for date, customer, product, quantity, price in orders:
        totals[customer] = totals.get(customer, 0) + quantity * price
    return totals


def best_seller(orders):
    """The product sold in the largest quantity, and that quantity."""
    sold = {}
    for date, customer, product, quantity, price in orders:
        sold[product] = sold.get(product, 0) + quantity
    best = max(sold, key=sold.get)
    return best, sold[best]


def main():
    orders = read_orders("orders.txt")
    print("Revenue by customer:")
    for customer, total in sorted(revenue_by_customer(orders).items()):
        print(" ", customer, round(total, 2))
    product, quantity = best_seller(orders)
    print("Best seller:", product, quantity)


if __name__ == "__main__":
    main()

A few choices worth comparing with yours:

  • Numbers are converted once, in parse_order. The original converted p[3] to int in two places. Parsing at the edge means nothing downstream handles text that should be a number.
  • Unpacking the tuple into named variables in each loop does the job the single-letter names never did: quantity * price explains itself.
  • max(sold, key=sold.get) replaces the hand-written “best so far” loop. On a tie both return the product that was seen first, so the output can’t change.
  • Rounding stays in main. revenue_by_customer returns exact totals, and only the printed report rounds them. Which answer is “right” depends on who’s asking, and that decision belongs to the program, not the rules.

The Day 1 reference solution

This is the log analyzer at the end of Day 1, and where Day 2 starts. Run runs all six tests: four unit tests for analyzer.py, including today’s deliberate fix, and the two tests that keep report.py honest.

Day 1 reference: analyzer.py and report.py Python
"""Print a summary of a log file: the Day 1 script, rebuilt from tested parts.""" from analyzer import count_levels, errors_by_source, read_lines def main(path="sample.log"): lines = read_lines(path) print("Lines read:", len(lines)) print("Levels:", count_levels(lines)) print("Errors by source:", errors_by_source(lines)) if __name__ == "__main__": main() """Log analyzer, end of Day 1: small functions, each one tested.""" 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 colon.""" return line.split()[3].rstrip(":") 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 import unittest from analyzer import count_levels, errors_by_source, level_of, source_of 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\"", ] class TestAnalyzer(unittest.TestCase): def test_level_and_source(self): self.assertEqual(level_of(LINES[0]), "INFO") self.assertEqual(source_of(LINES[1]), "payments") def test_source_without_a_colon(self): # fixed on Day 1, after the refactor self.assertEqual(source_of("2026-09-01 10:00:00 ERROR db timeout"), "db") def test_count_levels(self): self.assertEqual(count_levels(LINES), {"INFO": 1, "ERROR": 2}) def test_errors_by_source(self): self.assertEqual(errors_by_source(LINES), {"payments": 1, "search": 1}) import importlib import io import runpy import unittest from contextlib import redirect_stdout import report # What the original script printed. The refactor must not change it. EXPECTED = """\ Lines read: 6 Levels: {'INFO': 3, 'WARNING': 1, 'ERROR': 2} Errors by source: {'payments': 1, 'search': 1} """ 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(), "") import sys import unittest import test_analyzer import test_report loader = unittest.defaultTestLoader suite = unittest.TestSuite([loader.loadTestsFromModule(test_analyzer), loader.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

Working on your own machine? Download the Day 1 files and carry on there. You need Python 3.12 or later, and nothing else.

Every day of this course ends with a reference like this one. If an exercise went wrong, or you skipped a day, start the next day from here. You’ll never be blocked by a broken exercise.

What you can now do

  • Separate rules from the program into modules that change for different reasons.
  • Make a behaviour change as its own step: a failing test first, then the fix.
  • Spot a mutable default argument, and use None instead.
  • Diagnose a test that passes alone and fails in the suite.
  • Refactor an unfamiliar script under a safety net, without changing its output.

Tomorrow the analyzer stops re-slicing strings and starts parsing each line into a proper record.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.