Skip to content

Commit 7c1275c

Browse files
authored
Merge pull request #2246 from FCP-INDI/monitoring-datetime-edge-cases
🥅 Catch more datetime edge cases in monitoring
2 parents cee03d2 + ac65f49 commit 7c1275c

File tree

3 files changed

+196
-36
lines changed

3 files changed

+196
-36
lines changed

CPAC/utils/monitoring/draw_gantt_chart.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,19 @@
3939

4040
# You should have received a copy of the GNU Lesser General Public
4141
# License along with C-PAC. If not, see <https://www.gnu.org/licenses/>.
42-
"""Module to draw an html gantt chart from logfile produced by
43-
``CPAC.utils.monitoring.log_nodes_cb()``.
42+
"""Module to draw an html gantt chart from logfile produced by `~CPAC.utils.monitoring.log_nodes_cb`.
4443
4544
See https://nipype.readthedocs.io/en/latest/api/generated/nipype.utils.draw_gantt_chart.html
4645
"""
4746

4847
from collections import OrderedDict
49-
from datetime import datetime
48+
from datetime import datetime, timedelta
5049
import random
5150
from warnings import warn
5251

5352
from nipype.utils.draw_gantt_chart import draw_lines, draw_resource_bar, log_to_dict
5453

55-
from CPAC.utils.monitoring.monitoring import DatetimeWithSafeNone
54+
from CPAC.utils.monitoring.monitoring import _NoTime, DatetimeWithSafeNone
5655

5756

5857
def create_event_dict(start_time, nodes_list):
@@ -404,37 +403,38 @@ def generate_gantt_chart(
404403

405404
for node in nodes_list:
406405
if "duration" not in node and (node["start"] and node["finish"]):
407-
node["duration"] = (node["finish"] - node["start"]).total_seconds()
406+
_duration = node["finish"] - node["start"]
407+
assert isinstance(_duration, timedelta)
408+
node["duration"] = _duration.total_seconds()
408409

409410
# Create the header of the report with useful information
410411
start_node = nodes_list[0]
411412
last_node = nodes_list[-1]
413+
start = DatetimeWithSafeNone(start_node["start"])
414+
finish = DatetimeWithSafeNone(last_node["finish"])
415+
if isinstance(start, _NoTime) or isinstance(finish, _NoTime):
416+
return
417+
start, finish = DatetimeWithSafeNone.sync_tz(start, finish)
412418
try:
413-
duration = (last_node["finish"] - start_node["start"]).total_seconds()
419+
duration = (finish - start).total_seconds()
414420
except TypeError:
415421
# no duration
416422
return
417423

418424
# Get events based dictionary of node run stats
419-
events = create_event_dict(start_node["start"], nodes_list)
425+
events = create_event_dict(start, nodes_list)
420426

421427
# Summary strings of workflow at top
422-
html_string += (
423-
"<p>Start: " + start_node["start"].strftime("%Y-%m-%d %H:%M:%S") + "</p>"
424-
)
425-
html_string += (
426-
"<p>Finish: " + last_node["finish"].strftime("%Y-%m-%d %H:%M:%S") + "</p>"
427-
)
428+
html_string += "<p>Start: " + start.strftime("%Y-%m-%d %H:%M:%S") + "</p>"
429+
html_string += "<p>Finish: " + finish.strftime("%Y-%m-%d %H:%M:%S") + "</p>"
428430
html_string += "<p>Duration: " + f"{duration / 60:.2f}" + " minutes</p>"
429431
html_string += "<p>Nodes: " + str(len(nodes_list)) + "</p>"
430432
html_string += "<p>Cores: " + str(cores) + "</p>"
431433
html_string += close_header
432434
# Draw nipype nodes Gantt chart and runtimes
433-
html_string += draw_lines(
434-
start_node["start"], duration, minute_scale, space_between_minutes
435-
)
435+
html_string += draw_lines(start, duration, minute_scale, space_between_minutes)
436436
html_string += draw_nodes(
437-
start_node["start"],
437+
start,
438438
nodes_list,
439439
cores,
440440
minute_scale,
@@ -448,8 +448,8 @@ def generate_gantt_chart(
448448
# Plot gantt chart
449449
resource_offset = 120 + 30 * cores
450450
html_string += draw_resource_bar(
451-
start_node["start"],
452-
last_node["finish"],
451+
start,
452+
finish,
453453
estimated_mem_ts,
454454
space_between_minutes,
455455
minute_scale,
@@ -458,8 +458,8 @@ def generate_gantt_chart(
458458
"Memory",
459459
)
460460
html_string += draw_resource_bar(
461-
start_node["start"],
462-
last_node["finish"],
461+
start,
462+
finish,
463463
runtime_mem_ts,
464464
space_between_minutes,
465465
minute_scale,
@@ -473,8 +473,8 @@ def generate_gantt_chart(
473473
runtime_threads_ts = calculate_resource_timeseries(events, "runtime_threads")
474474
# Plot gantt chart
475475
html_string += draw_resource_bar(
476-
start_node["start"],
477-
last_node["finish"],
476+
start,
477+
finish,
478478
estimated_threads_ts,
479479
space_between_minutes,
480480
minute_scale,
@@ -483,8 +483,8 @@ def generate_gantt_chart(
483483
"Threads",
484484
)
485485
html_string += draw_resource_bar(
486-
start_node["start"],
487-
last_node["finish"],
486+
start,
487+
finish,
488488
runtime_threads_ts,
489489
space_between_minutes,
490490
minute_scale,

CPAC/utils/monitoring/monitoring.py

Lines changed: 146 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@
1616
# License along with C-PAC. If not, see <https://www.gnu.org/licenses/>.
1717
"""Monitoring utilities for C-PAC."""
1818

19-
from datetime import datetime, timedelta
19+
from datetime import datetime, timedelta, timezone
2020
import glob
2121
import json
2222
import math
2323
import os
2424
import socketserver
25+
import struct
2526
import threading
26-
from typing import Any, Optional, TypeAlias
27+
from typing import Any, Optional, overload, TypeAlias
28+
from zoneinfo import available_timezones, ZoneInfo
2729

2830
import networkx as nx
2931
from traits.trait_base import Undefined
@@ -72,16 +74,104 @@ def __sub__(self, other: "DatetimeWithSafeNone | _NoTime") -> datetime | timedel
7274
"""Subtract between None and a datetime or timedelta or None."""
7375
return _safe_none_diff(self, other)
7476

77+
def isoformat(self) -> str:
78+
"""Return an ISO 8601-like string of 0s for display."""
79+
return "0000-00-00"
80+
7581

7682
NoTime = _NoTime()
7783
"""A singleton None that can be used in place of a datetime object."""
7884

7985

8086
class DatetimeWithSafeNone(datetime, _NoTime):
81-
"""Time class that can be None or a time value."""
82-
83-
def __new__(cls, dt: "OptionalDatetime") -> "DatetimeWithSafeNone | _NoTime":
87+
"""Time class that can be None or a time value.
88+
89+
Examples
90+
--------
91+
>>> from datetime import datetime
92+
>>> DatetimeWithSafeNone(datetime(2025, 6, 18, 21, 6, 43, 730004)).isoformat()
93+
'2025-06-18T21:06:43.730004'
94+
>>> DatetimeWithSafeNone("2025-06-18T21:06:43.730004").isoformat()
95+
'2025-06-18T21:06:43.730004'
96+
>>> DatetimeWithSafeNone(b"\\x07\\xe9\\x06\\x12\\x10\\x18\\x1c\\x88\\x6d\\x01").isoformat()
97+
'2025-06-18T16:24:28.028040+00:00'
98+
>>> DatetimeWithSafeNone(b'\\x07\\xe9\\x06\\x12\\x10\\x18\\x1c\\x88m\\x00').isoformat()
99+
'2025-06-18T16:24:28.028040'
100+
>>> DatetimeWithSafeNone(DatetimeWithSafeNone("2025-06-18")).isoformat()
101+
'2025-06-18T00:00:00'
102+
>>> DatetimeWithSafeNone(None)
103+
NoTime
104+
>>> DatetimeWithSafeNone(None).isoformat()
105+
'0000-00-00'
106+
"""
107+
108+
@overload
109+
def __new__(
110+
cls,
111+
year: "OptionalDatetime",
112+
month: None = None,
113+
day: None = None,
114+
hour: None = None,
115+
minute: None = None,
116+
second: None = None,
117+
microsecond: None = None,
118+
tzinfo: None = None,
119+
*,
120+
fold: None = None,
121+
) -> "DatetimeWithSafeNone | _NoTime": ...
122+
@overload
123+
def __new__(
124+
cls,
125+
year: int,
126+
month: Optional[int] = None,
127+
day: Optional[int] = None,
128+
hour: int = 0,
129+
minute: int = 0,
130+
second: int = 0,
131+
microsecond: int = 0,
132+
tzinfo: Optional[timezone | ZoneInfo] = None,
133+
*,
134+
fold: int = 0,
135+
) -> "DatetimeWithSafeNone": ...
136+
137+
def __new__(
138+
cls,
139+
year: "int | OptionalDatetime",
140+
month: Optional[int] = None,
141+
day: Optional[int] = None,
142+
hour: Optional[int] = 0,
143+
minute: Optional[int] = 0,
144+
second: Optional[int] = 0,
145+
microsecond: Optional[int] = 0,
146+
tzinfo: Optional[timezone | ZoneInfo] = None,
147+
*,
148+
fold: Optional[int] = 0,
149+
) -> "DatetimeWithSafeNone | _NoTime":
84150
"""Create a new instance of the class."""
151+
if (
152+
isinstance(year, int)
153+
and isinstance(month, int)
154+
and isinstance(day, int)
155+
and isinstance(hour, int)
156+
and isinstance(minute, int)
157+
and isinstance(second, int)
158+
and isinstance(microsecond, int)
159+
and isinstance(fold, int)
160+
):
161+
return datetime.__new__(
162+
cls,
163+
year,
164+
month,
165+
day,
166+
hour,
167+
minute,
168+
second,
169+
microsecond,
170+
tzinfo,
171+
fold=fold,
172+
)
173+
else:
174+
dt = year
85175
if dt is None:
86176
return NoTime
87177
if isinstance(dt, datetime):
@@ -98,9 +188,43 @@ def __new__(cls, dt: "OptionalDatetime") -> "DatetimeWithSafeNone | _NoTime":
98188
)
99189
if isinstance(dt, bytes):
100190
try:
101-
dt = dt.decode("utf-8")
191+
tzflag: Optional[int]
192+
year, month, day, hour, minute, second = struct.unpack(">H5B", dt[:7])
193+
microsecond, tzflag = struct.unpack("<HB", dt[7:])
194+
match tzflag:
195+
case 1:
196+
tzinfo = timezone.utc
197+
case 2: # pragma: no cover
198+
try:
199+
tzinfo = ZoneInfo(
200+
next(
201+
zone
202+
for zone in available_timezones()
203+
if "localtime" in zone
204+
)
205+
)
206+
except StopIteration:
207+
tzinfo = None
208+
case 0 | _:
209+
tzinfo = None
210+
if (
211+
isinstance(year, int)
212+
and isinstance(month, int)
213+
and isinstance(day, int)
214+
and isinstance(hour, int)
215+
and isinstance(minute, int)
216+
and isinstance(second, int)
217+
and isinstance(microsecond, int)
218+
):
219+
return datetime.__new__(
220+
cls, year, month, day, hour, minute, second, microsecond, tzinfo
221+
)
222+
else:
223+
msg = f"Unexpected type: {[type(part) for part in [year, month, day, hour, minute, second, microsecond]]}"
224+
raise TypeError(msg)
102225
except UnicodeDecodeError:
103-
error = f"Cannot decode bytes to string: {dt}"
226+
error = f"Cannot decode bytes to string: {dt!r}"
227+
raise TypeError(error)
104228
if isinstance(dt, str):
105229
try:
106230
return DatetimeWithSafeNone(datetime.fromisoformat(dt))
@@ -114,7 +238,7 @@ def __bool__(self) -> bool:
114238
"""Return True if not NoTime."""
115239
return self is not NoTime
116240

117-
def __sub__(self, other: "DatetimeWithSafeNone | _NoTime") -> datetime | timedelta:
241+
def __sub__(self, other: "DatetimeWithSafeNone | _NoTime") -> datetime | timedelta: # type: ignore[reportIncompatibleMethodOverride]
118242
"""Subtract between a datetime or timedelta or None."""
119243
return _safe_none_diff(self, other)
120244

@@ -128,6 +252,17 @@ def __str__(self) -> str:
128252
"""Return the string representation of the datetime or NoTime."""
129253
return super().__str__()
130254

255+
@staticmethod
256+
def sync_tz(
257+
one: "DatetimeWithSafeNone", two: "DatetimeWithSafeNone"
258+
) -> tuple[datetime, datetime]:
259+
"""Add timezone to other if one datetime is aware and other isn't ."""
260+
if one.tzinfo is None and two.tzinfo is not None:
261+
return one.replace(tzinfo=two.tzinfo), two
262+
if one.tzinfo is not None and two.tzinfo is None:
263+
return one, two.replace(tzinfo=one.tzinfo)
264+
return one, two
265+
131266

132267
class DatetimeJSONEncoder(json.JSONEncoder):
133268
"""JSON encoder that handles DatetimeWithSafeNone instances."""
@@ -146,7 +281,9 @@ def json_dumps(obj: Any, **kwargs) -> str:
146281
return json.dumps(obj, cls=DatetimeJSONEncoder, **kwargs)
147282

148283

149-
OptionalDatetime: TypeAlias = Optional[datetime | str | DatetimeWithSafeNone | _NoTime]
284+
OptionalDatetime: TypeAlias = Optional[
285+
datetime | str | bytes | DatetimeWithSafeNone | _NoTime
286+
]
150287
"""Type alias for a datetime, ISO-format string or None."""
151288

152289

CPAC/utils/tests/test_utils.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,16 +204,39 @@ def check_expected_keys(
204204

205205

206206
@pytest.mark.parametrize(
207-
"t1", [datetime.now(), datetime.isoformat(datetime.now()), None]
207+
"t1",
208+
[
209+
datetime.now(),
210+
datetime.now().astimezone(),
211+
datetime.isoformat(datetime.now()),
212+
None,
213+
],
208214
)
209215
@pytest.mark.parametrize(
210-
"t2", [datetime.now(), datetime.isoformat(datetime.now()), None]
216+
"t2",
217+
[
218+
datetime.now(),
219+
datetime.now().astimezone(),
220+
datetime.isoformat(datetime.now()),
221+
None,
222+
],
211223
)
212224
def test_datetime_with_safe_none(t1: OptionalDatetime, t2: OptionalDatetime):
213225
"""Test DatetimeWithSafeNone class works with datetime and None."""
226+
originals = t1, t2
214227
t1 = DatetimeWithSafeNone(t1)
215228
t2 = DatetimeWithSafeNone(t2)
216229
if t1 and t2:
230+
_tzinfos = [getattr(_, "tzinfo", None) for _ in originals]
231+
if (
232+
all(isinstance(_, datetime) for _ in originals)
233+
and any(_tzinfos)
234+
and not all(_tzinfos)
235+
):
236+
with pytest.raises(TypeError):
237+
originals[1] - originals[0] # type: ignore[reportOperatorIssue]
238+
_t1, _t2 = DatetimeWithSafeNone.sync_tz(*originals) # type: ignore[reportArgumentType]
239+
assert isinstance(_t2 - _t1, timedelta)
217240
assert isinstance(t2 - t1, timedelta)
218241
else:
219242
assert t2 - t1 == timedelta(0)

0 commit comments

Comments
 (0)