diff --git a/README.rst b/README.rst index cc44df14..129d1d9c 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,19 @@ 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 adds support for libversion using a configuration option which allows users to use libversion as a fallback, i.e., in case the native version comparison fails. Usage: + +.. code:: python + + from univers.config import config + from univers.versions import PypiVersion + + v3 = PypiVersion("1.2.3-invalid") + v4 = PypiVersion("1.2.4-invalid") + result = v3 < v4 # Error without fallback + config.use_libversion_fallback = True + result = v3 < v4 # result == True Installation ============ diff --git a/src/univers/config.py b/src/univers/config.py new file mode 100644 index 00000000..850f9d2a --- /dev/null +++ b/src/univers/config.py @@ -0,0 +1,66 @@ + +# univers/config.py + +from contextlib import contextmanager + +class Config: + """ + Global configuration for univers library. + + Simple configuration for single-threaded use. + """ + + def __init__(self): + self._use_libversion_fallback = False + + @property + def use_libversion_fallback(self): + """ + Get the current libversion fallback setting. + + Returns: + bool: True if libversion fallback is enabled, False otherwise. + """ + return self._use_libversion_fallback + + @use_libversion_fallback.setter + def use_libversion_fallback(self, value): + """ + Set the global libversion fallback setting. + + Args: + value: Boolean value to enable (True) or disable (False) fallback. + + Example: + >>> from univers import config + >>> config.use_libversion_fallback = True + """ + self._use_libversion_fallback = bool(value) + + @contextmanager + def libversion_fallback(self, enabled=True): + """ + Context manager for temporary fallback setting. + + Args: + enabled (bool): Whether to enable fallback within the context. + + Example: + >>> from univers import config + >>> from univers.versions import PypiVersion + >>> + >>> with config.libversion_fallback(enabled=True): + ... v1 = PypiVersion("1.2.3-custom") + ... v2 = PypiVersion("1.2.4-custom") + ... result = v1 < v2 + """ + old_value = self._use_libversion_fallback + self._use_libversion_fallback = enabled + try: + yield + finally: + self._use_libversion_fallback = old_value + +# Global config instance + +config = Config() \ No newline at end of file diff --git a/src/univers/libversion.py b/src/univers/libversion.py new file mode 100644 index 00000000..296c5efb --- /dev/null +++ b/src/univers/libversion.py @@ -0,0 +1,130 @@ +# 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_ZERO = 1 +METAORDER_NONZERO = 2 +METAORDER_PRE_RELEASE = 3 +METAORDER_POST_RELEASE = 4 +METAORDER_LETTER_SUFFIX = 5 +METAORDER_UPPER_BOUND = 6 + + +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 parse_token_to_component(s): + if s.isalpha(): + keyword_type = LibversionVersion.classify_keyword(s) + metaorder = METAORDER_PRE_RELEASE if keyword_type == KEYWORD_PRE_RELEASE else METAORDER_POST_RELEASE + return s, metaorder + else: + s = s.lstrip("0") + metaorder = METAORDER_ZERO if s == "" else METAORDER_NONZERO + return s, metaorder + + @staticmethod + def get_next_version_component(s): + components = re.split(r"[^a-zA-Z0-9]+", s) + for component in components: + yield LibversionVersion.parse_token_to_component(component) + + 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 ("0", METAORDER_ZERO) + c2 = other.components[i] if i < len(other.components) else ("0", 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: + if c1[0].lower() < c2[0].lower(): + return -1 + elif c1[0].lower() > c2[0].lower(): + return 1 + 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..44fc2195 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -9,10 +9,12 @@ from packaging import version as packaging_version from univers import arch +from univers.config import config from univers import debian 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 @@ -84,17 +86,86 @@ class Version: def __attrs_post_init__(self): normalized_string = self.normalize(self.string) - if not self.is_valid(normalized_string): - raise InvalidVersion(f"{self.string!r} is not a valid {self.__class__!r}") + + # Skip validation if fallback is enabled + if not config.use_libversion_fallback: + if not self.is_valid(normalized_string): + print("Validation - config id:", id(config), "value:", config.use_libversion_fallback) + 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) + + # Try to build value, but allow it to fail if fallback is enabled + try: + value = self.build_value(normalized_string) + except Exception as e: + if config.use_libversion_fallback: + # Store the normalized string as value if building fails + value = normalized_string + else: + raise + object.__setattr__(self, "value", value) + def __init_subclass__(cls, **kwargs): + """ + Automatically wrap comparison methods in subclasses with fallback logic. + """ + super().__init_subclass__(**kwargs) + + comparison_methods = ['__lt__', '__le__', '__gt__', '__ge__', '__eq__', '__ne__'] + + for method_name in comparison_methods: + # Only wrap if the method is defined in THIS specific class + if method_name in cls.__dict__: + original_method = cls.__dict__[method_name] + wrapped = cls._wrap_comparison_method(original_method, method_name) + setattr(cls, method_name, wrapped) + + @staticmethod + def _wrap_comparison_method(original_method, method_name): + """ + Wrap a comparison method with fallback logic. + + Uses only standard library features (no external dependencies). + """ + def wrapper(self, other): + try: + # Try the original comparison method + return original_method(self, other) + except (ValueError, TypeError, AttributeError) as e: + # If it fails and fallback is enabled, use libversion + if config.use_libversion_fallback: + try: + import libversion + result = libversion.version_compare2(str(self), str(other)) + + # Map libversion result to the appropriate comparison + if method_name == '__lt__': + return result < 0 + elif method_name == '__le__': + return result <= 0 + elif method_name == '__gt__': + return result > 0 + elif method_name == '__ge__': + return result >= 0 + elif method_name == '__eq__': + return result == 0 + elif method_name == '__ne__': + return result != 0 + except Exception: + # If fallback also fails, re-raise the original exception + raise e + # If fallback is disabled, re-raise the original exception + raise + + # Preserve method name + wrapper.__name__ = original_method.__name__ + return wrapper + @classmethod def is_valid(cls, string): """ @@ -177,6 +248,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..b8b9486c 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 @@ -22,6 +23,7 @@ from univers.versions import RubygemsVersion from univers.versions import SemverVersion from univers.versions import Version +from univers.config import config def test_version(): @@ -241,3 +243,39 @@ 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 not LibversionVersion.is_valid("1.2.3a-1-a") + 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.0") == LibversionVersion("1.0.0") + + +def test_libversion_fallback_config(): + # Default: fallback disabled + v1 = PypiVersion("1.2.3") + v2 = PypiVersion("1.2.4") + assert v1 < v2 + + # Enable globally + config.use_libversion_fallback = True + v3 = PypiVersion("1.2.3-invalid") + v4 = PypiVersion("1.2.4-invalid") + assert v3 < v4 # Uses fallback if needed + + # Temporarily enable fallback + config.use_libversion_fallback = False + with config.libversion_fallback(enabled=True): + v5 = PypiVersion("custom-1") + v6 = PypiVersion("custom-2") + assert v5 < v6 # Uses fallback if needed +