Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ packaging==21.0
pyparsing==2.4.7
semantic-version==2.8.5
semver==2.13.0
isort==5.10.1
isort==5.10.1
114 changes: 114 additions & 0 deletions src/univers/datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#
# SPDX-License-Identifier: MIT
#
# Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download.

import re
from datetime import datetime
from datetime import timedelta
from datetime import timezone


class DatetimeVersion:
"""
datetime version.

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.
"""

VERSION_PATTERN = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
)
_TIME_TZ_RE = re.compile(
r"^(?P<h>\d{2}):(?P<M>\d{2}):(?P<s>\d{2})(?:\.(?P<f>\d+))?(?P<tz>Z|[+-]\d{2}:\d{2})$"
)

def __init__(self, version):
version = str(version).strip()
if not self.is_valid(version):
raise InvalidVersionError(version)

# save the original
self.original = version

# normalize Z to +00:00 to make tz parsing uniform
if version.endswith("Z"):
version = version[:-1] + "+00:00"

# split into date and time+tz parts
date_part, time_tz_part = version.split("T", 1)

# parse the date-only portion first using fromisoformat
# (datetime.fromisoformat accepts date-only strings)
try:
dt = datetime.fromisoformat(date_part)
except ValueError:
raise InvalidVersionError(version)

# parse time and timezone with regex
m = self._TIME_TZ_RE.fullmatch(time_tz_part)
if not m:
raise InvalidVersionError(version)

hour = int(m.group("h"))
minute = int(m.group("M"))
second = int(m.group("s"))
frac = m.group("f") or ""
# ensure microseconds length is exactly 6 (truncate or pad), because datetime requires that
if frac:
micro = int((frac[:6]).ljust(6, "0"))
else:
micro = 0

leap_second = second == 60
if leap_second:
# we can't handle second=60, so we use 59 and add one second later
second = 59

tz_text = m.group("tz")
sign = 1 if tz_text[0] == "+" else -1
tzh = int(tz_text[1:3])
tzm = int(tz_text[4:6])
offset = sign * (tzh * 3600 + tzm * 60)
tzinfo = timezone(timedelta(seconds=offset))

# construct aware datetime for the exact instant
dt = datetime(
year=dt.year,
month=dt.month,
day=dt.day,
hour=hour,
minute=minute,
second=second,
microsecond=micro,
tzinfo=tzinfo,
)

if leap_second:
dt = dt + timedelta(seconds=1)

# canonicalize to UTC for comparisons/hashing
self.parsed_stamp = dt.astimezone(timezone.utc)

def __eq__(self, other):
return self.parsed_stamp == other.parsed_stamp

def __lt__(self, other):
return self.parsed_stamp < other.parsed_stamp

def __le__(self, other):
return self.parsed_stamp <= other.parsed_stamp

def __gt__(self, other):
return self.parsed_stamp > other.parsed_stamp

def __ge__(self, other):
return self.parsed_stamp >= other.parsed_stamp

@classmethod
def is_valid(cls, string):
return bool(cls.VERSION_PATTERN.fullmatch(string))


class InvalidVersionError(ValueError):
pass
6 changes: 6 additions & 0 deletions src/univers/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,11 @@ class IntdotVersionRange(VersionRange):
version_class = versions.IntdotVersion


class DatetimeVersionRange(VersionRange):
scheme = "datetime"
version_class = versions.DatetimeVersion


class GenericVersionRange(VersionRange):
scheme = "generic"
version_class = versions.SemverVersion
Expand Down Expand Up @@ -1446,6 +1451,7 @@ def build_range_from_snyk_advisory_string(scheme: str, string: Union[str, List])
"all": AllVersionRange,
"none": NoneVersionRange,
"intdot": IntdotVersionRange,
"datetime": DatetimeVersionRange,
}

PURL_TYPE_BY_GITLAB_SCHEME = {
Expand Down
12 changes: 12 additions & 0 deletions src/univers/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from packaging import version as packaging_version

from univers import arch
from univers import datetime
from univers import debian
from univers import gem
from univers import gentoo
Expand Down Expand Up @@ -156,6 +157,16 @@ def is_valid(cls, string):
return intdot.IntdotVersion.is_valid(string)


class DatetimeVersion(Version):
@classmethod
def is_valid(cls, string):
return datetime.DatetimeVersion.is_valid(string)

@classmethod
def build_value(self, string):
return datetime.DatetimeVersion(string)


class GenericVersion(Version):
@classmethod
def is_valid(cls, string):
Expand Down Expand Up @@ -726,4 +737,5 @@ def bump(self, index):
LegacyOpensslVersion,
AlpineLinuxVersion,
IntdotVersion,
DatetimeVersion,
]
42 changes: 42 additions & 0 deletions tests/test_version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from univers.version_constraint import VersionConstraint
from univers.version_range import PURL_TYPE_BY_GITLAB_SCHEME
from univers.version_range import RANGE_CLASS_BY_SCHEMES
from univers.version_range import DatetimeVersionRange
from univers.version_range import IntdotVersionRange
from univers.version_range import InvalidVersionRange
from univers.version_range import MattermostVersionRange
Expand All @@ -21,6 +22,7 @@
from univers.version_range import VersionRange
from univers.version_range import build_range_from_snyk_advisory_string
from univers.version_range import from_gitlab_native
from univers.versions import DatetimeVersion
from univers.versions import IntdotVersion
from univers.versions import OpensslVersion
from univers.versions import PypiVersion
Expand Down Expand Up @@ -366,3 +368,43 @@ def test_version_range_intdot():
assert IntdotVersion("1.3.3alpha") in intdot_range
assert IntdotVersion("1.2.2.pre") not in intdot_range
assert IntdotVersion("1010.23.234203.0") in IntdotVersionRange.from_string("vers:intdot/*")


def test_version_range_datetime():
assert DatetimeVersion("2021-05-05T01:02:03.1234+00:00") == DatetimeVersion(
"2021-05-05T01:02:03.1234+00:00"
)
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") == DatetimeVersion(
"2021-05-05T01:02:03.1234Z"
)
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") != DatetimeVersion(
"2022-05-05T01:02:03.1234Z"
)
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") <= DatetimeVersion(
"2022-05-05T01:02:03.1234Z"
)
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") >= DatetimeVersion(
"2020-05-05T01:02:03.1234Z"
)
assert DatetimeVersion("2021-05-05T01:02:03.1234Z") > DatetimeVersion(
"2020-05-05T01:02:03.1234+01:00"
)
assert DatetimeVersion("2000-01-01T01:02:03.1234Z") in DatetimeVersionRange.from_string(
"vers:datetime/*"
)
assert DatetimeVersion("2021-05-05T01:02:03Z") in DatetimeVersionRange.from_string(
"vers:datetime/>2021-01-01T01:02:03.1234Z|<2022-01-01T01:02:03.1234Z"
)
datetime_constraints = DatetimeVersionRange(
constraints=(
VersionConstraint(
comparator=">", version=DatetimeVersion(string="2000-01-01T01:02:03Z")
),
VersionConstraint(
comparator="<", version=DatetimeVersion(string="2002-01-01T01:02:03Z")
),
)
)
assert DatetimeVersion("2001-01-01T01:02:03Z") in datetime_constraints
with pytest.raises(Exception):
VersionRange.from_string("vers:datetime/2025-08-25")
12 changes: 12 additions & 0 deletions tests/test_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from univers.versions import AlpineLinuxVersion
from univers.versions import ArchLinuxVersion
from univers.versions import ComposerVersion
from univers.versions import DatetimeVersion
from univers.versions import DebianVersion
from univers.versions import EnhancedSemanticVersion
from univers.versions import GentooVersion
Expand Down Expand Up @@ -230,3 +231,14 @@ def test_intdot_version():
assert IntdotVersion("1.2.3.4.6-pre") <= IntdotVersion("2.2.3.4.5.pre")
assert IntdotVersion("1.2.3.4.6-pre") <= IntdotVersion("2.2.3.4.5-10")
assert IntdotVersion("1.2.3.4.6-pre") <= IntdotVersion("2.2.3.4.5-10")


def test_datetime_version():
assert DatetimeVersion("2023-10-28T18:30:00Z") == DatetimeVersion("2023-10-28T18:30:00Z")
assert DatetimeVersion("2023-01-11T10:10:10Z") > DatetimeVersion("2023-01-10T10:10:10Z")
assert DatetimeVersion("2022-10-28T18:30:00Z") < DatetimeVersion("2023-10-28T18:30:00Z")
assert DatetimeVersion("2022-10-28T18:30:00Z") <= DatetimeVersion("2023-10-28T18:30:00Z")
assert DatetimeVersion("2024-10-28T18:30:00Z") > DatetimeVersion("2023-10-28T18:30:00Z")
assert DatetimeVersion("2023-10-28T19:30:00+01:00") == DatetimeVersion("2023-10-28T18:30:00Z")
assert not DatetimeVersion.is_valid("2023-10-28Z19:30:00+01:00")
assert not DatetimeVersion.is_valid("10-10-2023T19:30:00+01:00")