From 992b3c418b0cc69f6a082914b422ab4c0422a430 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Fri, 22 Nov 2024 12:05:41 +0100 Subject: [PATCH 01/12] datetime versioning scheme Signed-off-by: Kunz, Immanuel --- requirements.txt | 1 + src/univers/datetime.py | 46 ++++++++++++++++++++++++++++++++++++ src/univers/version_range.py | 6 +++++ src/univers/versions.py | 12 ++++++++++ tests/test_version_range.py | 13 ++++++++++ tests/test_versions.py | 10 ++++++++ 6 files changed, 88 insertions(+) create mode 100644 src/univers/datetime.py diff --git a/requirements.txt b/requirements.txt index f253417d..f46d45d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pyparsing==2.4.7 semantic-version==2.8.5 semver==2.13.0 isort==5.10.1 +python-dateutil==2.9.0.post0 \ No newline at end of file diff --git a/src/univers/datetime.py b/src/univers/datetime.py new file mode 100644 index 00000000..8ea1517b --- /dev/null +++ b/src/univers/datetime.py @@ -0,0 +1,46 @@ +# +# SPDX-License-Identifier: MIT +# +# Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download. + +import re +from dateutil.parser import isoparse + +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 can use dateutil's ISO-parser but have to check for compliance with the RFC format first 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})$') + + def __init__(self, version): + if not self.is_valid(version): + raise InvalidVersionError(version) + + version = str(version).strip() + self.original = version + self.parsed_stamp = isoparse(version) + + 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 cls.VERSION_PATTERN.match(string) + +class InvalidVersionError(ValueError): + pass \ No newline at end of file diff --git a/src/univers/version_range.py b/src/univers/version_range.py index 76539006..582ca00c 100644 --- a/src/univers/version_range.py +++ b/src/univers/version_range.py @@ -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 @@ -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 = { diff --git a/src/univers/versions.py b/src/univers/versions.py index cf8c0bc7..0653cf9c 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -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 @@ -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): @@ -726,4 +737,5 @@ def bump(self, index): LegacyOpensslVersion, AlpineLinuxVersion, IntdotVersion, + DatetimeVersion, ] diff --git a/tests/test_version_range.py b/tests/test_version_range.py index 16cac2dc..e9a748be 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -366,3 +366,16 @@ 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("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 + diff --git a/tests/test_versions.py b/tests/test_versions.py index 7ceb80cb..3ccdfa96 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -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 @@ -230,3 +231,12 @@ 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") From f2cd21c57d9c9346ecbd21dbf04be82b11fc5d5f Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Fri, 22 Nov 2024 12:07:10 +0100 Subject: [PATCH 02/12] code style Signed-off-by: Kunz, Immanuel --- src/univers/datetime.py | 11 ++++++++--- src/univers/versions.py | 2 +- tests/test_version_range.py | 16 ++++++++++++---- tests/test_versions.py | 4 ++-- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/univers/datetime.py b/src/univers/datetime.py index 8ea1517b..d7ef8c62 100644 --- a/src/univers/datetime.py +++ b/src/univers/datetime.py @@ -4,8 +4,10 @@ # Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download. import re + from dateutil.parser import isoparse + class DatetimeVersion: """ datetime version. @@ -13,7 +15,9 @@ class DatetimeVersion: The timestamp must be RFC3339-compliant, i.e., a subset of ISO8601, where the date AND time are always specified. Therefore, we can use dateutil's ISO-parser but have to check for compliance with the RFC format first 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})$') + VERSION_PATTERN = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$" + ) def __init__(self, version): if not self.is_valid(version): @@ -41,6 +45,7 @@ def __ge__(self, other): @classmethod def is_valid(cls, string): return cls.VERSION_PATTERN.match(string) - + + class InvalidVersionError(ValueError): - pass \ No newline at end of file + pass diff --git a/src/univers/versions.py b/src/univers/versions.py index 0653cf9c..aa1136d7 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -161,7 +161,7 @@ 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) diff --git a/tests/test_version_range.py b/tests/test_version_range.py index e9a748be..6bf04287 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -369,12 +369,20 @@ def test_version_range_intdot(): def test_version_range_datetime(): - 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") + 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")), + 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 diff --git a/tests/test_versions.py b/tests/test_versions.py index 3ccdfa96..e93cec28 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -234,9 +234,9 @@ def test_intdot_version(): def test_datetime_version(): - assert DatetimeVersion("2023-10-28T18:30:00Z") == DatetimeVersion("2023-10-28T18:30:00Z") + 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("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") From 794fc6b1cd669269e1c3b72dcee7e5e80bcbc7e3 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Mon, 25 Aug 2025 14:24:36 +0200 Subject: [PATCH 03/12] code style Signed-off-by: Kunz, Immanuel --- tests/test_version_range.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_version_range.py b/tests/test_version_range.py index 6bf04287..6c533f2a 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -386,4 +386,3 @@ def test_version_range_datetime(): ) ) assert DatetimeVersion("2001-01-01T01:02:03Z") in datetime_constraints - From 7c4427d4bba47cb5293f116d0261e3cb10ab2e58 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Mon, 25 Aug 2025 15:45:56 +0200 Subject: [PATCH 04/12] add tests Signed-off-by: Kunz, Immanuel --- tests/test_version_range.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_version_range.py b/tests/test_version_range.py index 6c533f2a..af5bfc49 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -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 @@ -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 @@ -369,6 +371,10 @@ def test_version_range_intdot(): def test_version_range_datetime(): + 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("2000-01-01T01:02:03.1234Z") in DatetimeVersionRange.from_string( "vers:datetime/*" ) @@ -386,3 +392,5 @@ def test_version_range_datetime(): ) ) assert DatetimeVersion("2001-01-01T01:02:03Z") in datetime_constraints + with pytest.raises(Exception): + VersionRange.from_string("vers:datetime/2025-08-25") \ No newline at end of file From e12f69fff70a5986ef1d1c3927b86f0a4b5cc059 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Mon, 25 Aug 2025 16:03:09 +0200 Subject: [PATCH 05/12] add dateutil to setup.cfg Signed-off-by: Kunz, Immanuel --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 22589698..4b65218a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,6 +63,7 @@ install_requires = packaging semantic-version semver + python-dateutil [options.packages.find] From 64aecfb14b945d834fb1645da05a2dc0f1be85c8 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Mon, 25 Aug 2025 16:07:04 +0200 Subject: [PATCH 06/12] code style Signed-off-by: Kunz, Immanuel --- tests/test_version_range.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/test_version_range.py b/tests/test_version_range.py index af5bfc49..fbfb3736 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -371,10 +371,18 @@ def test_version_range_intdot(): def test_version_range_datetime(): - 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( + "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("2000-01-01T01:02:03.1234Z") in DatetimeVersionRange.from_string( "vers:datetime/*" ) @@ -393,4 +401,4 @@ def test_version_range_datetime(): ) assert DatetimeVersion("2001-01-01T01:02:03Z") in datetime_constraints with pytest.raises(Exception): - VersionRange.from_string("vers:datetime/2025-08-25") \ No newline at end of file + VersionRange.from_string("vers:datetime/2025-08-25") From 249ecf0174f63dbe776ee6d59251837d14392b32 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Fri, 5 Sep 2025 14:29:19 +0200 Subject: [PATCH 07/12] use standard lib datetime instead of third party library Signed-off-by: Kunz, Immanuel --- requirements.txt | 3 +-- setup.cfg | 1 - src/univers/datetime.py | 14 +++++++++----- tests/test_version_range.py | 3 +++ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index f46d45d1..c8a6391c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,4 @@ packaging==21.0 pyparsing==2.4.7 semantic-version==2.8.5 semver==2.13.0 -isort==5.10.1 -python-dateutil==2.9.0.post0 \ No newline at end of file +isort==5.10.1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 4b65218a..22589698 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,7 +63,6 @@ install_requires = packaging semantic-version semver - python-dateutil [options.packages.find] diff --git a/src/univers/datetime.py b/src/univers/datetime.py index d7ef8c62..f5872f46 100644 --- a/src/univers/datetime.py +++ b/src/univers/datetime.py @@ -5,14 +5,14 @@ import re -from dateutil.parser import isoparse +from datetime import datetime, 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 can use dateutil's ISO-parser but have to check for compliance with the RFC format first via a regex. + 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( @@ -20,12 +20,16 @@ class DatetimeVersion: ) def __init__(self, version): + version = str(version).strip() if not self.is_valid(version): raise InvalidVersionError(version) - version = str(version).strip() + # fromisoformat doesn't accept the "Z" suffix prior to 3.11, so we normalize it: + if version.endswith("Z"): + version = version[:-1] + "+00:00" + self.original = version - self.parsed_stamp = isoparse(version) + self.parsed_stamp = datetime.fromisoformat(version).astimezone(timezone.utc) def __eq__(self, other): return self.parsed_stamp == other.parsed_stamp @@ -44,7 +48,7 @@ def __ge__(self, other): @classmethod def is_valid(cls, string): - return cls.VERSION_PATTERN.match(string) + return bool(cls.VERSION_PATTERN.fullmatch(string)) class InvalidVersionError(ValueError): diff --git a/tests/test_version_range.py b/tests/test_version_range.py index fbfb3736..4a18d194 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -383,6 +383,9 @@ def test_version_range_datetime(): 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/*" ) From 6bd905292720c2b2ecc687895bb88a775e22a6b2 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Fri, 5 Sep 2025 14:30:11 +0200 Subject: [PATCH 08/12] code style Signed-off-by: Kunz, Immanuel --- src/univers/datetime.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/univers/datetime.py b/src/univers/datetime.py index f5872f46..fff5b496 100644 --- a/src/univers/datetime.py +++ b/src/univers/datetime.py @@ -4,8 +4,8 @@ # Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download. import re - -from datetime import datetime, timezone +from datetime import datetime +from datetime import timezone class DatetimeVersion: @@ -27,7 +27,7 @@ def __init__(self, version): # fromisoformat doesn't accept the "Z" suffix prior to 3.11, so we normalize it: if version.endswith("Z"): version = version[:-1] + "+00:00" - + self.original = version self.parsed_stamp = datetime.fromisoformat(version).astimezone(timezone.utc) From 45665de4c878d07849211e07212e8e828f09dade Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Tue, 16 Sep 2025 15:42:52 +0200 Subject: [PATCH 09/12] reimplement datetime parsing logic Signed-off-by: Kunz, Immanuel --- src/univers/datetime.py | 65 +++++++++++++++++++++++++++++++++---- tests/test_version_range.py | 3 ++ tests/test_versions.py | 2 ++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/univers/datetime.py b/src/univers/datetime.py index fff5b496..665920bd 100644 --- a/src/univers/datetime.py +++ b/src/univers/datetime.py @@ -4,9 +4,7 @@ # Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download. import re -from datetime import datetime -from datetime import timezone - +from datetime import datetime, timedelta, timezone class DatetimeVersion: """ @@ -18,18 +16,73 @@ class DatetimeVersion: 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\d{2}):(?P\d{2}):(?P\d{2})(?:\.(?P\d+))?(?PZ|[+-]\d{2}:\d{2})$" + ) def __init__(self, version): version = str(version).strip() if not self.is_valid(version): raise InvalidVersionError(version) - # fromisoformat doesn't accept the "Z" suffix prior to 3.11, so we normalize it: + # 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" - self.original = version - self.parsed_stamp = datetime.fromisoformat(version).astimezone(timezone.utc) + # 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 + + tz_text = m.group("tz") + if tz_text == "Z": + tzinfo = timezone.utc + else: + # tz_text is in form +HH:MM or -HH:MM + 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, + ) + + # canonicalize to UTC for comparisons/hashing + self.parsed_stamp = dt.astimezone(timezone.utc) + def __eq__(self, other): return self.parsed_stamp == other.parsed_stamp diff --git a/tests/test_version_range.py b/tests/test_version_range.py index 4a18d194..bb278e74 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -371,6 +371,9 @@ def test_version_range_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" ) diff --git a/tests/test_versions.py b/tests/test_versions.py index e93cec28..62c7d2e2 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -240,3 +240,5 @@ def test_datetime_version(): 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") \ No newline at end of file From 93be4480cbda32d8802002ecc81b5806b8ebaffc Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Tue, 16 Sep 2025 15:43:52 +0200 Subject: [PATCH 10/12] code style Signed-off-by: Kunz, Immanuel --- src/univers/datetime.py | 6 ++++-- tests/test_versions.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/univers/datetime.py b/src/univers/datetime.py index 665920bd..59d031a8 100644 --- a/src/univers/datetime.py +++ b/src/univers/datetime.py @@ -4,7 +4,10 @@ # Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download. import re -from datetime import datetime, timedelta, timezone +from datetime import datetime +from datetime import timedelta +from datetime import timezone + class DatetimeVersion: """ @@ -83,7 +86,6 @@ def __init__(self, version): # canonicalize to UTC for comparisons/hashing self.parsed_stamp = dt.astimezone(timezone.utc) - def __eq__(self, other): return self.parsed_stamp == other.parsed_stamp diff --git a/tests/test_versions.py b/tests/test_versions.py index 62c7d2e2..e876318f 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -241,4 +241,4 @@ def test_datetime_version(): 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") \ No newline at end of file + assert not DatetimeVersion.is_valid("10-10-2023T19:30:00+01:00") From 8725fee6beeb3fab10b5349c35c5659e42fcaf36 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Tue, 16 Sep 2025 16:08:20 +0200 Subject: [PATCH 11/12] parse leap second correctly Signed-off-by: Kunz, Immanuel --- src/univers/datetime.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/univers/datetime.py b/src/univers/datetime.py index 59d031a8..4dcc9bcb 100644 --- a/src/univers/datetime.py +++ b/src/univers/datetime.py @@ -60,16 +60,18 @@ def __init__(self, version): 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") - if tz_text == "Z": - tzinfo = timezone.utc - else: - # tz_text is in form +HH:MM or -HH:MM - 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)) + 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( @@ -83,6 +85,9 @@ def __init__(self, version): tzinfo=tzinfo, ) + if leap_second: + dt = dt + timedelta(seconds=1) + # canonicalize to UTC for comparisons/hashing self.parsed_stamp = dt.astimezone(timezone.utc) From ec39593fd4a51f9515565cc922dfc1e40dcfa5d9 Mon Sep 17 00:00:00 2001 From: "Kunz, Immanuel" Date: Tue, 16 Sep 2025 16:08:35 +0200 Subject: [PATCH 12/12] code style Signed-off-by: Kunz, Immanuel --- src/univers/datetime.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/univers/datetime.py b/src/univers/datetime.py index 4dcc9bcb..264c164d 100644 --- a/src/univers/datetime.py +++ b/src/univers/datetime.py @@ -60,12 +60,11 @@ def __init__(self, version): else: micro = 0 - leap_second = (second == 60) + 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])