]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Split out GenericVersion into its own module
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Sun, 6 Aug 2023 12:17:27 +0000 (14:17 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Sun, 6 Aug 2023 17:30:31 +0000 (19:30 +0200)
mkosi/__init__.py
mkosi/config.py
mkosi/mounts.py
mkosi/versioncomp.py [new file with mode: 0644]
tests/test_config.py
tests/test_versioncomp.py [new file with mode: 0644]

index a927a6a4c53efecb63cd2eaa125747a35a52987c..f847a5b367de7a086c672e701df3d37e1672662d 100644 (file)
@@ -23,7 +23,6 @@ from mkosi.archive import extract_tar, make_cpio, make_tar
 from mkosi.config import (
     Compression,
     ConfigFeature,
-    GenericVersion,
     ManifestFormat,
     MkosiArgs,
     MkosiConfig,
@@ -56,6 +55,7 @@ from mkosi.util import (
     try_import,
     umask,
 )
+from mkosi.versioncomp import GenericVersion
 
 
 @contextlib.contextmanager
index 6050d5b68d6f7ae1c3d89e0249c3785e05fee259..f0ea7aa8c5cdd1c6c260ef49d938a9224751faff 100644 (file)
@@ -19,7 +19,6 @@ import subprocess
 import sys
 import textwrap
 from collections.abc import Iterable, Sequence
-from itertools import takewhile
 from pathlib import Path
 from typing import Any, Callable, Optional, Type, Union, cast
 
@@ -37,6 +36,7 @@ from mkosi.util import (
     qemu_check_kvm_support,
     qemu_check_vsock_support,
 )
+from mkosi.versioncomp import GenericVersion
 
 __version__ = "14"
 
@@ -1836,171 +1836,6 @@ class MkosiConfigParser:
         return args, tuple(load_config(ns) for ns in presets)
 
 
-class GenericVersion:
-    # These constants follow the convention of the return value of rpmdev-vercmp that are followe
-    # by systemd-analyze compare-versions when called with only two arguments (without a comparison
-    # operator), recreated in the compare_versions method.
-    _EQUAL = 0
-    _RIGHT_SMALLER = 1
-    _LEFT_SMALLER = -1
-
-    def __init__(self, version: str):
-        self._version = version
-
-    @classmethod
-    def compare_versions(cls, v1: str, v2: str) -> int:
-        """Implements comparison according to UAPI Group Version Format Specification"""
-        def rstrip_invalid_version_chars(s: str) -> str:
-            valid_version_chars = {*string.ascii_letters, *string.digits, "~", "-", "^", "."}
-            for i, c in enumerate(s):
-                if c in valid_version_chars:
-                    return s[i:]
-            return ""
-
-        def digit_prefix(s: str) -> str:
-            return "".join(takewhile(lambda c: c in string.digits, s))
-
-        def letter_prefix(s: str) -> str:
-            return "".join(takewhile(lambda c: c in string.ascii_letters, s))
-
-        while True:
-            # Any characters which are outside of the set of listed above (a-z, A-Z, 0-9, -, ., ~,
-            # ^) are skipped in both strings. In particular, this means that non-ASCII characters
-            # that are Unicode digits or letters are skipped too.
-            v1 = rstrip_invalid_version_chars(v1)
-            v2 = rstrip_invalid_version_chars(v2)
-
-            # If the remaining part of one of strings starts with "~": if other remaining part does
-            # not start with ~, the string with ~ compares lower. Otherwise, both tilde characters
-            # are skipped.
-
-            if v1.startswith("~") and v2.startswith("~"):
-                v1 = v1.removeprefix("~")
-                v2 = v2.removeprefix("~")
-            elif v1.startswith("~"):
-                return cls._LEFT_SMALLER
-            elif v2.startswith("~"):
-                return cls._RIGHT_SMALLER
-
-            # If one of the strings has ended: if the other string hasn’t, the string that has
-            # remaining characters compares higher. Otherwise, the strings compare equal.
-
-            if not v1 and not v2:
-                return cls._EQUAL
-            elif not v1 and v2:
-                return cls._LEFT_SMALLER
-            elif v1 and not v2:
-                return cls._RIGHT_SMALLER
-
-            # If the remaining part of one of strings starts with "-": if the other remaining part
-            # does not start with -, the string with - compares lower. Otherwise, both minus
-            # characters are skipped.
-
-            if v1.startswith("-") and v2.startswith("-"):
-                v1 = v1.removeprefix("-")
-                v2 = v2.removeprefix("-")
-            elif v1.startswith("-"):
-                return cls._LEFT_SMALLER
-            elif v2.startswith("-"):
-                return cls._RIGHT_SMALLER
-
-            # If the remaining part of one of strings starts with "^": if the other remaining part
-            # does not start with ^, the string with ^ compares higher. Otherwise, both caret
-            # characters are skipped.
-
-            if v1.startswith("^") and v2.startswith("^"):
-                v1 = v1.removeprefix("^")
-                v2 = v2.removeprefix("^")
-            elif v1.startswith("^"):
-                # TODO: bug?
-                return cls._LEFT_SMALLER  #cls._RIGHT_SMALLER
-            elif v2.startswith("^"):
-                return cls._RIGHT_SMALLER #cls._LEFT_SMALLER
-
-            # If the remaining part of one of strings starts with ".": if the other remaining part
-            # does not start with ., the string with . compares lower. Otherwise, both dot
-            # characters are skipped.
-
-            if v1.startswith(".") and v2.startswith("."):
-                v1 = v1.removeprefix(".")
-                v2 = v2.removeprefix(".")
-            elif v1.startswith("."):
-                return cls._LEFT_SMALLER
-            elif v2.startswith("."):
-                return cls._RIGHT_SMALLER
-
-            # If either of the remaining parts starts with a digit: numerical prefixes are compared
-            # numerically. Any leading zeroes are skipped. The numerical prefixes (until the first
-            # non-digit character) are evaluated as numbers. If one of the prefixes is empty, it
-            # evaluates as 0. If the numbers are different, the string with the bigger number
-            # compares higher. Otherwise, the comparison continues at the following characters at
-            # point 1.
-
-            v1_digit_prefix = digit_prefix(v1)
-            v2_digit_prefix = digit_prefix(v2)
-
-            if v1_digit_prefix or v2_digit_prefix:
-                v1_digits = int(v1_digit_prefix) if v1_digit_prefix else 0
-                v2_digits = int(v2_digit_prefix) if v2_digit_prefix else 0
-
-                if v1_digits < v2_digits:
-                    return cls._LEFT_SMALLER
-                elif v1_digits > v2_digits:
-                    return cls._RIGHT_SMALLER
-
-                v1 = v1.removeprefix(v1_digit_prefix)
-                v2 = v2.removeprefix(v2_digit_prefix)
-                continue
-
-            # Leading alphabetical prefixes are compared alphabetically. The substrings are
-            # compared letter-by-letter. If both letters are the same, the comparison continues
-            # with the next letter. Capital letters compare lower than lower-case letters (A <
-            # a). When the end of one substring has been reached (a non-letter character or the end
-            # of the whole string), if the other substring has remaining letters, it compares
-            # higher. Otherwise, the comparison continues at the following characters at point 1.
-
-            v1_letter_prefix = letter_prefix(v1)
-            v2_letter_prefix = letter_prefix(v2)
-
-            if v1_letter_prefix < v2_letter_prefix:
-                return cls._LEFT_SMALLER
-            elif v1_letter_prefix > v2_letter_prefix:
-                return cls._RIGHT_SMALLER
-
-            v1 = v1.removeprefix(v1_letter_prefix)
-            v2 = v2.removeprefix(v2_letter_prefix)
-
-    def __eq__(self, other: object) -> bool:
-        if not isinstance(other, GenericVersion):
-            return False
-        return self.compare_versions(self._version, other._version) == self._EQUAL
-
-    def __ne__(self, other: object) -> bool:
-        if not isinstance(other, GenericVersion):
-            return False
-        return self.compare_versions(self._version, other._version) != self._EQUAL
-
-    def __lt__(self, other: object) -> bool:
-        if not isinstance(other, GenericVersion):
-            return False
-        return self.compare_versions(self._version, other._version) == self._LEFT_SMALLER
-
-    def __le__(self, other: object) -> bool:
-        if not isinstance(other, GenericVersion):
-            return False
-        return self.compare_versions(self._version, other._version) in (self._EQUAL, self._LEFT_SMALLER)
-
-    def __gt__(self, other: object) -> bool:
-        if not isinstance(other, GenericVersion):
-            return False
-        return self.compare_versions(self._version, other._version) == self._RIGHT_SMALLER
-
-    def __ge__(self, other: object) -> bool:
-        if not isinstance(other, GenericVersion):
-            return False
-        return self.compare_versions(self._version, other._version) in (self._EQUAL, self._RIGHT_SMALLER)
-
-
 def load_credentials(args: argparse.Namespace) -> dict[str, str]:
     creds = {}
 
index fbf2878255c3282deed8b02239df37fe0a1e1af0..cfb03d6aa7f53831ec8fa74e6ded2285a1c4cbd5 100644 (file)
@@ -9,11 +9,11 @@ from collections.abc import Iterator, Sequence
 from pathlib import Path
 from typing import Optional, TypeVar
 
-from mkosi.config import GenericVersion
 from mkosi.log import complete_step
 from mkosi.run import run
 from mkosi.types import PathString
 from mkosi.util import umask
+from mkosi.versioncomp import GenericVersion
 
 T = TypeVar("T")
 
diff --git a/mkosi/versioncomp.py b/mkosi/versioncomp.py
new file mode 100644 (file)
index 0000000..3079f92
--- /dev/null
@@ -0,0 +1,169 @@
+# SPDX-License-Identifier: LGPL-2.1+
+
+import string
+from itertools import takewhile
+
+
+class GenericVersion:
+    # These constants follow the convention of the return value of rpmdev-vercmp that are followe
+    # by systemd-analyze compare-versions when called with only two arguments (without a comparison
+    # operator), recreated in the compare_versions method.
+    _EQUAL = 0
+    _RIGHT_SMALLER = 1
+    _LEFT_SMALLER = -1
+
+    def __init__(self, version: str):
+        self._version = version
+
+    @classmethod
+    def compare_versions(cls, v1: str, v2: str) -> int:
+        """Implements comparison according to UAPI Group Version Format Specification"""
+        def rstrip_invalid_version_chars(s: str) -> str:
+            valid_version_chars = {*string.ascii_letters, *string.digits, "~", "-", "^", "."}
+            for i, c in enumerate(s):
+                if c in valid_version_chars:
+                    return s[i:]
+            return ""
+
+        def digit_prefix(s: str) -> str:
+            return "".join(takewhile(lambda c: c in string.digits, s))
+
+        def letter_prefix(s: str) -> str:
+            return "".join(takewhile(lambda c: c in string.ascii_letters, s))
+
+        while True:
+            # Any characters which are outside of the set of listed above (a-z, A-Z, 0-9, -, ., ~,
+            # ^) are skipped in both strings. In particular, this means that non-ASCII characters
+            # that are Unicode digits or letters are skipped too.
+            v1 = rstrip_invalid_version_chars(v1)
+            v2 = rstrip_invalid_version_chars(v2)
+
+            # If the remaining part of one of strings starts with "~": if other remaining part does
+            # not start with ~, the string with ~ compares lower. Otherwise, both tilde characters
+            # are skipped.
+
+            if v1.startswith("~") and v2.startswith("~"):
+                v1 = v1.removeprefix("~")
+                v2 = v2.removeprefix("~")
+            elif v1.startswith("~"):
+                return cls._LEFT_SMALLER
+            elif v2.startswith("~"):
+                return cls._RIGHT_SMALLER
+
+            # If one of the strings has ended: if the other string hasn’t, the string that has
+            # remaining characters compares higher. Otherwise, the strings compare equal.
+
+            if not v1 and not v2:
+                return cls._EQUAL
+            elif not v1 and v2:
+                return cls._LEFT_SMALLER
+            elif v1 and not v2:
+                return cls._RIGHT_SMALLER
+
+            # If the remaining part of one of strings starts with "-": if the other remaining part
+            # does not start with -, the string with - compares lower. Otherwise, both minus
+            # characters are skipped.
+
+            if v1.startswith("-") and v2.startswith("-"):
+                v1 = v1.removeprefix("-")
+                v2 = v2.removeprefix("-")
+            elif v1.startswith("-"):
+                return cls._LEFT_SMALLER
+            elif v2.startswith("-"):
+                return cls._RIGHT_SMALLER
+
+            # If the remaining part of one of strings starts with "^": if the other remaining part
+            # does not start with ^, the string with ^ compares higher. Otherwise, both caret
+            # characters are skipped.
+
+            if v1.startswith("^") and v2.startswith("^"):
+                v1 = v1.removeprefix("^")
+                v2 = v2.removeprefix("^")
+            elif v1.startswith("^"):
+                # TODO: bug?
+                return cls._LEFT_SMALLER  #cls._RIGHT_SMALLER
+            elif v2.startswith("^"):
+                return cls._RIGHT_SMALLER #cls._LEFT_SMALLER
+
+            # If the remaining part of one of strings starts with ".": if the other remaining part
+            # does not start with ., the string with . compares lower. Otherwise, both dot
+            # characters are skipped.
+
+            if v1.startswith(".") and v2.startswith("."):
+                v1 = v1.removeprefix(".")
+                v2 = v2.removeprefix(".")
+            elif v1.startswith("."):
+                return cls._LEFT_SMALLER
+            elif v2.startswith("."):
+                return cls._RIGHT_SMALLER
+
+            # If either of the remaining parts starts with a digit: numerical prefixes are compared
+            # numerically. Any leading zeroes are skipped. The numerical prefixes (until the first
+            # non-digit character) are evaluated as numbers. If one of the prefixes is empty, it
+            # evaluates as 0. If the numbers are different, the string with the bigger number
+            # compares higher. Otherwise, the comparison continues at the following characters at
+            # point 1.
+
+            v1_digit_prefix = digit_prefix(v1)
+            v2_digit_prefix = digit_prefix(v2)
+
+            if v1_digit_prefix or v2_digit_prefix:
+                v1_digits = int(v1_digit_prefix) if v1_digit_prefix else 0
+                v2_digits = int(v2_digit_prefix) if v2_digit_prefix else 0
+
+                if v1_digits < v2_digits:
+                    return cls._LEFT_SMALLER
+                elif v1_digits > v2_digits:
+                    return cls._RIGHT_SMALLER
+
+                v1 = v1.removeprefix(v1_digit_prefix)
+                v2 = v2.removeprefix(v2_digit_prefix)
+                continue
+
+            # Leading alphabetical prefixes are compared alphabetically. The substrings are
+            # compared letter-by-letter. If both letters are the same, the comparison continues
+            # with the next letter. Capital letters compare lower than lower-case letters (A <
+            # a). When the end of one substring has been reached (a non-letter character or the end
+            # of the whole string), if the other substring has remaining letters, it compares
+            # higher. Otherwise, the comparison continues at the following characters at point 1.
+
+            v1_letter_prefix = letter_prefix(v1)
+            v2_letter_prefix = letter_prefix(v2)
+
+            if v1_letter_prefix < v2_letter_prefix:
+                return cls._LEFT_SMALLER
+            elif v1_letter_prefix > v2_letter_prefix:
+                return cls._RIGHT_SMALLER
+
+            v1 = v1.removeprefix(v1_letter_prefix)
+            v2 = v2.removeprefix(v2_letter_prefix)
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, GenericVersion):
+            return False
+        return self.compare_versions(self._version, other._version) == self._EQUAL
+
+    def __ne__(self, other: object) -> bool:
+        if not isinstance(other, GenericVersion):
+            return False
+        return self.compare_versions(self._version, other._version) != self._EQUAL
+
+    def __lt__(self, other: object) -> bool:
+        if not isinstance(other, GenericVersion):
+            return False
+        return self.compare_versions(self._version, other._version) == self._LEFT_SMALLER
+
+    def __le__(self, other: object) -> bool:
+        if not isinstance(other, GenericVersion):
+            return False
+        return self.compare_versions(self._version, other._version) in (self._EQUAL, self._LEFT_SMALLER)
+
+    def __gt__(self, other: object) -> bool:
+        if not isinstance(other, GenericVersion):
+            return False
+        return self.compare_versions(self._version, other._version) == self._RIGHT_SMALLER
+
+    def __ge__(self, other: object) -> bool:
+        if not isinstance(other, GenericVersion):
+            return False
+        return self.compare_versions(self._version, other._version) in (self._EQUAL, self._RIGHT_SMALLER)
index 441f052c169aa320463ea83eb37b40c68c2a2b8d..d61c9374423c2e3cf384d1f57ff7456d2c042c51 100644 (file)
@@ -1,10 +1,6 @@
 # SPDX-License-Identifier: LGPL-2.1+
 
-import itertools
-
-import pytest
-
-from mkosi.config import Compression, GenericVersion
+from mkosi.config import Compression
 
 
 def test_compression_enum_creation() -> None:
@@ -35,222 +31,3 @@ def test_compression_enum_str() -> None:
     assert str(Compression.gz)   == "gz"
     assert str(Compression.lz4)  == "lz4"
     assert str(Compression.lzma) == "lzma"
-
-
-def test_generic_version_systemd() -> None:
-    """Same as the first block of systemd/test/test-compare-versions.sh"""
-    assert GenericVersion("1") < GenericVersion("2")
-    assert GenericVersion("1") <= GenericVersion("2")
-    assert GenericVersion("1") != GenericVersion("2")
-    assert not (GenericVersion("1") > GenericVersion("2"))
-    assert not (GenericVersion("1") == GenericVersion("2"))
-    assert not (GenericVersion("1") >= GenericVersion("2"))
-    assert GenericVersion.compare_versions("1", "2") == -1
-    assert GenericVersion.compare_versions("2", "2") == 0
-    assert GenericVersion.compare_versions("2", "1") == 1
-
-
-def test_generic_version_spec() -> None:
-    """Examples from the uapi group version format spec"""
-    assert GenericVersion("11") == GenericVersion("11")
-    assert GenericVersion("systemd-123") == GenericVersion("systemd-123")
-    assert GenericVersion("bar-123") < GenericVersion("foo-123")
-    assert GenericVersion("123a") > GenericVersion("123")
-    assert GenericVersion("123.a") > GenericVersion("123")
-    assert GenericVersion("123.a") < GenericVersion("123.b")
-    assert GenericVersion("123a") > GenericVersion("123.a")
-    assert GenericVersion("11α") == GenericVersion("11β")
-    assert GenericVersion("A") < GenericVersion("a")
-    assert GenericVersion("") < GenericVersion("0")
-    assert GenericVersion("0.") > GenericVersion("0")
-    assert GenericVersion("0.0") > GenericVersion("0")
-    assert GenericVersion("0") > GenericVersion("~")
-    assert GenericVersion("") > GenericVersion("~")
-    assert GenericVersion("1_") == GenericVersion("1")
-    assert GenericVersion("_1") == GenericVersion("1")
-    assert GenericVersion("1_") < GenericVersion("1.2")
-    assert GenericVersion("1_2_3") > GenericVersion("1.3.3")
-    assert GenericVersion("1+") == GenericVersion("1")
-    assert GenericVersion("+1") == GenericVersion("1")
-    assert GenericVersion("1+") < GenericVersion("1.2")
-    assert GenericVersion("1+2+3") > GenericVersion("1.3.3")
-
-
-@pytest.mark.parametrize(
-    "s1,s2",
-    itertools.combinations_with_replacement(
-        enumerate(
-            [
-                GenericVersion("122.1"),
-                GenericVersion("123~rc1-1"),
-                GenericVersion("123"),
-                GenericVersion("123-a"),
-                GenericVersion("123-a.1"),
-                GenericVersion("123-1"),
-                GenericVersion("123-1.1"),
-                GenericVersion("123^post1"),
-                GenericVersion("123.a-1"),
-                GenericVersion("123.1-1"),
-                GenericVersion("123a-1"),
-                GenericVersion("124-1"),
-            ],
-        ),
-        2
-    )
-)
-def test_generic_version_strverscmp_improved_doc(s1: tuple[int, GenericVersion], s2: tuple[int, GenericVersion]) -> None:
-    """Example from the doc string of strverscmp_improved in systemd/src/fundamental/string-util-fundamental.c"""
-    i1, v1 = s1
-    i2, v2 = s2
-    assert (v1 == v2) == (i1 == i2)
-    assert  (v1 < v2) == (i1 < i2)
-    assert (v1 <= v2) == (i1 <= i2)
-    assert  (v1 > v2) == (i1 > i2)
-    assert (v1 >= v2) == (i1 >= i2)
-    assert (v1 != v2) == (i1 != i2)
-
-
-def RPMVERCMP(a: str, b: str, expected: int) -> None:
-    assert (GenericVersion(a) > GenericVersion(b)) - (GenericVersion(a) < GenericVersion(b)) == expected
-
-
-def test_generic_version_rpmvercmp() -> None:
-    # Tests copied from rpm's rpmio test suite, under the LGPL license:
-    # https://github.com/rpm-software-management/rpm/blob/master/tests/rpmvercmp.at.
-    # The original form is retained as much as possible for easy comparisons and updates.
-
-    RPMVERCMP("1.0", "1.0", 0)
-    RPMVERCMP("1.0", "2.0", -1)
-    RPMVERCMP("2.0", "1.0", 1)
-
-    RPMVERCMP("2.0.1", "2.0.1", 0)
-    RPMVERCMP("2.0", "2.0.1", -1)
-    RPMVERCMP("2.0.1", "2.0", 1)
-
-    RPMVERCMP("2.0.1a", "2.0.1a", 0)
-    RPMVERCMP("2.0.1a", "2.0.1", 1)
-    RPMVERCMP("2.0.1", "2.0.1a", -1)
-
-    RPMVERCMP("5.5p1", "5.5p1", 0)
-    RPMVERCMP("5.5p1", "5.5p2", -1)
-    RPMVERCMP("5.5p2", "5.5p1", 1)
-
-    RPMVERCMP("5.5p10", "5.5p10", 0)
-    RPMVERCMP("5.5p1", "5.5p10", -1)
-    RPMVERCMP("5.5p10", "5.5p1", 1)
-
-    RPMVERCMP("10xyz", "10.1xyz", 1)    # Note: this is reversed from rpm's vercmp */
-    RPMVERCMP("10.1xyz", "10xyz", -1)   # Note: this is reversed from rpm's vercmp */
-
-    RPMVERCMP("xyz10", "xyz10", 0)
-    RPMVERCMP("xyz10", "xyz10.1", -1)
-    RPMVERCMP("xyz10.1", "xyz10", 1)
-
-    RPMVERCMP("xyz.4", "xyz.4", 0)
-    RPMVERCMP("xyz.4", "8", -1)
-    RPMVERCMP("8", "xyz.4", 1)
-    RPMVERCMP("xyz.4", "2", -1)
-    RPMVERCMP("2", "xyz.4", 1)
-
-    RPMVERCMP("5.5p2", "5.6p1", -1)
-    RPMVERCMP("5.6p1", "5.5p2", 1)
-
-    RPMVERCMP("5.6p1", "6.5p1", -1)
-    RPMVERCMP("6.5p1", "5.6p1", 1)
-
-    RPMVERCMP("6.0.rc1", "6.0", 1)
-    RPMVERCMP("6.0", "6.0.rc1", -1)
-
-    RPMVERCMP("10b2", "10a1", 1)
-    RPMVERCMP("10a2", "10b2", -1)
-
-    RPMVERCMP("1.0aa", "1.0aa", 0)
-    RPMVERCMP("1.0a", "1.0aa", -1)
-    RPMVERCMP("1.0aa", "1.0a", 1)
-
-    RPMVERCMP("10.0001", "10.0001", 0)
-    RPMVERCMP("10.0001", "10.1", 0)
-    RPMVERCMP("10.1", "10.0001", 0)
-    RPMVERCMP("10.0001", "10.0039", -1)
-    RPMVERCMP("10.0039", "10.0001", 1)
-
-    RPMVERCMP("4.999.9", "5.0", -1)
-    RPMVERCMP("5.0", "4.999.9", 1)
-
-    RPMVERCMP("20101121", "20101121", 0)
-    RPMVERCMP("20101121", "20101122", -1)
-    RPMVERCMP("20101122", "20101121", 1)
-
-    RPMVERCMP("2_0", "2_0", 0)
-    RPMVERCMP("2.0", "2_0", -1)   # Note: in rpm those compare equal
-    RPMVERCMP("2_0", "2.0", 1)    # Note: in rpm those compare equal
-
-    # RhBug:178798 case */
-    RPMVERCMP("a", "a", 0)
-    RPMVERCMP("a+", "a+", 0)
-    RPMVERCMP("a+", "a_", 0)
-    RPMVERCMP("a_", "a+", 0)
-    RPMVERCMP("+a", "+a", 0)
-    RPMVERCMP("+a", "_a", 0)
-    RPMVERCMP("_a", "+a", 0)
-    RPMVERCMP("+_", "+_", 0)
-    RPMVERCMP("_+", "+_", 0)
-    RPMVERCMP("_+", "_+", 0)
-    RPMVERCMP("+", "_", 0)
-    RPMVERCMP("_", "+", 0)
-
-    # Basic testcases for tilde sorting
-    RPMVERCMP("1.0~rc1", "1.0~rc1", 0)
-    RPMVERCMP("1.0~rc1", "1.0", -1)
-    RPMVERCMP("1.0", "1.0~rc1", 1)
-    RPMVERCMP("1.0~rc1", "1.0~rc2", -1)
-    RPMVERCMP("1.0~rc2", "1.0~rc1", 1)
-    RPMVERCMP("1.0~rc1~git123", "1.0~rc1~git123", 0)
-    RPMVERCMP("1.0~rc1~git123", "1.0~rc1", -1)
-    RPMVERCMP("1.0~rc1", "1.0~rc1~git123", 1)
-
-    # Basic testcases for caret sorting
-    RPMVERCMP("1.0^", "1.0^", 0)
-    RPMVERCMP("1.0^", "1.0", 1)
-    RPMVERCMP("1.0", "1.0^", -1)
-    RPMVERCMP("1.0^git1", "1.0^git1", 0)
-    RPMVERCMP("1.0^git1", "1.0", 1)
-    RPMVERCMP("1.0", "1.0^git1", -1)
-    RPMVERCMP("1.0^git1", "1.0^git2", -1)
-    RPMVERCMP("1.0^git2", "1.0^git1", 1)
-    RPMVERCMP("1.0^git1", "1.01", -1)
-    RPMVERCMP("1.01", "1.0^git1", 1)
-    RPMVERCMP("1.0^20160101", "1.0^20160101", 0)
-    RPMVERCMP("1.0^20160101", "1.0.1", -1)
-    RPMVERCMP("1.0.1", "1.0^20160101", 1)
-    RPMVERCMP("1.0^20160101^git1", "1.0^20160101^git1", 0)
-    RPMVERCMP("1.0^20160102", "1.0^20160101^git1", 1)
-    RPMVERCMP("1.0^20160101^git1", "1.0^20160102", -1)
-
-    # Basic testcases for tilde and caret sorting */
-    RPMVERCMP("1.0~rc1^git1", "1.0~rc1^git1", 0)
-    RPMVERCMP("1.0~rc1^git1", "1.0~rc1", 1)
-    RPMVERCMP("1.0~rc1", "1.0~rc1^git1", -1)
-    RPMVERCMP("1.0^git1~pre", "1.0^git1~pre", 0)
-    RPMVERCMP("1.0^git1", "1.0^git1~pre", 1)
-    RPMVERCMP("1.0^git1~pre", "1.0^git1", -1)
-
-    # These are included here to document current, arguably buggy behaviors
-    # for reference purposes and for easy checking against unintended
-    # behavior changes. */
-    print("/* RPM version comparison oddities */")
-    # RhBug:811992 case
-    RPMVERCMP("1b.fc17", "1b.fc17", 0)
-    RPMVERCMP("1b.fc17", "1.fc17", 1) # Note: this is reversed from rpm's vercmp, WAT! */
-    RPMVERCMP("1.fc17", "1b.fc17", -1)
-    RPMVERCMP("1g.fc17", "1g.fc17", 0)
-    RPMVERCMP("1g.fc17", "1.fc17", 1)
-    RPMVERCMP("1.fc17", "1g.fc17", -1)
-
-    # Non-ascii characters are considered equal so these are all the same, eh… */
-    RPMVERCMP("1.1.α", "1.1.α", 0)
-    RPMVERCMP("1.1.α", "1.1.β", 0)
-    RPMVERCMP("1.1.β", "1.1.α", 0)
-    RPMVERCMP("1.1.αα", "1.1.α", 0)
-    RPMVERCMP("1.1.α", "1.1.ββ", 0)
-    RPMVERCMP("1.1.ββ", "1.1.αα", 0)
diff --git a/tests/test_versioncomp.py b/tests/test_versioncomp.py
new file mode 100644 (file)
index 0000000..b6f8e8c
--- /dev/null
@@ -0,0 +1,225 @@
+# SPDX-License-Identifier: LGPL-2.1+
+import itertools
+
+import pytest
+
+from mkosi.versioncomp import GenericVersion
+
+
+def test_generic_version_systemd() -> None:
+    """Same as the first block of systemd/test/test-compare-versions.sh"""
+    assert GenericVersion("1") < GenericVersion("2")
+    assert GenericVersion("1") <= GenericVersion("2")
+    assert GenericVersion("1") != GenericVersion("2")
+    assert not (GenericVersion("1") > GenericVersion("2"))
+    assert not (GenericVersion("1") == GenericVersion("2"))
+    assert not (GenericVersion("1") >= GenericVersion("2"))
+    assert GenericVersion.compare_versions("1", "2") == -1
+    assert GenericVersion.compare_versions("2", "2") == 0
+    assert GenericVersion.compare_versions("2", "1") == 1
+
+
+def test_generic_version_spec() -> None:
+    """Examples from the uapi group version format spec"""
+    assert GenericVersion("11") == GenericVersion("11")
+    assert GenericVersion("systemd-123") == GenericVersion("systemd-123")
+    assert GenericVersion("bar-123") < GenericVersion("foo-123")
+    assert GenericVersion("123a") > GenericVersion("123")
+    assert GenericVersion("123.a") > GenericVersion("123")
+    assert GenericVersion("123.a") < GenericVersion("123.b")
+    assert GenericVersion("123a") > GenericVersion("123.a")
+    assert GenericVersion("11α") == GenericVersion("11β")
+    assert GenericVersion("A") < GenericVersion("a")
+    assert GenericVersion("") < GenericVersion("0")
+    assert GenericVersion("0.") > GenericVersion("0")
+    assert GenericVersion("0.0") > GenericVersion("0")
+    assert GenericVersion("0") > GenericVersion("~")
+    assert GenericVersion("") > GenericVersion("~")
+    assert GenericVersion("1_") == GenericVersion("1")
+    assert GenericVersion("_1") == GenericVersion("1")
+    assert GenericVersion("1_") < GenericVersion("1.2")
+    assert GenericVersion("1_2_3") > GenericVersion("1.3.3")
+    assert GenericVersion("1+") == GenericVersion("1")
+    assert GenericVersion("+1") == GenericVersion("1")
+    assert GenericVersion("1+") < GenericVersion("1.2")
+    assert GenericVersion("1+2+3") > GenericVersion("1.3.3")
+
+
+@pytest.mark.parametrize(
+    "s1,s2",
+    itertools.combinations_with_replacement(
+        enumerate(
+            [
+                GenericVersion("122.1"),
+                GenericVersion("123~rc1-1"),
+                GenericVersion("123"),
+                GenericVersion("123-a"),
+                GenericVersion("123-a.1"),
+                GenericVersion("123-1"),
+                GenericVersion("123-1.1"),
+                GenericVersion("123^post1"),
+                GenericVersion("123.a-1"),
+                GenericVersion("123.1-1"),
+                GenericVersion("123a-1"),
+                GenericVersion("124-1"),
+            ],
+        ),
+        2
+    )
+)
+def test_generic_version_strverscmp_improved_doc(s1: tuple[int, GenericVersion], s2: tuple[int, GenericVersion]) -> None:
+    """Example from the doc string of strverscmp_improved in systemd/src/fundamental/string-util-fundamental.c"""
+    i1, v1 = s1
+    i2, v2 = s2
+    assert (v1 == v2) == (i1 == i2)
+    assert  (v1 < v2) == (i1 < i2)
+    assert (v1 <= v2) == (i1 <= i2)
+    assert  (v1 > v2) == (i1 > i2)
+    assert (v1 >= v2) == (i1 >= i2)
+    assert (v1 != v2) == (i1 != i2)
+
+
+def RPMVERCMP(a: str, b: str, expected: int) -> None:
+    assert (GenericVersion(a) > GenericVersion(b)) - (GenericVersion(a) < GenericVersion(b)) == expected
+
+
+def test_generic_version_rpmvercmp() -> None:
+    # Tests copied from rpm's rpmio test suite, under the LGPL license:
+    # https://github.com/rpm-software-management/rpm/blob/master/tests/rpmvercmp.at.
+    # The original form is retained as much as possible for easy comparisons and updates.
+
+    RPMVERCMP("1.0", "1.0", 0)
+    RPMVERCMP("1.0", "2.0", -1)
+    RPMVERCMP("2.0", "1.0", 1)
+
+    RPMVERCMP("2.0.1", "2.0.1", 0)
+    RPMVERCMP("2.0", "2.0.1", -1)
+    RPMVERCMP("2.0.1", "2.0", 1)
+
+    RPMVERCMP("2.0.1a", "2.0.1a", 0)
+    RPMVERCMP("2.0.1a", "2.0.1", 1)
+    RPMVERCMP("2.0.1", "2.0.1a", -1)
+
+    RPMVERCMP("5.5p1", "5.5p1", 0)
+    RPMVERCMP("5.5p1", "5.5p2", -1)
+    RPMVERCMP("5.5p2", "5.5p1", 1)
+
+    RPMVERCMP("5.5p10", "5.5p10", 0)
+    RPMVERCMP("5.5p1", "5.5p10", -1)
+    RPMVERCMP("5.5p10", "5.5p1", 1)
+
+    RPMVERCMP("10xyz", "10.1xyz", 1)    # Note: this is reversed from rpm's vercmp */
+    RPMVERCMP("10.1xyz", "10xyz", -1)   # Note: this is reversed from rpm's vercmp */
+
+    RPMVERCMP("xyz10", "xyz10", 0)
+    RPMVERCMP("xyz10", "xyz10.1", -1)
+    RPMVERCMP("xyz10.1", "xyz10", 1)
+
+    RPMVERCMP("xyz.4", "xyz.4", 0)
+    RPMVERCMP("xyz.4", "8", -1)
+    RPMVERCMP("8", "xyz.4", 1)
+    RPMVERCMP("xyz.4", "2", -1)
+    RPMVERCMP("2", "xyz.4", 1)
+
+    RPMVERCMP("5.5p2", "5.6p1", -1)
+    RPMVERCMP("5.6p1", "5.5p2", 1)
+
+    RPMVERCMP("5.6p1", "6.5p1", -1)
+    RPMVERCMP("6.5p1", "5.6p1", 1)
+
+    RPMVERCMP("6.0.rc1", "6.0", 1)
+    RPMVERCMP("6.0", "6.0.rc1", -1)
+
+    RPMVERCMP("10b2", "10a1", 1)
+    RPMVERCMP("10a2", "10b2", -1)
+
+    RPMVERCMP("1.0aa", "1.0aa", 0)
+    RPMVERCMP("1.0a", "1.0aa", -1)
+    RPMVERCMP("1.0aa", "1.0a", 1)
+
+    RPMVERCMP("10.0001", "10.0001", 0)
+    RPMVERCMP("10.0001", "10.1", 0)
+    RPMVERCMP("10.1", "10.0001", 0)
+    RPMVERCMP("10.0001", "10.0039", -1)
+    RPMVERCMP("10.0039", "10.0001", 1)
+
+    RPMVERCMP("4.999.9", "5.0", -1)
+    RPMVERCMP("5.0", "4.999.9", 1)
+
+    RPMVERCMP("20101121", "20101121", 0)
+    RPMVERCMP("20101121", "20101122", -1)
+    RPMVERCMP("20101122", "20101121", 1)
+
+    RPMVERCMP("2_0", "2_0", 0)
+    RPMVERCMP("2.0", "2_0", -1)   # Note: in rpm those compare equal
+    RPMVERCMP("2_0", "2.0", 1)    # Note: in rpm those compare equal
+
+    # RhBug:178798 case */
+    RPMVERCMP("a", "a", 0)
+    RPMVERCMP("a+", "a+", 0)
+    RPMVERCMP("a+", "a_", 0)
+    RPMVERCMP("a_", "a+", 0)
+    RPMVERCMP("+a", "+a", 0)
+    RPMVERCMP("+a", "_a", 0)
+    RPMVERCMP("_a", "+a", 0)
+    RPMVERCMP("+_", "+_", 0)
+    RPMVERCMP("_+", "+_", 0)
+    RPMVERCMP("_+", "_+", 0)
+    RPMVERCMP("+", "_", 0)
+    RPMVERCMP("_", "+", 0)
+
+    # Basic testcases for tilde sorting
+    RPMVERCMP("1.0~rc1", "1.0~rc1", 0)
+    RPMVERCMP("1.0~rc1", "1.0", -1)
+    RPMVERCMP("1.0", "1.0~rc1", 1)
+    RPMVERCMP("1.0~rc1", "1.0~rc2", -1)
+    RPMVERCMP("1.0~rc2", "1.0~rc1", 1)
+    RPMVERCMP("1.0~rc1~git123", "1.0~rc1~git123", 0)
+    RPMVERCMP("1.0~rc1~git123", "1.0~rc1", -1)
+    RPMVERCMP("1.0~rc1", "1.0~rc1~git123", 1)
+
+    # Basic testcases for caret sorting
+    RPMVERCMP("1.0^", "1.0^", 0)
+    RPMVERCMP("1.0^", "1.0", 1)
+    RPMVERCMP("1.0", "1.0^", -1)
+    RPMVERCMP("1.0^git1", "1.0^git1", 0)
+    RPMVERCMP("1.0^git1", "1.0", 1)
+    RPMVERCMP("1.0", "1.0^git1", -1)
+    RPMVERCMP("1.0^git1", "1.0^git2", -1)
+    RPMVERCMP("1.0^git2", "1.0^git1", 1)
+    RPMVERCMP("1.0^git1", "1.01", -1)
+    RPMVERCMP("1.01", "1.0^git1", 1)
+    RPMVERCMP("1.0^20160101", "1.0^20160101", 0)
+    RPMVERCMP("1.0^20160101", "1.0.1", -1)
+    RPMVERCMP("1.0.1", "1.0^20160101", 1)
+    RPMVERCMP("1.0^20160101^git1", "1.0^20160101^git1", 0)
+    RPMVERCMP("1.0^20160102", "1.0^20160101^git1", 1)
+    RPMVERCMP("1.0^20160101^git1", "1.0^20160102", -1)
+
+    # Basic testcases for tilde and caret sorting */
+    RPMVERCMP("1.0~rc1^git1", "1.0~rc1^git1", 0)
+    RPMVERCMP("1.0~rc1^git1", "1.0~rc1", 1)
+    RPMVERCMP("1.0~rc1", "1.0~rc1^git1", -1)
+    RPMVERCMP("1.0^git1~pre", "1.0^git1~pre", 0)
+    RPMVERCMP("1.0^git1", "1.0^git1~pre", 1)
+    RPMVERCMP("1.0^git1~pre", "1.0^git1", -1)
+
+    # These are included here to document current, arguably buggy behaviors
+    # for reference purposes and for easy checking against unintended
+    # behavior changes. */
+    print("/* RPM version comparison oddities */")
+    # RhBug:811992 case
+    RPMVERCMP("1b.fc17", "1b.fc17", 0)
+    RPMVERCMP("1b.fc17", "1.fc17", 1) # Note: this is reversed from rpm's vercmp, WAT! */
+    RPMVERCMP("1.fc17", "1b.fc17", -1)
+    RPMVERCMP("1g.fc17", "1g.fc17", 0)
+    RPMVERCMP("1g.fc17", "1.fc17", 1)
+    RPMVERCMP("1.fc17", "1g.fc17", -1)
+
+    # Non-ascii characters are considered equal so these are all the same, eh… */
+    RPMVERCMP("1.1.α", "1.1.α", 0)
+    RPMVERCMP("1.1.α", "1.1.β", 0)
+    RPMVERCMP("1.1.β", "1.1.α", 0)
+    RPMVERCMP("1.1.αα", "1.1.α", 0)
+    RPMVERCMP("1.1.α", "1.1.ββ", 0)
+    RPMVERCMP("1.1.ββ", "1.1.αα", 0)