diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..6cadf72 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,298 @@ +# Copyright (c) 2022 The Toltec Contributors +# SPDX-License-Identifier: MIT + +import unittest +from toltec.version import ( + Version, + Dependency, + DependencyKind, + VersionComparator, + InvalidVersionError, + InvalidDependencyError, +) + + +class TestVersion(unittest.TestCase): + def test_init(self): + with self.assertRaises( + InvalidVersionError, + msg="Invalid epoch '-1', only non-negative values are allowed", + ): + version = Version(-1, "test", "0") + + with self.assertRaises( + InvalidVersionError, + msg="Invalid chars in upstream version 't:est', allowed chars " + "are A-Za-z0-9.+~-", + ): + version = Version(0, "t:est", "0") + + with self.assertRaises( + InvalidVersionError, + msg="Upstream version cannot be empty", + ): + version = Version(0, "", "0") + + with self.assertRaises( + InvalidVersionError, + msg="Invalid chars in revision '1-2-3', allowed chars are " + "A-Za-z0-9.+~", + ): + version = Version(0, "test", "1-2-3") + + with self.assertRaises( + InvalidVersionError, + msg="Revision cannot be empty", + ): + version = Version(0, "test", "") + + version = Version(1, "test", "1") + self.assertEqual(version.upstream, "test") + self.assertEqual(version.revision, "1") + self.assertEqual(version.epoch, 1) + + def test_parse(self): + valid_versions = ( + ("0.0.0-1", Version(0, "0.0.0", "1")), + ("0.0.1-1", Version(0, "0.0.1", "1")), + ("0.1.0-3", Version(0, "0.1.0", "3")), + ("0.1.1", Version(0, "0.1.1", "0")), + ("1.0-0", Version(0, "1.0", "0")), + ("1.0.0", Version(0, "1.0.0", "0")), + ("1-0-0", Version(0, "1-0", "0")), + ("1:0.0.14-1", Version(1, "0.0.14", "1")), + ("1.0.20210219-2", Version(0, "1.0.20210219", "2")), + ("1.3.5-14", Version(0, "1.3.5", "14")), + ("19.21-2", Version(0, "19.21", "2")), + ("2.0.10-1", Version(0, "2.0.10", "1")), + ("2020.11.08-2", Version(0, "2020.11.08", "2")), + ) + + for string, parsed in valid_versions: + self.assertEqual(Version.parse(string), parsed) + + with self.assertRaises( + InvalidVersionError, + msg="Invalid epoch 'test', must be numeric", + ): + version = Version.parse("test:1.1") + + with self.assertRaises( + InvalidVersionError, + msg="Upstream version cannot be empty", + ): + version = Version.parse("0:-1") + + def test_compare(self): + self.assertEqual(Version(0, "1.0", "1"), Version(0, "1.0", "1")) + + ordered_pairs = ( + (Version(0, "1.0", "1"), Version(1, "0.1", "1")), + (Version(0, "1.0", "1"), Version(0, "1.0", "2")), + (Version(0, "1.0", "2"), Version(0, "1.1", "1")), + (Version(1, "1.0~~", "7"), Version(1, "1.0~~a", "1")), + (Version(1, "1.0~~a", "7"), Version(1, "1.0~", "1")), + (Version(1, "1.0~", "7"), Version(1, "1.0", "1")), + (Version(1, "1.0", "7"), Version(1, "1.0a", "1")), + ) + + for lower, greater in ordered_pairs: + self.assertTrue(lower < greater) + self.assertTrue(lower <= greater) + self.assertTrue(greater > lower) + self.assertTrue(greater >= lower) + self.assertFalse(greater < lower) + self.assertFalse(greater <= lower) + self.assertFalse(lower > greater) + self.assertFalse(lower >= greater) + + +class TestDependency(unittest.TestCase): + def test_init(self): + version = Version(1, "0.1", "1") + dep = Dependency( + DependencyKind.BUILD, + "test", + VersionComparator.GREATER_THAN_OR_EQUAL, + version, + ) + self.assertEqual(dep.kind, DependencyKind.BUILD) + self.assertEqual(dep.package, "test") + self.assertEqual( + dep.version_comparator, + VersionComparator.GREATER_THAN_OR_EQUAL, + ) + self.assertEqual(dep.version, version) + + def test_parse(self): + valid_deps = ( + ( + "test", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.EQUAL, + None, + ), + ), + ( + "host:test", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.EQUAL, + None, + ), + ), + ( + "build:test", + Dependency( + DependencyKind.BUILD, + "test", + VersionComparator.EQUAL, + None, + ), + ), + ( + "test=0.1-1", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.EQUAL, + Version(0, "0.1", "1"), + ), + ), + ( + "test<<0.1-1", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.LOWER_THAN, + Version(0, "0.1", "1"), + ), + ), + ( + "test<=0.1-1", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.LOWER_THAN_OR_EQUAL, + Version(0, "0.1", "1"), + ), + ), + ( + "test>>0.1-1", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.GREATER_THAN, + Version(0, "0.1", "1"), + ), + ), + ( + "test>=0.1-1", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.GREATER_THAN_OR_EQUAL, + Version(0, "0.1", "1"), + ), + ), + ( + "test=1:0.1-1", + Dependency( + DependencyKind.HOST, + "test", + VersionComparator.EQUAL, + Version(1, "0.1", "1"), + ), + ), + ( + "build:test=1:0.1-1", + Dependency( + DependencyKind.BUILD, + "test", + VersionComparator.EQUAL, + Version(1, "0.1", "1"), + ), + ), + ) + + for string, parsed in valid_deps: + self.assertEqual(Dependency.parse(string), parsed) + + with self.assertRaises( + InvalidDependencyError, + msg="Unknown dependency type 'invalid', valid types are " + "'build', 'host'", + ): + dep = Dependency.parse("invalid:test") + + with self.assertRaises( + InvalidDependencyError, + msg="Invalid version comparator '<<>>', valid operators are " + "'<<', '<=', '=', '>=', '>>'", + ): + dep = Dependency.parse("host:test<<>>0.1") + + def test_to_debian(self): + converted_versions = ( + ("test", "test"), + ("host:test", "test"), + ("test=0.1-1", "test (= 0.1-1)"), + ("test<<0.1-1", "test (<< 0.1-1)"), + ("test<=0.1-1", "test (<= 0.1-1)"), + ("test>>0.1-1", "test (>> 0.1-1)"), + ("test>=0.1-1", "test (>= 0.1-1)"), + ("test=1:0.1-1", "test (= 1:0.1-1)"), + ) + + for ours, debian in converted_versions: + self.assertEqual(Dependency.parse(ours).to_debian(), debian) + + def test_match(self): + matches = ( + ("test", "0.1", True), + ("test", "0.1-0", True), + ("test", "0:0.1-0", True), + ("test", "0.1-1", True), + ("test", "0.0", True), + ("test", "1:0.0", True), + ("test=0.1", "0.1", True), + ("test=0.1", "0.1-0", True), + ("test=0.1", "0:0.1-0", True), + ("test=0.1", "0.1-1", False), + ("test=0.1", "0.0", False), + ("test=0.1", "1:0.0", False), + ("test>=0.1", "0.1", True), + ("test>=0.1", "0.1-0", True), + ("test>=0.1", "0:0.1-0", True), + ("test>=0.1", "0.1-1", True), + ("test>=0.1", "0.0", False), + ("test>=0.1", "1:0.0", True), + ("test>>0.1", "0.1", False), + ("test>>0.1", "0.1-0", False), + ("test>>0.1", "0:0.1-0", False), + ("test>>0.1", "0.1-1", True), + ("test>>0.1", "0.0", False), + ("test>>0.1", "1:0.0", True), + ("test<=0.1", "0.1", True), + ("test<=0.1", "0.1-0", True), + ("test<=0.1", "0:0.1-0", True), + ("test<=0.1", "0.1-1", False), + ("test<=0.1", "0.0", True), + ("test<=0.1", "1:0.0", False), + ("test<<0.1", "0.1", False), + ("test<<0.1", "0.1-0", False), + ("test<<0.1", "0:0.1-0", False), + ("test<<0.1", "0.1-1", False), + ("test<<0.1", "0.0", True), + ("test<<0.1", "1:0.0", False), + ) + + for dep, version, result in matches: + self.assertEqual( + Dependency.parse(dep).match(Version.parse(version)), + result, + f"match {dep} with {version} is {result}", + ) diff --git a/toltec/version.py b/toltec/version.py index ae48aa7..2e72921 100644 --- a/toltec/version.py +++ b/toltec/version.py @@ -1,16 +1,114 @@ # Copyright (c) 2021 The Toltec Contributors # SPDX-License-Identifier: MIT -"""Parse versions and dependency specifications.""" +""" +Parse versions and dependency specifications. + +Syntax and comparison rules for version numbers and comparison operators are +taken from Debian’s. See: + +* +* +""" import re +from functools import total_ordering from enum import Enum -from typing import Optional +from typing import Optional, Callable + +# Characters permitted in the upstream part of a version number +_UPSTREAM_CHARS = "A-Za-z0-9.+~-" +_UPSTREAM_REGEX = re.compile(f"^[{_UPSTREAM_CHARS}]*$") -# Characters permitted in the upstream version part of a version number -_VERSION_CHARS = re.compile("^[A-Za-z0-9.+~-]+$") +# Characters permitted in the revision part of a version number +_REVISION_CHARS = "A-Za-z0-9.+~" +_REVISION_REGEX = re.compile(f"^[{_REVISION_CHARS}]*$") # Characters making up a version comparator -_COMPARATOR_CHARS = re.compile("[<>=]") +_COMPARATOR_CHARS = re.compile("[<>=]+") + +# Regex used to find digit and non-digit chars +_DIGIT_REGEX = re.compile("[0-9]") +_NON_DIGIT_REGEX = re.compile("[^0-9]") + +# Sorting key used for non-digit version parts +_ALPHA_SORT_KEY = ( + ( + "~", # ~ sorts lower than anything, even empty parts + None, + ) + + tuple(chr(letter) for letter in range(ord("A"), ord("Z") + 1)) + + tuple(chr(letter) for letter in range(ord("a"), ord("z") + 1)) + + ( + "+", + "-", + ".", + ) +) + + +def _find_digit(string: str) -> int: + """ + Find the index of the first digit char in a string, or return the + length of the string if there is none. + """ + matches = _DIGIT_REGEX.search(string) + return len(string) if not matches else matches.start() + + +def _find_non_digit(string: str) -> int: + """ + Find the index of the first non-digit char in a string, or return the + length of the string if there is none. + """ + matches = _NON_DIGIT_REGEX.search(string) + return len(string) if not matches else matches.start() + + +def _compare_version_parts(left: str, right: str) -> bool: + """ + Compare two parts of a version string according to Debian version + sorting rules. + :returns: true if and only if `left` is strictly lower than `right` + """ + + def split( + string: str, index_finder: Callable[[str], int] + ) -> tuple[str, str]: + index = index_finder(string) + return string[:index], string[index:] + + def map_alpha(string: str, length: int) -> tuple[int, ...]: + return tuple( + _ALPHA_SORT_KEY.index(string[i]) + if i in range(len(string)) + else _ALPHA_SORT_KEY.index(None) + for i in range(length) + ) + + # Split the strings into alternating non-digit parts and numeric parts, + # compare non-digit parts using _ALPHA_SORT_KEY and numeric parts as + # integers, stop at the first non-equal part + while left or right: + left_alpha, left = split(left, _find_digit) + right_alpha, right = split(right, _find_digit) + + max_len = max(len(left_alpha), len(right_alpha)) + left_map = map_alpha(left_alpha, max_len) + right_map = map_alpha(right_alpha, max_len) + + if left_map != right_map: + return left_map < right_map + + left_digit, left = split(left, _find_non_digit) + right_digit, right = split(right, _find_non_digit) + + left_numeric = int(left_digit) + right_numeric = int(right_digit) + + if left_numeric != right_numeric: + return left_numeric < right_numeric + + return False class VersionComparator(Enum): @@ -27,11 +125,11 @@ class InvalidVersionError(Exception): """Raised when parsing of an invalid version is attempted.""" +@total_ordering class Version: """ Parse package versions. - See for details about the format and the comparison rules. """ @@ -40,14 +138,28 @@ def __init__(self, epoch: int, upstream: str, revision: str): self.revision = revision self.epoch = epoch - if _VERSION_CHARS.fullmatch(upstream) is None: + if epoch < 0: raise InvalidVersionError( - f"Invalid chars in upstream version: '{upstream}'" + f"Invalid epoch '{epoch}', only non-negative values " + "are allowed" ) - if _VERSION_CHARS.fullmatch(revision) is None: + if not upstream: + raise InvalidVersionError("Upstream version cannot be empty") + + if _UPSTREAM_REGEX.fullmatch(upstream) is None: raise InvalidVersionError( - f"Invalid chars in revision: '{revision}'" + f"Invalid chars in upstream version '{upstream}', allowed " + f"chars are {_UPSTREAM_CHARS}" + ) + + if not revision: + raise InvalidVersionError("Revision cannot be empty") + + if _REVISION_REGEX.fullmatch(revision) is None: + raise InvalidVersionError( + f"Invalid chars in revision '{revision}' allowed chars " + f"are {_REVISION_CHARS}" ) self._original: Optional[str] = None @@ -56,21 +168,28 @@ def __init__(self, epoch: int, upstream: str, revision: str): def parse(version: str) -> "Version": """Parse a version number.""" original = version - colon = version.find(":") + first_colon = version.find(":") - if colon == -1: + if first_colon == -1: epoch = 0 else: - epoch = int(version[:colon]) - version = version[colon + 1 :] + epoch_text = version[:first_colon] + version = version[first_colon + 1 :] - dash = version.find("-") + try: + epoch = int(epoch_text) + except ValueError as err: + raise InvalidVersionError( + f"Invalid epoch '{epoch_text}', must be numeric" + ) from err - if dash == -1: + last_dash = version.rfind("-") + + if last_dash == -1: revision = "0" else: - revision = version[dash + 1 :] - version = version[:dash] + revision = version[last_dash + 1 :] + version = version[:last_dash] upstream = version @@ -97,14 +216,26 @@ def __repr__(self) -> str: revision={repr(self.revision)}, epoch={repr(self.epoch)})" def __eq__(self, other: object) -> bool: - if isinstance(other, Version): - return ( - self.epoch == other.epoch - and self.upstream == other.upstream - and self.revision == other.revision - ) + if not isinstance(other, Version): + return NotImplemented - return False + return ( + self.epoch == other.epoch + and self.upstream == other.upstream + and self.revision == other.revision + ) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, Version): + return NotImplemented + + if self.epoch != other.epoch: + return self.epoch < other.epoch + + if self.upstream != other.upstream: + return _compare_version_parts(self.upstream, other.upstream) + + return _compare_version_parts(self.revision, other.revision) def __hash__(self) -> int: return hash((self.epoch, self.upstream, self.revision)) @@ -158,55 +289,79 @@ def __init__( def parse(dependency: str) -> "Dependency": """Parse a dependency specification.""" original = dependency - colon = dependency.find(":") + comp_match = _COMPARATOR_CHARS.search(dependency) - if colon == -1: - kind = DependencyKind.HOST + if comp_match is None: + version_comparator = VersionComparator.EQUAL + version = None else: - for enum_kind in DependencyKind: - if enum_kind.value == dependency[:colon]: - kind = enum_kind - dependency = dependency[colon + 1 :] + comparator_str = comp_match.group(0) + comp_char = comp_match.start() + + for enum_comparator in VersionComparator: + if enum_comparator.value == comparator_str: + version_comparator = enum_comparator + version = Version.parse(dependency[comp_match.end() :]) + dependency = dependency[: comp_match.start()] break else: raise InvalidDependencyError( - f"Unknown dependency type \ -'{dependency[:colon]}'" + f"Invalid version comparator '{comp_char}', valid types " + "are " + + ",".join( + f"'{enum_comparator.value}'" + for enum_comparator in VersionComparator + ) ) - comp_char_match = _COMPARATOR_CHARS.search(dependency) + colon = dependency.find(":") - if comp_char_match is None: + if colon == -1: + kind = DependencyKind.HOST package = dependency - version_comparator = VersionComparator.EQUAL - version = None else: - comp_char = comp_char_match.start() - for enum_comparator in VersionComparator: - if dependency[comp_char:].startswith(enum_comparator.value): - package = dependency[:comp_char] - version_comparator = enum_comparator - version = Version.parse( - dependency[comp_char + len(enum_comparator.value) :] - ) + dep_kind_str = dependency[:colon] + + for enum_kind in DependencyKind: + if enum_kind.value == dep_kind_str: + kind = enum_kind + package = dependency[colon + 1 :] break else: raise InvalidDependencyError( - f"Invalid version comparator \ -'{dependency[comp_char : comp_char + 2]}'" + f"Unknown dependency type '{dep_kind_str}', valid types " + "are " + + ",".join( + f"'{enum_kind.value}'" for enum_kind in DependencyKind + ) ) result = Dependency(kind, package, version_comparator, version) result._original = original # pylint:disable=protected-access return result - def to_debian(self) -> str: - """ - Convert a dependency specification to the Debian format. + def match(self, version: Version) -> bool: + """Check whether a given version fulfills this dependency.""" + if self.version is None: + return True + + if self.version_comparator == VersionComparator.EQUAL: + return version == self.version + + if self.version_comparator == VersionComparator.LOWER_THAN: + return version < self.version - See - for the syntax expected by Debian tools. - """ + if self.version_comparator == VersionComparator.LOWER_THAN_OR_EQUAL: + return version <= self.version + + if self.version_comparator == VersionComparator.GREATER_THAN: + return version > self.version + + # self.version_comparator == VersionComparator.GREATER_THAN_OR_EQUAL: + return version >= self.version + + def to_debian(self) -> str: + """Convert a dependency specification to the Debian format.""" if self.version is None: return self.package