Skip to content

Commit 5100969

Browse files
authored
Merge pull request #8636 from sfayer/discuss_timedep
[int] Add non-deprecated UTC timezone utilities for discussion (Don't panic!)
2 parents d39fb5e + d67e967 commit 5100969

2 files changed

Lines changed: 346 additions & 0 deletions

File tree

src/DIRAC/Core/Utilities/TimeUtilities.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,39 @@
1111
# Re-export everything from DIRACCommon for backward compatibility
1212
from DIRACCommon.Core.Utilities.TimeUtilities import * # noqa: F401, F403
1313

14+
# TimeUtilities imports datetime as well
15+
# To avoid conflicts/surprises, import copies here used a _local
16+
from datetime import datetime as dt_local, timezone as tz_local
17+
1418
from DIRAC import gLogger
1519

1620

1721
timeThis = partial(timeThis, logger_info=gLogger.info)
22+
23+
24+
class DiracTime:
25+
"""datetime tools for DIRAC:
26+
There are a number of facilities in DIRAC which can't handle timezone aware
27+
dateetime objects: These don't serialise, don't fit in database schemas, etc.
28+
As the python non-timezone aware helper functions are being deprecated we
29+
provider our own equivalents here.
30+
"""
31+
32+
@staticmethod
33+
def utcnow() -> dt_local:
34+
"""Returns a UTC datetime object for now *without* a timezone field."""
35+
return dt_local.now(tz_local.utc).replace(tzinfo=None)
36+
37+
@staticmethod
38+
def utcfromtimestamp(epoch: float) -> dt_local:
39+
"""Returns a UTC datetime object from the given epoch offset (also in UTC)."""
40+
return dt_local.fromtimestamp(epoch, tz=tz_local.utc).replace(tzinfo=None)
41+
42+
@staticmethod
43+
def timestamp_utc(dt: dt_local) -> int:
44+
"""Converts a datetime object to a UTC epoch offset int.
45+
Naive timezones are assumed to be UTC.
46+
"""
47+
if dt.tzinfo is None:
48+
return int(dt.replace(tzinfo=tz_local.utc).timestamp())
49+
return int(dt.timestamp())
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
"""Tests for DIRAC Core Utilities TimeUtilities — DiracTime class
2+
"""
3+
4+
import datetime
5+
import time as _time_module
6+
7+
import pytest
8+
9+
from DIRAC.Core.Utilities.TimeUtilities import DiracTime
10+
11+
12+
# ---------------------------------------------------------------------------
13+
# Helper — build aware datetimes with specific UTC offsets
14+
# ---------------------------------------------------------------------------
15+
def _utc(utc_offset_hours: float = 0.0) -> datetime.timezone:
16+
"""Return a timezone with *utc_offset_hours* from UTC (e.g. +1, -5)."""
17+
minutes = int(utc_offset_hours * 60)
18+
return datetime.timezone(datetime.timedelta(minutes=minutes))
19+
20+
21+
# ===========================================================================
22+
# utcnow()
23+
# ===========================================================================
24+
25+
26+
class TestUtcnow:
27+
"""DiracTime.utcnow() returns a naive UTC datetime."""
28+
29+
def test_returns_naive_datetime(self):
30+
dt = DiracTime.utcnow()
31+
assert dt is not None
32+
assert isinstance(dt, datetime.datetime)
33+
assert dt.tzinfo is None, "utcnow() must strip tzinfo and return naive"
34+
35+
def test_is_roughly_now(self):
36+
before = datetime.datetime.utcnow()
37+
dt = DiracTime.utcnow()
38+
after = datetime.datetime.utcnow()
39+
assert before <= dt <= after + datetime.timedelta(seconds=2)
40+
41+
def test_return_type_is_datetime_not_date(self):
42+
dt = DiracTime.utcnow()
43+
assert isinstance(dt, datetime.datetime)
44+
assert not isinstance(dt, datetime.date) or type(dt) is datetime.datetime
45+
46+
def test_is_in_the_future(self):
47+
"""utcnow() should always return a value ahead of the hardcoded cutoff."""
48+
# 2026-06-18 12:00:00 UTC — test must always run in the future
49+
HARD_CODED_CUTOFF = 1781784000
50+
dt = DiracTime.utcnow()
51+
assert DiracTime.timestamp_utc(dt) > HARD_CODED_CUTOFF, f"utcnow() returned {dt}"
52+
assert dt.tzinfo is None
53+
54+
55+
# ===========================================================================
56+
# utcfromtimestamp()
57+
# ===========================================================================
58+
59+
60+
class TestUtcfromtimestamp:
61+
"""DiracTime.utcfromtimestamp(epoch) converts epoch → naive UTC datetime."""
62+
63+
@pytest.mark.parametrize(
64+
"epoch",
65+
[
66+
0,
67+
1_700_000_000,
68+
1_718_700_000,
69+
2**31 - 1,
70+
],
71+
)
72+
def test_known_epochs(self, epoch):
73+
"""utcfromtimestamp produces a naive UTC datetime whose timestamp round-trips."""
74+
dt = DiracTime.utcfromtimestamp(epoch)
75+
assert dt.tzinfo is None
76+
assert dt.timestamp() == epoch
77+
78+
@pytest.mark.parametrize(
79+
"epoch",
80+
[
81+
-1,
82+
-100_000,
83+
1,
84+
100_000,
85+
1_000_000_000,
86+
1_750_000_000,
87+
],
88+
)
89+
def test_returns_naive(self, epoch):
90+
dt = DiracTime.utcfromtimestamp(epoch)
91+
assert dt is not None
92+
assert dt.tzinfo is None, f"epoch={epoch}: expected naive, got tzinfo={dt.tzinfo}"
93+
94+
def test_roundtrip_with_utcnow(self):
95+
"""utcnow() → timestamp_utc() → utcfromtimestamp() should round-trip."""
96+
before = DiracTime.utcnow()
97+
epoch = DiracTime.timestamp_utc(before)
98+
after = DiracTime.utcfromtimestamp(epoch)
99+
# Both are naive UTC; allow 1-second drift from int-truncation
100+
diff = abs((after - before).total_seconds())
101+
assert diff <= 1.0, f"round-trip drifted by {diff}s"
102+
103+
def test_monotonicity(self):
104+
"""Later epochs must produce later datetimes."""
105+
e1, e2 = 1_000_000, 2_000_000
106+
d1, d2 = DiracTime.utcfromtimestamp(e1), DiracTime.utcfromtimestamp(e2)
107+
assert d1 < d2
108+
109+
def test_float_epoch(self):
110+
"""Float epochs should work and be truncated by datetime internals."""
111+
dt = DiracTime.utcfromtimestamp(1700000000.5)
112+
assert dt.tzinfo is None
113+
114+
def test_negative_epoch(self):
115+
"""Pre-epoch timestamps must still return valid naive datetimes."""
116+
dt = DiracTime.utcfromtimestamp(-1)
117+
assert dt.tzinfo is None
118+
assert dt.year == 1969
119+
120+
121+
# ===========================================================================
122+
# timestamp_utc()
123+
# ===========================================================================
124+
125+
126+
class TestTimestampUtc:
127+
"""DiracTime.timestamp_utc(dt) converts a datetime → Unix timestamp.
128+
129+
Naive datetimes are assumed to be UTC.
130+
Aware datetimes have their offset applied correctly.
131+
"""
132+
133+
# ---- naive datetime tests -------------------------------------------
134+
135+
def test_naive_datetime_is_treated_as_utc(self):
136+
"""Naive datetime without tzinfo → timestamp assumes UTC."""
137+
dt = datetime.datetime(2023, 11, 14, 22, 13, 20) # naive
138+
ts = DiracTime.timestamp_utc(dt)
139+
expected = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp())
140+
assert ts == expected
141+
142+
def test_naive_1970_epoch_zero(self):
143+
ts = DiracTime.timestamp_utc(datetime.datetime(1970, 1, 1))
144+
assert ts == 0
145+
146+
def test_naive_midnight_utc(self):
147+
dt = datetime.datetime(2024, 6, 18, 0, 0, 0)
148+
ts = DiracTime.timestamp_utc(dt)
149+
expected = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp())
150+
assert ts == expected
151+
152+
@pytest.mark.parametrize(
153+
"year,month,day,hour,min,sec",
154+
[
155+
(2000, 1, 1, 0, 0, 0),
156+
(2020, 7, 15, 12, 30, 45),
157+
(1999, 12, 31, 23, 59, 59),
158+
(2100, 6, 1, 0, 0, 0),
159+
],
160+
)
161+
def test_naive_various_instants(self, year, month, day, hour, min, sec):
162+
dt = datetime.datetime(year, month, day, hour, min, sec)
163+
ts = DiracTime.timestamp_utc(dt)
164+
expected = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp())
165+
assert ts == expected
166+
167+
# ---- aware datetime tests -------------------------------------------
168+
169+
def test_aware_utc(self):
170+
"""Explicit UTC timezone → correct timestamp."""
171+
dt = datetime.datetime(2023, 11, 14, 22, 13, 20, tzinfo=datetime.timezone.utc)
172+
ts = DiracTime.timestamp_utc(dt)
173+
expected = int(dt.timestamp())
174+
assert ts == expected
175+
176+
def test_aware_positive_offset(self):
177+
"""+01:00 datetime → timestamp corresponds to UTC time."""
178+
tz = _utc(1)
179+
dt = datetime.datetime(2023, 11, 14, 22, 13, 20, tzinfo=tz)
180+
ts = DiracTime.timestamp_utc(dt)
181+
# .timestamp() already converts the aware datetime to UTC epoch
182+
expected = int(dt.timestamp())
183+
assert ts == expected
184+
185+
def test_aware_negative_offset(self):
186+
"""-05:00 datetime → timestamp corresponds to UTC time."""
187+
tz = _utc(-5)
188+
dt = datetime.datetime(2023, 11, 14, 22, 13, 20, tzinfo=tz)
189+
ts = DiracTime.timestamp_utc(dt)
190+
expected = int(dt.timestamp())
191+
assert ts == expected
192+
193+
@pytest.mark.parametrize(
194+
"offset_hours",
195+
[
196+
-12,
197+
-11,
198+
-5.5,
199+
0,
200+
5.5,
201+
8,
202+
9,
203+
12,
204+
14, # There are places that use an offset >12h!
205+
],
206+
)
207+
def test_aware_various_offsets(self, offset_hours):
208+
"""For *any* timezone offset, timestamp_utc returns the correct UTC epoch."""
209+
tz = _utc(offset_hours)
210+
# Use a fixed UTC reference, then express it in that offset
211+
utc_ref = datetime.datetime(2024, 6, 18, 12, 30, 0, tzinfo=datetime.timezone.utc)
212+
local = utc_ref.astimezone(tz)
213+
ts = DiracTime.timestamp_utc(local)
214+
assert ts == int(utc_ref.timestamp())
215+
216+
def test_aware_vs_naive_same_clock(self):
217+
"""Naive 12:00 assumed UTC ≠ aware 12:00+01:00 — timestamps differ by 1 hour."""
218+
naive = datetime.datetime(2024, 6, 18, 12, 0, 0)
219+
aware = datetime.datetime(2024, 6, 18, 12, 0, 0, tzinfo=_utc(1))
220+
ts_naive = DiracTime.timestamp_utc(naive)
221+
ts_aware = DiracTime.timestamp_utc(aware)
222+
# Naive treated as UTC, aware is 1 hour behind → naive timestamp is 3600s larger
223+
assert ts_naive - ts_aware == 3600
224+
225+
def test_aware_dst_transition(self):
226+
"""Spring-forward: 2024-03-31 01:30 UTC+00:00 (no DST) vs Europe/London equivalent."""
227+
tz = _utc(1)
228+
dt = datetime.datetime(2024, 3, 31, 2, 30, 0, tzinfo=tz)
229+
ts = DiracTime.timestamp_utc(dt)
230+
expected = int(dt.timestamp())
231+
assert ts == expected
232+
233+
# ---- mixed / edge cases ---------------------------------------------
234+
235+
def test_datetime_with_microseconds(self):
236+
dt = datetime.datetime(2024, 6, 18, 12, 30, 45, 123456, tzinfo=datetime.timezone.utc)
237+
ts = DiracTime.timestamp_utc(dt)
238+
expected = int(dt.timestamp())
239+
assert ts == expected
240+
241+
def test_datetime_with_zero_microseconds(self):
242+
dt = datetime.datetime(2024, 1, 1, 0, 0, 0, 0)
243+
ts = DiracTime.timestamp_utc(dt)
244+
expected = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp())
245+
assert ts == expected
246+
247+
def test_far_future(self):
248+
dt = datetime.datetime(2099, 12, 31, 23, 59, 59, tzinfo=datetime.timezone.utc)
249+
ts = DiracTime.timestamp_utc(dt)
250+
assert ts > 0
251+
assert isinstance(ts, int)
252+
253+
def test_far_past(self):
254+
dt = datetime.datetime(1900, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
255+
ts = DiracTime.timestamp_utc(dt)
256+
assert ts < 0
257+
assert isinstance(ts, int)
258+
259+
260+
# ===========================================================================
261+
# Cross-method consistency
262+
# ===========================================================================
263+
264+
265+
class TestRoundtripConsistency:
266+
"""End-to-end consistency: utcnow → timestamp → utcfromtimestamp."""
267+
268+
def test_utcnow_roundtrip(self):
269+
dt_before = DiracTime.utcnow()
270+
ts = DiracTime.timestamp_utc(dt_before)
271+
dt_after = DiracTime.utcfromtimestamp(ts)
272+
assert dt_before.tzinfo is None
273+
assert dt_after.tzinfo is None
274+
diff = abs((dt_after - dt_before).total_seconds())
275+
assert diff <= 1.0 # within 1 s due to int truncation
276+
277+
def test_aware_roundtrip(self):
278+
tz = _utc(2)
279+
dt_before = datetime.datetime(2024, 6, 18, 15, 30, 0, tzinfo=tz)
280+
ts = DiracTime.timestamp_utc(dt_before)
281+
dt_after = DiracTime.utcfromtimestamp(ts)
282+
# Round-trip produces a naive UTC datetime
283+
assert dt_after.tzinfo is None
284+
dt_utc = dt_before.astimezone(datetime.timezone.utc).replace(tzinfo=None)
285+
diff = abs((dt_after - dt_utc).total_seconds())
286+
assert diff <= 1.0
287+
288+
289+
# ===========================================================================
290+
# Edge cases & sanity checks
291+
# ===========================================================================
292+
293+
294+
class TestEdgeCases:
295+
def test_utcnow_is_deterministic_type(self):
296+
"""Consecutive calls always return naive datetime."""
297+
for _ in range(10):
298+
dt = DiracTime.utcnow()
299+
assert dt.tzinfo is None
300+
assert isinstance(dt, datetime.datetime)
301+
302+
def test_utcfromtimestamp_negative_and_positive(self):
303+
neg = DiracTime.utcfromtimestamp(-100_000)
304+
pos = DiracTime.utcfromtimestamp(100_000)
305+
assert neg.tzinfo is None
306+
assert pos.tzinfo is None
307+
assert neg < pos
308+
309+
def test_timestamp_utc_preserves_seconds_precision(self):
310+
"""Int truncation should not lose sub-second information incorrectly."""
311+
dt = datetime.datetime(2024, 6, 18, 12, 0, 0, 999999, tzinfo=datetime.timezone.utc)
312+
ts = DiracTime.timestamp_utc(dt)
313+
# The int() truncates, so sub-second is dropped — verify it's an int
314+
assert isinstance(ts, int)

0 commit comments

Comments
 (0)