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
2020import glob
2121import json
2222import math
2323import os
2424import socketserver
25+ import struct
2526import threading
26- from typing import Any , Optional , TypeAlias
27+ from typing import Any , Optional , overload , TypeAlias
28+ from zoneinfo import available_timezones , ZoneInfo
2729
2830import networkx as nx
2931from 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
7682NoTime = _NoTime ()
7783"""A singleton None that can be used in place of a datetime object."""
7884
7985
8086class 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
132267class 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
0 commit comments