Skip to content

Commit f2831fd

Browse files
Merge pull request #59 from NVIDIA-NeMo/fix-atif-timestamp-timezone
fix(atif): emit real UTC step timestamps instead of local time
2 parents 6031c5e + df05af0 commit f2831fd

2 files changed

Lines changed: 78 additions & 2 deletions

File tree

src/nooa/atif/exporter.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,16 @@ class _DispatchStep(NamedTuple):
167167

168168

169169
def _iso8601_utc(dt: datetime | None = None) -> str:
170-
"""ISO 8601 UTC timestamp (with millisecond precision and Z suffix)."""
171-
dt = dt or datetime.now(UTC)
170+
"""ISO 8601 UTC timestamp (with millisecond precision and Z suffix).
171+
172+
``dt`` is normalised to UTC first: the ``Z`` suffix asserts UTC, so
173+
formatting a local-time value verbatim would mislabel it. Event
174+
timestamps arrive naive (local), while the default is already aware —
175+
without the conversion a single trajectory mixes both, leaving steps
176+
out of chronological order by the local UTC offset. A naive value is
177+
interpreted as local time, which is what the event system produces.
178+
"""
179+
dt = datetime.now(UTC) if dt is None else dt.astimezone(UTC)
172180
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
173181

174182

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""ATIF step timestamps must be real UTC, not local time wearing a Z suffix.
4+
5+
``_iso8601_utc`` used to format whatever tzinfo it was handed and append ``Z``
6+
unconditionally. Event timestamps arrive naive (local), while the no-argument
7+
default is already UTC-aware, so a single trajectory ended up mixing both and
8+
its steps were not in chronological order.
9+
"""
10+
11+
import os
12+
import time
13+
from datetime import UTC, datetime, timedelta, timezone
14+
15+
import pytest
16+
17+
from nooa.atif.exporter import _iso8601_utc
18+
19+
20+
def _hour(stamp: str) -> int:
21+
return int(stamp[11:13])
22+
23+
24+
class TestIso8601Utc:
25+
def test_aware_utc_is_unchanged(self):
26+
dt = datetime(2026, 7, 30, 10, 6, 42, 680_000, tzinfo=UTC)
27+
assert _iso8601_utc(dt) == "2026-07-30T10:06:42.680Z"
28+
29+
def test_aware_non_utc_is_converted(self):
30+
"""A +02:00 wall clock of 12:06 is 10:06 UTC."""
31+
dt = datetime(2026, 7, 30, 12, 6, 42, 680_000, tzinfo=timezone(timedelta(hours=2)))
32+
assert _iso8601_utc(dt) == "2026-07-30T10:06:42.680Z"
33+
34+
def test_negative_offset_is_converted(self):
35+
dt = datetime(2026, 7, 30, 6, 6, 42, 680_000, tzinfo=timezone(timedelta(hours=-4)))
36+
assert _iso8601_utc(dt) == "2026-07-30T10:06:42.680Z"
37+
38+
def test_naive_is_interpreted_as_local(self):
39+
"""Naive event timestamps are local time and must be shifted to UTC."""
40+
naive = datetime.now()
41+
assert _iso8601_utc(naive) == _iso8601_utc(naive.astimezone(UTC))
42+
43+
def test_default_matches_explicit_now(self):
44+
assert abs(
45+
datetime.strptime(_iso8601_utc(), "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=UTC)
46+
- datetime.now(UTC)
47+
) < timedelta(seconds=5)
48+
49+
@pytest.mark.skipif(not hasattr(time, "tzset"), reason="tzset() is POSIX-only")
50+
def test_naive_and_default_agree_under_a_non_utc_tz(self):
51+
"""The regression: two code paths must not disagree by the UTC offset.
52+
53+
Under a non-UTC local zone, an event-sourced (naive) timestamp and the
54+
no-argument default previously landed hours apart in the same file.
55+
"""
56+
prev = os.environ.get("TZ")
57+
try:
58+
os.environ["TZ"] = "Europe/Berlin" # UTC+2 in July
59+
time.tzset()
60+
from_event = _iso8601_utc(datetime.now()) # naive → local
61+
from_default = _iso8601_utc() # aware → UTC
62+
assert _hour(from_event) == _hour(from_default)
63+
finally:
64+
if prev is None:
65+
os.environ.pop("TZ", None)
66+
else:
67+
os.environ["TZ"] = prev
68+
time.tzset()

0 commit comments

Comments
 (0)