The Tally That Wouldn't Reset
An on-call engineer maintains a Python 3.11 script that summarizes failed SSH logins from 24 hourly log slices. The core function is def count_failures(path, counts={}): ... counts[ip] = counts.get(ip, 0) + 1; return counts. The main routine loops over the slices, calls the function once per file, writes a per-hour JSON report, and leaves the process running in an interactive IPython session so the operator can re-run individual hours. Monday's full run looked correct: IP 203.0.113.42 appeared 17 times in the 03:00 slice and the report showed 17. On Tuesday the operator re-ran the same 03:00 slice three times to check a parser change. The final JSON showed 51 for that IP, and every subsequent re-run increased the totals. The logs are immutable and de-duplicated upstream. No database or cache is involved; the only state is inside the running Python process. A second operator running the script from a fresh shell gets the expected 17.
1. Explain precisely why repeated calls inflate the counts inside one Python process while a fresh shell reports the correct number.
Read the worked answer
Diagnosis. Default argument expressions are evaluated once, when the def statement executes, not on each call. counts={} creates a single dict object bound into count_failures.__defaults__. Every call that omits counts receives that same dict. The function mutates it, returns it, and the caller serializes cumulative state. The arithmetic matches the symptom: the same immutable slice parsed three times gives 17 × 3 = 51.
A fresh process defines a new function object with a new default dict, so its first call starts at zero. In IPython, re-running only a call cell does not reset __defaults__; re-running a cell that contains the def can accidentally mask the bug by rebinding the function. Clearing the dict after each call is not a safe fix because state still leaks across exceptions, concurrent calls, or any caller that keeps the returned reference.
2. Rewrite the function and the calling pattern so per-file results are isolated, while still allowing an explicit accumulator when a caller really wants one.
Read the worked answer
Use None as the sentinel and create a new dict inside the function. Return a copy so callers cannot mutate internal state accidentally. Keep accumulation explicit: only pass a dict when cross-file totals are genuinely required.
def count_failures(path, counts=None):
result = {} if counts is None else counts
with open(path, encoding='utf-8') as fh:
for line in fh:
if 'Failed password' not in line:
continue
ip = extract_ip(line)
result[ip] = result.get(ip, 0) + 1
return dict(result)
# per-hour report
for path in hourly_slices:
hour_counts = count_failures(path)
write_json(path, hour_counts)
# explicit whole-day accumulation, deliberately shared
day = {}
for path in hourly_slices:
count_failures(path, day)
The important change is that omitting the second argument no longer shares state. The explicit day dict documents the only place where accumulation is intended.
3. Write a small pytest test that would have failed on the original code and that protects the fixed behavior.
Read the worked answer
The test must call the function more than once in the same process with the same input and assert that the second result is not contaminated by the first. A fresh-process test would pass even with the bug.
def test_repeated_calls_are_independent(tmp_path):
log = tmp_path / 'auth.log'
log.write_text(
'Failed password for root from 203.0.113.42 port 22 ssh2\n'
'Failed password for admin from 203.0.113.42 port 22 ssh2\n',
encoding='utf-8',
)
first = count_failures(log)
second = count_failures(log)
assert first == second
assert second['203.0.113.42'] == 2
With the mutable default, second would be 4, then 6 on the next call. Add a second test that passes an explicit accumulator and checks that it is updated, so the fix does not break the intentional accumulation path.