diff --git a/README.rst b/README.rst index cc44df14..43f685a2 100644 --- a/README.rst +++ b/README.rst @@ -120,7 +120,6 @@ and support for more package types are implemented on a continuous basis. Alternative ============ - Rather than using ecosystem-specific version schemes and code, another approach is to use a single procedure for all the versions as implemented in `libversion `_. ``libversion`` works in the most @@ -128,7 +127,16 @@ common case but may not work correctly when a task that demand precise version comparisons such as for dependency resolution and vulnerability lookup where a "good enough" comparison accuracy is not acceptable. ``libversion`` does not handle version range notations. +For this reason, univers provides a dedicated libversion scheme for users who +explicitly choose that behavior. Usage: + +.. code:: python + + from univers.versions import LibversionVersion + v3 = LibversionVersion("1.2.3-invalid") + v4 = LibversionVersion("1.2.4-invalid") + result = v3 < v4 Installation ============ diff --git a/src/univers/libversion.py b/src/univers/libversion.py new file mode 100644 index 00000000..c14a8433 --- /dev/null +++ b/src/univers/libversion.py @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download. + +import re + +PRE_RELEASE_KEYWORDS = ["alpha", "beta", "rc", "pre"] +POST_RELEASE_KEYWORDS = ["post", "patch", "pl", "errata"] + +KEYWORD_UNKNOWN = 0 +KEYWORD_PRE_RELEASE = 1 +KEYWORD_POST_RELEASE = 2 + +METAORDER_LOWER_BOUND = 0 +METAORDER_PRE_RELEASE = 1 +METAORDER_ZERO = 2 +METAORDER_POST_RELEASE = 3 +METAORDER_NONZERO = 4 +METAORDER_LETTER_SUFFIX = 5 +METAORDER_UPPER_BOUND = 6 + +_TOKEN_RE = re.compile(r"[A-Za-z]+|[0-9]+") + + +class LibversionVersion: + def __init__(self, version_string): + self.version_string = version_string + self.components = list(self.get_next_version_component(version_string)) + + def __hash__(self): + return hash(self.components) + + def __eq__(self, other): + return self.compare_components(other) == 0 + + def __lt__(self, other): + return self.compare_components(other) < 0 + + def __le__(self, other): + return self.compare_components(other) <= 0 + + def __gt__(self, other): + return self.compare_components(other) > 0 + + def __ge__(self, other): + return self.compare_components(other) >= 0 + + @staticmethod + def classify_keyword(s): + if s in PRE_RELEASE_KEYWORDS: + return KEYWORD_PRE_RELEASE + elif s in POST_RELEASE_KEYWORDS: + return KEYWORD_POST_RELEASE + else: + return KEYWORD_UNKNOWN + + @staticmethod + def get_next_version_component(s): + tokens = list(_TOKEN_RE.finditer(s)) + token_count = len(tokens) + parsed_tokens = [] + + prev_end = None + for token in tokens: + start, end = token.span() + delim_before = prev_end is not None and start > prev_end + parsed_tokens.append( + { + "value": token.group(0), + "is_alpha": token.group(0).isalpha(), + "delim_before": delim_before, + "start": start, + "end": end, + } + ) + prev_end = end + + for i, token in enumerate(parsed_tokens): + next_token = parsed_tokens[i + 1] if i + 1 < token_count else None + delim_after = next_token is not None and next_token["start"] > token["end"] + + if token["is_alpha"]: + raw_value = token["value"] + value = raw_value.lower() + keyword_type = LibversionVersion.classify_keyword(value) + + is_letter_suffix = False + if i > 0 and not token["delim_before"]: + prev_token = parsed_tokens[i - 1] + if not prev_token["is_alpha"]: + next_is_numeric_no_delim = ( + next_token is not None + and not next_token["is_alpha"] + and not delim_after + ) + if not next_is_numeric_no_delim: + is_letter_suffix = True + + if is_letter_suffix: + metaorder = METAORDER_LETTER_SUFFIX + elif keyword_type == KEYWORD_POST_RELEASE: + metaorder = METAORDER_POST_RELEASE + else: + metaorder = METAORDER_PRE_RELEASE + + yield value, metaorder + continue + + value = token["value"].lstrip("0") + metaorder = METAORDER_ZERO if value == "" else METAORDER_NONZERO + yield value, metaorder + + def compare_components(self, other): + max_len = max(len(self.components), len(other.components)) + + for i in range(max_len): + """ + Get current components or pad with zero + """ + c1 = self.components[i] if i < len(self.components) else ("", METAORDER_ZERO) + c2 = other.components[i] if i < len(other.components) else ("", METAORDER_ZERO) + + """ + Compare based on metaorder + """ + if c1[1] < c2[1]: + return -1 + elif c1[1] > c2[1]: + return 1 + + """ + Check based on empty components + """ + c1_is_empty = c1[0] == "" + c2_is_empty = c2[0] == "" + + if c1_is_empty and c2_is_empty: + continue + elif c1_is_empty: + return -1 + elif c2_is_empty: + return 1 + + """ + Compare based on alphabet or numeric + """ + c1_is_alpha = c1[0].isalpha() + c2_is_alpha = c2[0].isalpha() + + if c1_is_alpha and c2_is_alpha: + c1_letter = c1[0][0].lower() if c1[0] else "" + c2_letter = c2[0][0].lower() if c2[0] else "" + if c1_letter < c2_letter: + return -1 + elif c1_letter > c2_letter: + return 1 + continue + elif c1_is_alpha: + return -1 + elif c2_is_alpha: + return 1 + + """ + Compare based on numeric comparison + """ + c1_value = int(c1[0]) if c1[0] else 0 + c2_value = int(c2[0]) if c2[0] else 0 + + if c1_value < c2_value: + return -1 + elif c1_value > c2_value: + return 1 + + return 0 \ No newline at end of file diff --git a/src/univers/version_range.py b/src/univers/version_range.py index ecb68041..b977c3ff 100644 --- a/src/univers/version_range.py +++ b/src/univers/version_range.py @@ -1186,6 +1186,11 @@ def from_native(cls, string): return cls(constraints=constraints) +class LibversionVersionRange(VersionRange): + scheme = "libversion" + version_class = versions.LibversionVersion + + class MattermostVersionRange(VersionRange): scheme = "mattermost" version_class = versions.SemverVersion @@ -1446,6 +1451,7 @@ def build_range_from_snyk_advisory_string(scheme: str, string: Union[str, List]) "alpm": ArchLinuxVersionRange, "nginx": NginxVersionRange, "openssl": OpensslVersionRange, + "libversion": LibversionVersionRange, "mattermost": MattermostVersionRange, "conan": ConanVersionRange, "all": AllVersionRange, diff --git a/src/univers/versions.py b/src/univers/versions.py index 69df9d40..50bad94f 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -13,6 +13,7 @@ from univers import gem from univers import gentoo from univers import intdot +from univers import libversion from univers import maven from univers import nuget from univers import rpm @@ -88,10 +89,10 @@ def __attrs_post_init__(self): raise InvalidVersion(f"{self.string!r} is not a valid {self.__class__!r}") # Set the normalized string as default value - # Notes: setattr is used because this is an immutable frozen instance. # See https://www.attrs.org/en/stable/init.html?#post-init object.__setattr__(self, "normalized_string", normalized_string) + value = self.build_value(normalized_string) object.__setattr__(self, "value", value) @@ -177,6 +178,16 @@ def build_value(cls, string): @classmethod def is_valid(cls, string): return intdot.IntdotVersion.is_valid(string) + + +class LibversionVersion(Version): + @classmethod + def is_valid(cls, string): + return libversion.LibversionVersion(string) + + @classmethod + def build_value(cls, string): + return libversion.LibversionVersion(string) class GenericVersion(Version): diff --git a/tests/test_version_range.py b/tests/test_version_range.py index e05a41e2..67dfea3d 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -15,6 +15,7 @@ from univers.version_range import RANGE_CLASS_BY_SCHEMES from univers.version_range import IntdotVersionRange from univers.version_range import InvalidVersionRange +from univers.version_range import LibversionVersionRange from univers.version_range import MattermostVersionRange from univers.version_range import OpensslVersionRange from univers.version_range import PypiVersionRange @@ -23,6 +24,9 @@ from univers.version_range import from_gitlab_native from univers.versions import IntdotVersion from univers.versions import LexicographicVersion +from univers.versions import InvalidVersion +from univers.versions import LibversionVersion +from univers.versions import NugetVersion from univers.versions import OpensslVersion from univers.versions import PypiVersion from univers.versions import SemverVersion @@ -376,3 +380,13 @@ def test_version_range_lexicographic(): assert LexicographicVersion(-123) in VersionRange.from_string("vers:lexicographic/<~") assert LexicographicVersion(None) in VersionRange.from_string("vers:lexicographic/*") assert LexicographicVersion("ABC") in VersionRange.from_string("vers:lexicographic/>abc|<=None") + + +def test_version_range_libversion(): + assert LibversionVersion("1.2.3") in LibversionVersionRange.from_string("vers:libversion/*") + assert LibversionVersion("1.2.3") in LibversionVersionRange.from_string("vers:libversion/>0.9|<2.1.0-alpha") + assert LibversionVersion("1.0.0") in LibversionVersionRange.from_string("vers:libversion/>=1.0.0") + assert LibversionVersion("1.5.0") in LibversionVersionRange.from_string("vers:libversion/>=1.0.0|<=1.5.0") + assert not LibversionVersion("2.0.0") in LibversionVersionRange.from_string("vers:libversion/<2.0.0") + assert not LibversionVersion("1.2.3") in LibversionVersionRange.from_string("vers:libversion/>=1.2.4") + assert LibversionVersion("1.0.0") in LibversionVersionRange.from_string("vers:libversion/!=1.1.0") diff --git a/tests/test_versions.py b/tests/test_versions.py index 7f7f81b8..27ba7b3b 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -14,6 +14,7 @@ from univers.versions import GolangVersion from univers.versions import IntdotVersion from univers.versions import LexicographicVersion +from univers.versions import LibversionVersion from univers.versions import MavenVersion from univers.versions import NginxVersion from univers.versions import NugetVersion @@ -241,3 +242,22 @@ def test_lexicographic_version(): assert LexicographicVersion("Abc") < LexicographicVersion(None) assert LexicographicVersion("123") < LexicographicVersion("bbc") assert LexicographicVersion("2.3.4") > LexicographicVersion("1.2.3") + + +def test_libversion_version(): + assert LibversionVersion("1.2.3") == LibversionVersion("1.2.3") + assert LibversionVersion("1.2.3") != LibversionVersion("1.2.4") + assert LibversionVersion.is_valid("1.2.3") + assert LibversionVersion.normalize("v1.2.3") == "1.2.3" + assert LibversionVersion("1.2.3") > LibversionVersion("1.2.2") + assert LibversionVersion("1.2.3") < LibversionVersion("1.3.0") + assert LibversionVersion("1.2.3") >= LibversionVersion("1.2.3") + assert LibversionVersion("1.2.3") <= LibversionVersion("1.2.3") + assert LibversionVersion("1.2.3-alpha") < LibversionVersion("1.2.3") + assert LibversionVersion("1.2.3-alpha") != LibversionVersion("1.2.3-beta") + assert LibversionVersion("1.0custom1") < LibversionVersion("1.0") + assert LibversionVersion("1.0alpha1") == LibversionVersion("1.0a1") + assert LibversionVersion("1.0") < LibversionVersion("1.0a") + assert LibversionVersion("1.0.1") < LibversionVersion("1.0a") + assert LibversionVersion("1.0a1") < LibversionVersion("1.0") + assert LibversionVersion("1.0") == LibversionVersion("1.0.0")