Skip to content

Commit 8fc9f84

Browse files
authored
Merge pull request #149 from immqu/datetime-scheme
Datetime versioning scheme Approved by pombredanne
2 parents 0977d84 + 18e33ac commit 8fc9f84

File tree

6 files changed

+187
-1
lines changed

6 files changed

+187
-1
lines changed

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ packaging==21.0
33
pyparsing==2.4.7
44
semantic-version==2.8.5
55
semver==2.13.0
6-
isort==5.10.1
6+
isort==5.10.1

src/univers/datetime.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#
2+
# SPDX-License-Identifier: MIT
3+
#
4+
# Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download.
5+
6+
import re
7+
from datetime import datetime
8+
from datetime import timedelta
9+
from datetime import timezone
10+
11+
12+
class DatetimeVersion:
13+
"""
14+
datetime version.
15+
16+
The timestamp must be RFC3339-compliant, i.e., a subset of ISO8601, where the date AND time are always specified. Therefore, we cannot use an ISO-parser directly, but have to check for compliance with the RFC format via a regex.
17+
"""
18+
19+
VERSION_PATTERN = re.compile(
20+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
21+
)
22+
_TIME_TZ_RE = re.compile(
23+
r"^(?P<h>\d{2}):(?P<M>\d{2}):(?P<s>\d{2})(?:\.(?P<f>\d+))?(?P<tz>Z|[+-]\d{2}:\d{2})$"
24+
)
25+
26+
def __init__(self, version):
27+
version = str(version).strip()
28+
if not self.is_valid(version):
29+
raise InvalidVersionError(version)
30+
31+
# save the original
32+
self.original = version
33+
34+
# normalize Z to +00:00 to make tz parsing uniform
35+
if version.endswith("Z"):
36+
version = version[:-1] + "+00:00"
37+
38+
# split into date and time+tz parts
39+
date_part, time_tz_part = version.split("T", 1)
40+
41+
# parse the date-only portion first using fromisoformat
42+
# (datetime.fromisoformat accepts date-only strings)
43+
try:
44+
dt = datetime.fromisoformat(date_part)
45+
except ValueError:
46+
raise InvalidVersionError(version)
47+
48+
# parse time and timezone with regex
49+
m = self._TIME_TZ_RE.fullmatch(time_tz_part)
50+
if not m:
51+
raise InvalidVersionError(version)
52+
53+
hour = int(m.group("h"))
54+
minute = int(m.group("M"))
55+
second = int(m.group("s"))
56+
frac = m.group("f") or ""
57+
# ensure microseconds length is exactly 6 (truncate or pad), because datetime requires that
58+
if frac:
59+
micro = int((frac[:6]).ljust(6, "0"))
60+
else:
61+
micro = 0
62+
63+
leap_second = second == 60
64+
if leap_second:
65+
# we can't handle second=60, so we use 59 and add one second later
66+
second = 59
67+
68+
tz_text = m.group("tz")
69+
sign = 1 if tz_text[0] == "+" else -1
70+
tzh = int(tz_text[1:3])
71+
tzm = int(tz_text[4:6])
72+
offset = sign * (tzh * 3600 + tzm * 60)
73+
tzinfo = timezone(timedelta(seconds=offset))
74+
75+
# construct aware datetime for the exact instant
76+
dt = datetime(
77+
year=dt.year,
78+
month=dt.month,
79+
day=dt.day,
80+
hour=hour,
81+
minute=minute,
82+
second=second,
83+
microsecond=micro,
84+
tzinfo=tzinfo,
85+
)
86+
87+
if leap_second:
88+
dt = dt + timedelta(seconds=1)
89+
90+
# canonicalize to UTC for comparisons/hashing
91+
self.parsed_stamp = dt.astimezone(timezone.utc)
92+
93+
def __eq__(self, other):
94+
return self.parsed_stamp == other.parsed_stamp
95+
96+
def __lt__(self, other):
97+
return self.parsed_stamp < other.parsed_stamp
98+
99+
def __le__(self, other):
100+
return self.parsed_stamp <= other.parsed_stamp
101+
102+
def __gt__(self, other):
103+
return self.parsed_stamp > other.parsed_stamp
104+
105+
def __ge__(self, other):
106+
return self.parsed_stamp >= other.parsed_stamp
107+
108+
@classmethod
109+
def is_valid(cls, string):
110+
return bool(cls.VERSION_PATTERN.fullmatch(string))
111+
112+
113+
class InvalidVersionError(ValueError):
114+
pass

src/univers/version_range.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,11 @@ class IntdotVersionRange(VersionRange):
973973
version_class = versions.IntdotVersion
974974

975975

976+
class DatetimeVersionRange(VersionRange):
977+
scheme = "datetime"
978+
version_class = versions.DatetimeVersion
979+
980+
976981
class GenericVersionRange(VersionRange):
977982
scheme = "generic"
978983
version_class = versions.SemverVersion
@@ -1451,6 +1456,7 @@ def build_range_from_snyk_advisory_string(scheme: str, string: Union[str, List])
14511456
"all": AllVersionRange,
14521457
"none": NoneVersionRange,
14531458
"intdot": IntdotVersionRange,
1459+
"datetime": DatetimeVersionRange,
14541460
"lexicographic": LexicographicVersionRange,
14551461
}
14561462

src/univers/versions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from packaging import version as packaging_version
1010

1111
from univers import arch
12+
from univers import datetime
1213
from univers import debian
1314
from univers import gem
1415
from univers import gentoo
@@ -179,6 +180,16 @@ def is_valid(cls, string):
179180
return intdot.IntdotVersion.is_valid(string)
180181

181182

183+
class DatetimeVersion(Version):
184+
@classmethod
185+
def is_valid(cls, string):
186+
return datetime.DatetimeVersion.is_valid(string)
187+
188+
@classmethod
189+
def build_value(self, string):
190+
return datetime.DatetimeVersion(string)
191+
192+
182193
class GenericVersion(Version):
183194
@classmethod
184195
def is_valid(cls, string):
@@ -749,5 +760,6 @@ def bump(self, index):
749760
LegacyOpensslVersion,
750761
AlpineLinuxVersion,
751762
IntdotVersion,
763+
DatetimeVersion,
752764
LexicographicVersion,
753765
]

tests/test_version_range.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from univers.version_constraint import VersionConstraint
1414
from univers.version_range import PURL_TYPE_BY_GITLAB_SCHEME
1515
from univers.version_range import RANGE_CLASS_BY_SCHEMES
16+
from univers.version_range import DatetimeVersionRange
1617
from univers.version_range import IntdotVersionRange
1718
from univers.version_range import InvalidVersionRange
1819
from univers.version_range import MattermostVersionRange
@@ -21,6 +22,7 @@
2122
from univers.version_range import VersionRange
2223
from univers.version_range import build_range_from_snyk_advisory_string
2324
from univers.version_range import from_gitlab_native
25+
from univers.versions import DatetimeVersion
2426
from univers.versions import IntdotVersion
2527
from univers.versions import LexicographicVersion
2628
from univers.versions import OpensslVersion
@@ -369,6 +371,46 @@ def test_version_range_intdot():
369371
assert IntdotVersion("1010.23.234203.0") in IntdotVersionRange.from_string("vers:intdot/*")
370372

371373

374+
def test_version_range_datetime():
375+
assert DatetimeVersion("2021-05-05T01:02:03.1234+00:00") == DatetimeVersion(
376+
"2021-05-05T01:02:03.1234+00:00"
377+
)
378+
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") == DatetimeVersion(
379+
"2021-05-05T01:02:03.1234Z"
380+
)
381+
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") != DatetimeVersion(
382+
"2022-05-05T01:02:03.1234Z"
383+
)
384+
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") <= DatetimeVersion(
385+
"2022-05-05T01:02:03.1234Z"
386+
)
387+
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") >= DatetimeVersion(
388+
"2020-05-05T01:02:03.1234Z"
389+
)
390+
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") > DatetimeVersion(
391+
"2020-05-05T01:02:03.1234+01:00"
392+
)
393+
assert DatetimeVersion("2000-01-01T01:02:03.1234Z") in DatetimeVersionRange.from_string(
394+
"vers:datetime/*"
395+
)
396+
assert DatetimeVersion("2021-05-05T01:02:03Z") in DatetimeVersionRange.from_string(
397+
"vers:datetime/>2021-01-01T01:02:03.1234Z|<2022-01-01T01:02:03.1234Z"
398+
)
399+
datetime_constraints = DatetimeVersionRange(
400+
constraints=(
401+
VersionConstraint(
402+
comparator=">", version=DatetimeVersion(string="2000-01-01T01:02:03Z")
403+
),
404+
VersionConstraint(
405+
comparator="<", version=DatetimeVersion(string="2002-01-01T01:02:03Z")
406+
),
407+
)
408+
)
409+
assert DatetimeVersion("2001-01-01T01:02:03Z") in datetime_constraints
410+
with pytest.raises(Exception):
411+
VersionRange.from_string("vers:datetime/2025-08-25")
412+
413+
372414
def test_version_range_lexicographic():
373415
assert LexicographicVersion("1.2.3") in VersionRange.from_string(
374416
"vers:lexicographic/<1.2.4|>0.9"

tests/test_versions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from univers.versions import AlpineLinuxVersion
99
from univers.versions import ArchLinuxVersion
1010
from univers.versions import ComposerVersion
11+
from univers.versions import DatetimeVersion
1112
from univers.versions import DebianVersion
1213
from univers.versions import EnhancedSemanticVersion
1314
from univers.versions import GentooVersion
@@ -233,6 +234,17 @@ def test_intdot_version():
233234
assert IntdotVersion("1.2.3.4.6-pre") <= IntdotVersion("2.2.3.4.5-10")
234235

235236

237+
def test_datetime_version():
238+
assert DatetimeVersion("2023-10-28T18:30:00Z") == DatetimeVersion("2023-10-28T18:30:00Z")
239+
assert DatetimeVersion("2023-01-11T10:10:10Z") > DatetimeVersion("2023-01-10T10:10:10Z")
240+
assert DatetimeVersion("2022-10-28T18:30:00Z") < DatetimeVersion("2023-10-28T18:30:00Z")
241+
assert DatetimeVersion("2022-10-28T18:30:00Z") <= DatetimeVersion("2023-10-28T18:30:00Z")
242+
assert DatetimeVersion("2024-10-28T18:30:00Z") > DatetimeVersion("2023-10-28T18:30:00Z")
243+
assert DatetimeVersion("2023-10-28T19:30:00+01:00") == DatetimeVersion("2023-10-28T18:30:00Z")
244+
assert not DatetimeVersion.is_valid("2023-10-28Z19:30:00+01:00")
245+
assert not DatetimeVersion.is_valid("10-10-2023T19:30:00+01:00")
246+
247+
236248
def test_lexicographic_version():
237249
assert LexicographicVersion("abc") == LexicographicVersion("abc")
238250
assert LexicographicVersion(" abc") == LexicographicVersion("abc")

0 commit comments

Comments
 (0)