Do not extract a now helper just yet. Pin tzinfo, fold, and naive equality before anything else. A one-line clock extract can change comparison behavior.
Messy repos inline datetime.now calls across many branches. Reviewers often treat that call as a trivial seam. It is not a trivial seam for comparisons.
Naive and aware values refuse to compare in Python. The raise is TypeError, not a quiet boolean. Tests that only check a datetime returned miss both.
The failure mode
Python 3 compares naive and aware datetimes by raising. The exception type is TypeError, never a False result. Log paths that used == can start crashing after extract.
datetime.now returns a naive local wall time. datetime.now with timezone.utc returns an aware value. Mixing them after an extract is a silent behavior change.
The fold attribute marks the second DST fallback hour. Two timestamps can share wall time and differ on fold. Equality checks without fold hide that duplicated hour.
This workflow records those three fields before any extract. Then it extracts one clock function only. No other policy in the module should move.
What you pin
Pin three facts for every call site you will touch.
- tzinfo is None, or a concrete tz object.
- fold is 0 or 1 on constructed local times.
- Naive versus aware equality raises TypeError.
Do not pin the current wall clock value. That timestamp drifts on every test run. Pin shape, zone, and comparison rules instead.
Do not pin the full TypeError text on day one. Message wording can shift across Python releases. Pin the exception class, then optionally a stable prefix.
Artifact: clock-shape harness
The module below stands in for a messy billing window. Treat it as a sample until you paste it locally. Then run the tests exactly as written.
# window.py — inline clocks, not a design target
from datetime import datetime, timedelta
def window_open(hours=24):
start = datetime.now()
end = start + timedelta(hours=hours)
return start, end
def in_window(ts, hours=24):
start, end = window_open(hours)
return start <= ts < end
def same_instant(a, b):
return a == b
in_window compares caller timestamps to a naive now. Pass an aware ts and the comparison raises TypeError. That raise is the behavior you must pin first.
Step 1 — Record live shape
Run one probe command before any refactor starts. Do not extract a helper during this step.
# probe_clock.py
from datetime import datetime, timezone
from window import window_open, same_instant
start, end = window_open()
print("type", type(start).__name__)
print("tzinfo", start.tzinfo)
print("fold", start.fold)
print("delta_hours", (end - start).total_seconds() / 3600)
try:
same_instant(start, datetime.now(timezone.utc))
print("naive_aware", "compared")
except TypeError as exc:
print("naive_aware", type(exc).__name__)
python probe_clock.py
Record stdout from the probe as a small table. Example rows look like the table below.
| Field | Observed sample |
|---|---|
| type | datetime |
| tzinfo | None |
| fold | 0 |
| window length | 24.0 hours |
| naive == aware | TypeError |
Your fold value may differ on some hosts. tzinfo should still be None for datetime.now. Window length should match the hours argument, not the wall clock.
Step 2 — Freeze the table in pytest
Convert the probe into tests that fail on shape drift. Do not assert the current wall time in those tests.
# test_clock_shape.py
from datetime import datetime, timezone, timedelta
import pytest
from window import window_open, in_window, same_instant
def test_window_bounds_are_naive():
start, end = window_open()
assert start.tzinfo is None
assert end.tzinfo is None
def test_window_start_fold_is_zero():
start, _ = window_open(hours=24)
assert start.fold == 0
def test_window_length_matches_hours_argument():
start, end = window_open(hours=6)
assert end - start == timedelta(hours=6)
def test_naive_aware_equality_raises_typeerror():
start, _ = window_open()
aware = datetime.now(timezone.utc)
with pytest.raises(TypeError):
same_instant(start, aware)
def test_in_window_rejects_aware_ts():
aware = datetime.now(timezone.utc)
with pytest.raises(TypeError):
in_window(aware)
pytest test_clock_shape.py -q
Green results mean you recorded current behavior only. Green results do not mean the behavior is desirable. You still have a mixed naive and aware trap.
Step 3 — Make each test fail once
Break the production function on a throwaway branch. Confirm each assertion fires, then restore the file. A test that cannot fail is not a pin.
git checkout -b pin-clock-shape
Temporarily return an aware UTC timestamp from window_open. Run the same file again and read the failures.
pytest test_clock_shape.py -q
git checkout -- window.py
Naive tzinfo tests should fail when tzinfo becomes UTC. The TypeError tests may go green if both sides are aware. That flip is the signal your pin is doing work.
If every test stays green after the break, the pin is incomplete. Fix the assertions before any extract. Do not skip this fail-once pass.
Step 4 — Extract one clock, nothing else
Add a single helper that preserves naive now. Do not switch that helper to UTC in this patch.
# window.py — after the smallest extract
from datetime import datetime, timedelta
def _now():
return datetime.now()
def window_open(hours=24):
start = _now()
end = start + timedelta(hours=hours)
return start, end
def in_window(ts, hours=24):
start, end = window_open(hours)
return start <= ts < end
def same_instant(a, b):
return a == b
Re-run the same tests after the extract. They must stay green on tzinfo and TypeError. The diff should show one function and call-site swaps.
pytest test_clock_shape.py -q
git diff --stat
If tzinfo tests fail, you changed zone policy. Revert that policy change and keep the extract only. Zone policy belongs in a later dedicated PR.
Decision table
Use this table before you accept any further clock patch.
| Proposed change | Allowed in this PR? | Pin that must stay green |
|---|---|---|
Extract _now() as datetime.now()
|
Yes | tzinfo is None, fold == 0 |
Switch _now() to timezone.utc
|
No | naive-versus-aware TypeError |
Accept aware ts inside in_window
|
No |
in_window still raises TypeError |
Replace datetime with time.time()
|
No | type name stays datetime |
Call deprecated datetime.utcnow()
|
No | tzinfo stays None on purpose |
One behavioral change per PR. Shape pins stay in CI after merge. UTC migration is a product change, not cleanup.
Optional message prefix
Some teams also pin a short TypeError prefix. Do that only after the class pin is green. Keep the check small so Python upgrades do not thrash CI.
def test_naive_aware_typeerror_mentions_offset():
start, _ = window_open()
aware = datetime.now(timezone.utc)
with pytest.raises(TypeError) as caught:
same_instant(start, aware)
text = str(caught.value).lower()
assert "offset-naive" in text or "naive" in text
Drop this test if your runtime localizes exception text. The class pin remains the contract. Prefix checks are extra, not the seam.
Where a free model can help
A model is useful after the harness is green. It is not a substitute for the pinned table.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option. Use the model only to propose the _now extract. Use the server to run pytest against the branch.
Do not paste the messy module and ask for a better clock. That prompt often invites an unplanned UTC migration. The migration is a product decision, not a refactor.
A safer prompt looks like the block below.
Do not change tzinfo, fold, or comparison behavior.
window.py already has green tests in test_clock_shape.py.
Propose the smallest extract of datetime.now() into _now().
Return a diff. Do not switch to timezone.utc.
Run the proposed diff on the server or locally. Keep the branch only if the four pins stay green. Reject any patch that makes in_window accept aware input.
Review checklist
- Confirm tzinfo is still None on window bounds.
- Confirm fold is still 0 on the extracted now.
- Confirm naive versus aware equality still raises TypeError.
- Confirm hours still maps to a timedelta of that length.
- Confirm the diff does not touch parsing, storage, or display.
Reject the PR if any checklist row is missing. Missing pins are how clock policy sneaks into cleanup. Cleanup is allowed. Policy is not allowed here.
Limitations
This harness does not freeze the absolute wall time. Parallel tests can still race on window boundaries. Inject a clock port later if billing must be deterministic.
fold is often zero outside DST fallback hours. A green fold test does not prove fallback handling. Add a constructed datetime with fold set to one.
from datetime import datetime
fallback = datetime(2025, 11, 2, 1, 30, fold=1)
assert fallback.fold == 1
That fixture documents fold, but it does not use _now. Keep it out of the extract PR if production never constructs fold. Add it when you later handle local DST math.
datetime.now uses the process local zone for wall time. The returned object still has tzinfo equal to None. Do not treat naive local time as UTC storage.
The extract does not fix mixed naive and aware bugs. It only stops you from hiding them inside a helper. Callers can still pass aware timestamps and crash.
Who should not use this
Skip this workflow if you already inject a clock port. Skip it if every timestamp is timezone-aware by contract. Skip it if the module is generated code you do not own.
Do not use a model-first extract on payment windows. Do not use it on audit logs or scheduling cutovers. Pin the types first, then extract one helper.
Decide UTC policy in a later reviewable PR. That PR needs storage, API, and log impact notes. It is not a rename of datetime.now.
Close
Clock extracts look like cleanup in review. They become policy changes when tzinfo moves. Pin naive equality, fold, and exception type first.
Extract one helper only after those pins stay green. Leave zone policy for a second, explicit change. The smallest safe clock change is a rename, not a timezone fix.
Top comments (0)