Most messy-repo refactors break on time, not on logic. The tests pass at 14:00 UTC and fail at 02:30 local. The extraction looked pure, but the module read the clock.
This post is one workflow: census time reads, freeze the wall clock, run a time-zone matrix, then move code under a zero-diff budget. The artifact is small enough to run in CI.
1. Why the clock survives most characterization suites
Characterization tests usually pin return values for one input. That input arrives at one instant, in one zone, under one locale.
An extraction changes three things at once. It changes where the clock read happens, which tzinfo is attached, and when module-level code runs. A datetime.now() that moved from caller to callee is now evaluated at a different point in the pipeline.
Date arithmetic makes this worse. now() + timedelta(days=1) crosses a DST fold differently in Berlin than in Tokyo. Your golden output will match in one zone and drift in another.
2. Step 1 — Census every time read before touching code
Do not grep. Parse. Grep misses aliases and chained attributes.
# tools/time_census.py
import ast, pathlib, sys
WANTED = {
"now", "utcnow", "today", "time", "monotonic", "monotonic_ns",
"fromtimestamp", "strftime", "localtime", "gmtime", "now_ns",
}
def dotted(node: ast.AST) -> str:
parts = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
def scan(path: pathlib.Path):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
if node.func.attr in WANTED:
yield node.lineno, dotted(node.func)
root = pathlib.Path(sys.argv[1])
rows = []
for path in sorted(root.rglob("*.py")):
for lineno, name in scan(path):
rows.append((str(path), lineno, name))
for path, lineno, name in rows:
print(f"{path}:{lineno}:{name}")
print(f"# total time reads: {len(rows)}", file=sys.stderr)
Run it and read the tail of the list.
python tools/time_census.py src/ > census-time.txt
python tools/time_census.py src/ 2>&1 >/dev/null
The count is your refactor's risk surface. Every line in that file is a place where the extraction can silently change meaning.
Split the list into two buckets immediately. Wall-clock reads go into the matrix. Monotonic reads go into a separate list you leave alone.
3. Step 2 — Freeze the wall clock, keep the monotonic clock real
Freezing time.monotonic is a trap. Timeouts, retry backoff, and circuit breakers then look tested while testing nothing.
Freeze only wall-clock reads. Keep the monotonic clock running, and pin those call sites with a fake seam you control explicitly.
The fixture below is a template. Run it against your own repo before you trust it.
# tests/test_label.py
import datetime as dt
import time
import pytest
from freezegun import freeze_time
from app import reports # the module under refactor
CASES = {
"utc_spring": ("UTC", "C", "2026-03-29T01:30:00+00:00"),
"berlin_fold": ("Europe/Berlin", "C", "2026-10-25T01:30:00+00:00"),
"tokyo_newyear": ("Asia/Tokyo", "C", "2026-01-01T00:00:00+00:00"),
}
@pytest.mark.parametrize("name", sorted(CASES))
def test_report_label(name, monkeypatch):
tz, locale, instant = CASES[name]
monkeypatch.setenv("TZ", tz)
time.tzset()
with freeze_time(instant):
label = reports.build_label()
print(f"RESULT {name} {dt.datetime.now().isoformat()} {label!r}")
Stop here if you plan to set the locale in the same process. Python's locale is process-global. One test will leak into the next.
4. Step 3 — Run each matrix cell in its own subprocess
Subprocess isolation is the only cheap way to get a clean TZ plus locale per cell. It also catches import-time reads for free.
# tools/matrix.py
import json, os, subprocess, sys
CASES = {
"utc_spring": {"tz": "UTC", "lang": "C"},
"berlin_fold": {"tz": "Europe/Berlin", "lang": "C"},
"tokyo_newyear": {"tz": "Asia/Tokyo", "lang": "C"},
"berlin_de": {"tz": "Europe/Berlin", "lang": "de_DE.UTF-8"},
}
record = "--record" in sys.argv
out = {}
for name, cfg in sorted(CASES.items()):
env = {**os.environ, "TZ": cfg["tz"], "LANG": cfg["lang"], "LC_ALL": cfg["lang"]}
proc = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "-s", "tests/test_label.py", "-k", name],
env=env, capture_output=True, text=True,
)
lines = [l for l in proc.stdout.splitlines() if l.startswith("RESULT")]
out[name] = {"rc": proc.returncode, "lines": lines}
if record:
json.dump(out, open("golden/before.json", "w"), indent=2, sort_keys=True)
print("recorded", len(out), "cells")
else:
print(json.dumps(out, indent=2, sort_keys=True))
Run it twice. Once before the move, once after. Same command, same case set.
python tools/matrix.py --record
# ...perform the extraction...
python tools/matrix.py > /tmp/after.json
5. Step 4 — Gate the move with a zero-diff budget
A move should change zero cells. Not one. If it changes one, you have a bug fix, not a refactor.
# tools/diff_budget.py
import json, sys
before = json.load(open("golden/before.json"))
after = json.load(open(sys.argv[1]))
budget = int(sys.argv[2])
missing = sorted(set(before) - set(after))
changed = sorted(k for k in before if k in after and before[k] != after[k])
print(f"cells={len(before)} changed={len(changed)} missing={len(missing)} budget={budget}")
for k in changed:
print("changed:", k)
for k in missing:
print("missing:", k)
sys.exit(0 if not missing and len(changed) <= budget else 1)
python tools/diff_budget.py /tmp/after.json 0
Keep this gate in CI. It is the cheapest regression net you will ever write for date-heavy code.
6. The smallest safe change, chosen from the matrix
The matrix tells you which move you are allowed to make next. Read the table top-down and stop at the first matching row.
| Matrix observation | Smallest safe change |
|---|---|
| All cells identical, case set unchanged | Move the code, add a re-export shim, delete the shim in a later commit |
| Same values, different tzinfo attached | Normalize at the boundary first, as its own commit, then move |
| One DST-fold cell changed | Do not move. Write the failing test, treat it as a bug fix |
| A cell fails at import, before any test runs | Pin import-time reads first, then retry the move |
| Monotonic cells behave differently | Stop. Your fake clock is wrong, not your code |
Two rules make this table work. One: the re-export shim stays until the diff budget is zero. Two: every non-zero diff becomes its own commit, never mixed into the move.
7. Where a free model and a free server actually help
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The census output is boring and that is the point. It is a long list of path:line:name rows, and reading it by hand is how people skip rows.
Two delegation steps fit naturally here. Free model access can turn census-time.txt into a per-module checklist of suspected clock dependencies. The free server option can host the nightly matrix run, so the baseline is regenerated without anyone's laptop.
Treat the generated checklist as a hypothesis list, not a verdict. Each item still needs a matrix cell that proves or disproves it. And check whether the server limits fit your repo size before you commit to that shape — I have not benchmarked that here.
If you already have a census file, the cheapest next step is to run it against whatever model access you have and diff the checklist against your own reading.
8. Limitations, and who should skip this
This workflow assumes you can install freezegun and run pytest in a subprocess. Repos on locked-down CI may not allow it.
The matrix only covers zones you enumerate. It cannot prove the absence of time dependencies. It gives you a fixed, reviewable case set instead of a vague feeling.
Skip this approach if your module has no date handling, or if the refactor is a pure file move with no import changes. The matrix will cost more than it catches.
Also skip it if you cannot freeze the clock at all because the code calls datetime.now() inside C extensions. Build the seam first.
The order never changes. Census, freeze the wall clock, run the matrix, then take the smallest move the table allows.
One last thing: write the diff budget down before you start. A budget you negotiate after the diff appears is not a budget.
Top comments (0)