44# Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download.
55
66import re
7- from datetime import datetime
8- from datetime import timezone
9-
7+ from datetime import datetime , timedelta , timezone
108
119class DatetimeVersion :
1210 """
@@ -18,18 +16,73 @@ class DatetimeVersion:
1816 VERSION_PATTERN = re .compile (
1917 r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
2018 )
19+ _TIME_TZ_RE = re .compile (
20+ r"^(?P<h>\d{2}):(?P<M>\d{2}):(?P<s>\d{2})(?:\.(?P<f>\d+))?(?P<tz>Z|[+-]\d{2}:\d{2})$"
21+ )
2122
2223 def __init__ (self , version ):
2324 version = str (version ).strip ()
2425 if not self .is_valid (version ):
2526 raise InvalidVersionError (version )
2627
27- # fromisoformat doesn't accept the "Z" suffix prior to 3.11, so we normalize it:
28+ # save the original
29+ self .original = version
30+
31+ # normalize Z to +00:00 to make tz parsing uniform
2832 if version .endswith ("Z" ):
2933 version = version [:- 1 ] + "+00:00"
3034
31- self .original = version
32- self .parsed_stamp = datetime .fromisoformat (version ).astimezone (timezone .utc )
35+ # split into date and time+tz parts
36+ date_part , time_tz_part = version .split ("T" , 1 )
37+
38+ # parse the date-only portion first using fromisoformat
39+ # (datetime.fromisoformat accepts date-only strings)
40+ try :
41+ dt = datetime .fromisoformat (date_part )
42+ except ValueError :
43+ raise InvalidVersionError (version )
44+
45+ # parse time and timezone with regex
46+ m = self ._TIME_TZ_RE .fullmatch (time_tz_part )
47+ if not m :
48+ raise InvalidVersionError (version )
49+
50+ hour = int (m .group ("h" ))
51+ minute = int (m .group ("M" ))
52+ second = int (m .group ("s" ))
53+ frac = m .group ("f" ) or ""
54+ # ensure microseconds length is exactly 6 (truncate or pad), because datetime requires that
55+ if frac :
56+ micro = int ((frac [:6 ]).ljust (6 , "0" ))
57+ else :
58+ micro = 0
59+
60+ tz_text = m .group ("tz" )
61+ if tz_text == "Z" :
62+ tzinfo = timezone .utc
63+ else :
64+ # tz_text is in form +HH:MM or -HH:MM
65+ sign = 1 if tz_text [0 ] == "+" else - 1
66+ tzh = int (tz_text [1 :3 ])
67+ tzm = int (tz_text [4 :6 ])
68+ offset = sign * (tzh * 3600 + tzm * 60 )
69+ tzinfo = timezone (timedelta (seconds = offset ))
70+
71+ # construct aware datetime for the exact instant
72+ dt = datetime (
73+ year = dt .year ,
74+ month = dt .month ,
75+ day = dt .day ,
76+ hour = hour ,
77+ minute = minute ,
78+ second = second ,
79+ microsecond = micro ,
80+ tzinfo = tzinfo ,
81+ )
82+
83+ # canonicalize to UTC for comparisons/hashing
84+ self .parsed_stamp = dt .astimezone (timezone .utc )
85+
3386
3487 def __eq__ (self , other ):
3588 return self .parsed_stamp == other .parsed_stamp
0 commit comments