Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,26 @@ 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
<https://github.com/repology/libversion>`_. ``libversion`` works in the most
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
============
Expand Down
66 changes: 66 additions & 0 deletions src/univers/config.py
Original file line number Diff line number Diff line change
@@ -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()
130 changes: 130 additions & 0 deletions src/univers/libversion.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions src/univers/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
89 changes: 85 additions & 4 deletions src/univers/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand Down
Loading