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:
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
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), withquantityanintandpriceafloat.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
ordersprinting nothing, and running it printing exactly what it prints now.
Work the same way as this morning: one step, run the tests, repeat.
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 convertedp[3]tointin 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 * priceexplains 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_customerreturns 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.
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
Noneinstead. - 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.