Your Clock Extract Is Unsafe Until ISO Strings Match
Extracting a clock from mixed I/O is unsafe.
Freeze ISO strings and naive datetimes before any extract.
Then change only one production call site.
Messy modules hide time in prints, retries, and logs.
Those emitted strings become an accidental public contract.
A helper rename will not preserve that contract.
The observable contract
A clock is not merely a class name.
A clock is every timestamp a caller can see.
Callers include logs, JSON payloads, and output filenames.
Naive datetimes drop timezone data without any warning.
Aware datetimes print offsets that tests rarely pin.
Microseconds appear or vanish across different formatter choices.
strftime patterns differ from isoformat output in separators.
time.time() returns a float, not a datetime.
Mixing those types creates silent format drift later.
What to freeze first
Pin these six observables before any helper extract.
- Naive versus aware type at the module boundary.
- ISO text with or without a numeric offset.
- Microsecond digits in every serialized timestamp output.
- strftime pattern text, including colons and padding.
- Integer truncation versus raw float from
time.time(). - Filename stamps that embed local wall time.
Do not pin private helper names in this pass.
Do not pin import paths of unused clock wrappers.
Those names are not part of the observable contract.
Decision table
Use this table when a gold comparison fails.
| Observable | Safe extract signal | Unsafe extract signal |
|---|---|---|
| Naive datetime | Type and isoformat stay identical | An aware object leaks out |
| Offset string | Exact +00:00 or exact Z
|
Mixed Z and +00:00 spellings |
| Microseconds | Digit count stays fixed | Truncation appears after the extract |
| strftime | Pattern text remains identical | Locale or zero-padding changes |
| Unix time | Same float or int policy |
int() added without a gold update |
| Filename stamp | Same local or UTC choice | Path timezone flipped silently |
Treat any unsafe signal as a blocked extract.
Do not fix format while moving the helper.
That mix hides the regression inside one patch.
Characterization harness
The harness below is a proposed local artifact.
It records clock observables without production metrics.
Run it against the messy module before helper edits.
# clock_charter.py — proposed characterization harness
from __future__ import annotations
import datetime as dt
import json
import re
from pathlib import Path
from typing import Any, Callable
ISO_AWARE = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$"
)
ISO_NAIVE = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$"
)
def classify_iso(text: str) -> str:
if ISO_AWARE.match(text):
return "aware_iso"
if ISO_NAIVE.match(text):
return "naive_iso"
return "other"
def snapshot_value(value: Any) -> dict[str, Any]:
if isinstance(value, dt.datetime):
offset = None
if value.tzinfo is not None:
delta = value.utcoffset()
offset = None if delta is None else int(delta.total_seconds())
return {
"kind": "datetime",
"naive": value.tzinfo is None,
"iso_class": classify_iso(value.isoformat()),
"microsecond_digits": len(f"{value.microsecond:06d}"),
"utcoffset_seconds": offset,
}
if isinstance(value, (int, float)) and not isinstance(value, bool):
return {
"kind": "unix",
"python_type": type(value).__name__,
"truncated_int": int(value),
"has_fraction": float(value) != int(value),
}
if isinstance(value, str):
return {
"kind": "str",
"iso_class": classify_iso(value),
"length": len(value),
"has_z": value.endswith("Z"),
"has_offset": bool(re.search(r"[+-]\d{2}:\d{2}$", value)),
}
return {"kind": type(value).__name__, "repr_type": type(value).__name__}
def freeze_clock_outputs(
producer: Callable[[], dict[str, Any]],
gold_path: Path,
*,
rewrite: bool = False,
) -> dict[str, Any]:
raw = producer()
snap = {key: snapshot_value(val) for key, val in sorted(raw.items())}
encoded = json.dumps(snap, indent=2, sort_keys=True) + "\n"
if rewrite or not gold_path.exists():
gold_path.write_text(encoded, encoding="utf-8")
return snap
gold = json.loads(gold_path.read_text(encoding="utf-8"))
if snap != gold:
raise AssertionError(
"clock contract drifted:\n"
f"gold={json.dumps(gold, indent=2, sort_keys=True)}\n"
f"now={json.dumps(snap, indent=2, sort_keys=True)}"
)
return snap
Wire the producer to your messy module next.
Do not mock datetime during the first freeze.
Mocks hide the contract you still need to record.
# test_clock_contract.py — proposed test
from pathlib import Path
from clock_charter import freeze_clock_outputs
GOLD = Path(__file__).with_name("clock_gold.json")
def producer():
# Replace with real imports from the messy module.
from messy_jobs import build_run_record
record = build_run_record(job_id="job-1")
log_ts = record["log_line"].split(" ", 1)[0]
return {
"started_at": record["started_at"],
"log_ts": log_ts,
"filename": record["artifact_name"],
"unix": record["epoch"],
}
def test_clock_contract():
freeze_clock_outputs(producer, GOLD, rewrite=False)
Commit clock_gold.json with the failing module unchanged.
That file is the characterization baseline, not a spec.
Later extracts must match it byte for byte.
Run the characterization target with this command only.
python -m pytest test_clock_contract.py -q --tb=short
A non-zero exit means the clock contract drifted.
Do not open a formatter debate from that failure.
Restore the previous constructor until gold matches.
Numbered workflow
Follow this sequence and skip no step.
- Inventory every timestamp leaving the messy module.
- Classify each value as datetime, string, or unix.
- Record one gold JSON file from current behavior.
- Commit the gold file before any helper move.
- Extract a clock protocol with no format change.
- Rebind exactly one production call site after extract.
- Re-run the gold comparison until it matches.
- Repeat the rebind for the next call site.
Inventory means reading prints, JSON dumps, and paths.
Do not trust grep for datetime.now alone.
strftime and time.time also emit the public contract.
The gold file is the characterization test itself.
It does not prove the timestamps are correct.
It proves the extract did not change observables.
Wall-clock instants will churn on every run.
Prefer recording types, ISO class, and digit counts.
Keep absolute instants out of the gold file.
Smallest safe change
A clock protocol can expose three boring methods.
Keep those methods explicit and free of formatting.
# proposed clock protocol — unexecuted example
from __future__ import annotations
import datetime as dt
import time
from typing import Protocol
class Clock(Protocol):
def now_naive(self) -> dt.datetime: ...
def now_utc(self) -> dt.datetime: ...
def unix(self) -> float: ...
class SystemClock:
def now_naive(self) -> dt.datetime:
return dt.datetime.now()
def now_utc(self) -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def unix(self) -> float:
return time.time()
Inject SystemClock() at one call site only.
Leave every other site on the old construction path.
Green gold files beat a complete module rewrite.
Do not convert naive values to aware in that patch.
Do not switch isoformat to strftime in that patch.
Do not truncate microseconds for alleged extra cleanliness.
Those edits remain separate product decisions for later.
Characterization tests cannot approve a later format change.
Product tests and external callers must approve it.
Determinism seam
A gold file still needs a deterministic producer.
Pass a clock into build_run_record after the freeze.
The first patch only adds the unused argument.
# proposed seam — unexecuted example
import datetime
def build_run_record(job_id: str, clock=None):
# Keep the original constructor on the first patch.
started = datetime.datetime.now()
return {"job_id": job_id, "started_at": started}
After gold stays green, replace that one now() call.
Do not thread the clock through unrelated helpers yet.
Breadth-first injection creates unused parameters across the tree.
A later deterministic clock can return fixed naive values.
Introduce that fake clock only inside tests.
Production code should keep SystemClock until formats change.
# proposed test clock — unexecuted example
import datetime as dt
class FrozenClock:
def __init__(self, naive: dt.datetime, utc: dt.datetime, unix: float):
self._naive = naive
self._utc = utc
self._unix = unix
def now_naive(self) -> dt.datetime:
return self._naive
def now_utc(self) -> dt.datetime:
return self._utc
def unix(self) -> float:
return self._unix
Do not start with FrozenClock in production modules.
That swap is a second change after gold stays green.
The extract patch must not alter timestamp spelling.
Where a remote full-tree run fits
Large messy trees stall local editors and test runners.
A frozen gold file still needs a complete module import.
Some workstations cannot hold the whole checkout in memory.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option.
If the local tree will not load, run the gold comparison there.
Use a free model only after the gold file exists.
Ask it for the next single call site to rebind.
Paste the gold diff when the extract starts to drift.
The harness and table do not depend on that product.
Skip the remote step on a small local module.
Keep the gold file in ordinary version control either way.
Failure analysis
Three failure modes show up in gold diffs.
First, naive isoformat gains an offset after the extract.
That means an aware datetime leaked across the boundary.
Revert the extract and keep the old constructor.
Second, microsecond fields drop from six digits to zero.
That means the extract used a seconds-only time source.
Restore the original datetime.now construction path immediately.
Third, filename stamps shift by several hours overnight.
That means local time replaced UTC, or the reverse.
Pin the timezone policy before moving more call sites.
A fourth mode appears in HTTP logs and JSON APIs.
Parsers treat Z and +00:00 as different literals.
Pin the exact spelling your module already emits today.
Inspect the gold keys in this command after a failure.
python - <<'PY'
import json
from pathlib import Path
gold = json.loads(Path("clock_gold.json").read_text())
for key, row in sorted(gold.items()):
print(key, row.get("kind"), row.get("iso_class"), row.get("python_type"))
PY
Print only kinds and ISO classes during triage.
Do not pretty-print absolute instants from failing runs.
Those instants will differ on every local execution.
Limitations
This method does not prove timestamps are correct.
It only proves the observables did not change.
Wrong clocks stay wrong after a clean extract.
Gold files include wall-clock values if you allow them.
Prefer formats and types, not absolute wall-clock instants.
The proposed producer still needs a seam for determinism.
If the module sleeps, retries, or queries NTP, stop.
Those paths need extra pins for delay and errors.
This article does not cover retry or sleep pins.
Python isoformat may emit +00:00 rather than Z.
Those two strings are not interchangeable for every parser.
Do not normalize them during the extract patch.
Locale-aware strftime tokens can change across machines.
%b and %Z are unsafe gold keys without a locale pin.
Prefer isoformat fields when the module already uses them.
Who should not use this approach
Do not use this method on a greenfield clock.
Write a real clock interface from day one instead.
Characterization is for code you cannot rewrite yet.
Do not use this method to hide a timezone bug.
If callers need UTC, add a dedicated product test.
Then change the format in a separate patch.
Do not use this method for cryptographic nonces.
Time uniqueness is not a security property here.
Use a CSPRNG when the value must be unpredictable.
Teams without a test runner should not start here.
The gold file is useless without automated comparison.
Add a single pytest target before any extract.
Close
Freeze ISO strings before you extract a clock.
Move one call site and keep formats identical.
Treat gold drift as a failed extract, not noise.
Top comments (0)