Skip to content

Commit f2413c4

Browse files
committed
add libversion fallback
Signed-off-by: Kunz, Immanuel <immanuel.kunz@aisec.fraunhofer.de>
1 parent d4817f4 commit f2413c4

4 files changed

Lines changed: 169 additions & 8 deletions

File tree

README.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,21 @@ and support for more package types are implemented on a continuous basis.
120120

121121
Alternative
122122
============
123-
124123
Rather than using ecosystem-specific version schemes and code, another approach
125124
is to use a single procedure for all the versions as implemented in `libversion
126125
<https://github.com/repology/libversion>`_. ``libversion`` works in the most
127126
common case but may not work correctly when a task that demand precise version
128127
comparisons such as for dependency resolution and vulnerability lookup where
129128
a "good enough" comparison accuracy is not acceptable. ``libversion`` does not
130129
handle version range notations.
130+
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:
131+
.. code:: python
132+
v3 = PypiVersion("1.2.3-invalid")
133+
v4 = PypiVersion("1.2.4-invalid")
134+
result = v3 < v4 # Error without fallback
131135
136+
config.use_libversion_fallback = True
137+
result = v3 < v4 # result == True
132138
133139
Installation
134140
============

src/univers/config.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
2+
# univers/config.py
3+
4+
from contextlib import contextmanager
5+
6+
class Config:
7+
"""
8+
Global configuration for univers library.
9+
10+
Simple configuration for single-threaded use.
11+
"""
12+
13+
def __init__(self):
14+
self._use_libversion_fallback = False
15+
16+
@property
17+
def use_libversion_fallback(self):
18+
"""
19+
Get the current libversion fallback setting.
20+
21+
Returns:
22+
bool: True if libversion fallback is enabled, False otherwise.
23+
"""
24+
return self._use_libversion_fallback
25+
26+
@use_libversion_fallback.setter
27+
def use_libversion_fallback(self, value):
28+
"""
29+
Set the global libversion fallback setting.
30+
31+
Args:
32+
value: Boolean value to enable (True) or disable (False) fallback.
33+
34+
Example:
35+
>>> from univers import config
36+
>>> config.use_libversion_fallback = True
37+
"""
38+
self._use_libversion_fallback = bool(value)
39+
40+
@contextmanager
41+
def libversion_fallback(self, enabled=True):
42+
"""
43+
Context manager for temporary fallback setting.
44+
45+
Args:
46+
enabled (bool): Whether to enable fallback within the context.
47+
48+
Example:
49+
>>> from univers import config
50+
>>> from univers.versions import PypiVersion
51+
>>>
52+
>>> with config.libversion_fallback(enabled=True):
53+
... v1 = PypiVersion("1.2.3-custom")
54+
... v2 = PypiVersion("1.2.4-custom")
55+
... result = v1 < v2
56+
"""
57+
old_value = self._use_libversion_fallback
58+
self._use_libversion_fallback = enabled
59+
try:
60+
yield
61+
finally:
62+
self._use_libversion_fallback = old_value
63+
64+
# Global config instance
65+
66+
config = Config()

src/univers/versions.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from packaging import version as packaging_version
1010

1111
from univers import arch
12+
from univers.config import config
1213
from univers import debian
1314
from univers import gem
1415
from univers import gentoo
@@ -85,17 +86,86 @@ class Version:
8586

8687
def __attrs_post_init__(self):
8788
normalized_string = self.normalize(self.string)
88-
if not self.is_valid(normalized_string):
89-
raise InvalidVersion(f"{self.string!r} is not a valid {self.__class__!r}")
89+
90+
# Skip validation if fallback is enabled
91+
if not config.use_libversion_fallback:
92+
if not self.is_valid(normalized_string):
93+
print("Validation - config id:", id(config), "value:", config.use_libversion_fallback)
94+
raise InvalidVersion(f"{self.string!r} is not a valid {self.__class__!r}")
9095

9196
# Set the normalized string as default value
92-
9397
# Notes: setattr is used because this is an immutable frozen instance.
9498
# See https://www.attrs.org/en/stable/init.html?#post-init
9599
object.__setattr__(self, "normalized_string", normalized_string)
96-
value = self.build_value(normalized_string)
100+
101+
# Try to build value, but allow it to fail if fallback is enabled
102+
try:
103+
value = self.build_value(normalized_string)
104+
except Exception as e:
105+
if config.use_libversion_fallback:
106+
# Store the normalized string as value if building fails
107+
value = normalized_string
108+
else:
109+
raise
110+
97111
object.__setattr__(self, "value", value)
98112

113+
def __init_subclass__(cls, **kwargs):
114+
"""
115+
Automatically wrap comparison methods in subclasses with fallback logic.
116+
"""
117+
super().__init_subclass__(**kwargs)
118+
119+
comparison_methods = ['__lt__', '__le__', '__gt__', '__ge__', '__eq__', '__ne__']
120+
121+
for method_name in comparison_methods:
122+
# Only wrap if the method is defined in THIS specific class
123+
if method_name in cls.__dict__:
124+
original_method = cls.__dict__[method_name]
125+
wrapped = cls._wrap_comparison_method(original_method, method_name)
126+
setattr(cls, method_name, wrapped)
127+
128+
@staticmethod
129+
def _wrap_comparison_method(original_method, method_name):
130+
"""
131+
Wrap a comparison method with fallback logic.
132+
133+
Uses only standard library features (no external dependencies).
134+
"""
135+
def wrapper(self, other):
136+
try:
137+
# Try the original comparison method
138+
return original_method(self, other)
139+
except (ValueError, TypeError, AttributeError) as e:
140+
# If it fails and fallback is enabled, use libversion
141+
if config.use_libversion_fallback:
142+
try:
143+
import libversion
144+
result = libversion.version_compare2(str(self), str(other))
145+
146+
# Map libversion result to the appropriate comparison
147+
if method_name == '__lt__':
148+
return result < 0
149+
elif method_name == '__le__':
150+
return result <= 0
151+
elif method_name == '__gt__':
152+
return result > 0
153+
elif method_name == '__ge__':
154+
return result >= 0
155+
elif method_name == '__eq__':
156+
return result == 0
157+
elif method_name == '__ne__':
158+
return result != 0
159+
except Exception:
160+
# If fallback also fails, re-raise the original exception
161+
raise e
162+
# If fallback is disabled, re-raise the original exception
163+
raise
164+
165+
# Preserve method name
166+
wrapper.__name__ = original_method.__name__
167+
return wrapper
168+
99169
@classmethod
100170
def is_valid(cls, string):
101171
"""

tests/test_versions.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,9 @@
1212
from univers.versions import EnhancedSemanticVersion
1313
from univers.versions import GentooVersion
1414
from univers.versions import GolangVersion
15-
<<<<<<< HEAD
1615
from univers.versions import IntdotVersion
1716
from univers.versions import LexicographicVersion
18-
=======
1917
from univers.versions import LibversionVersion
20-
>>>>>>> 1533a12 (first libversion draft)
2118
from univers.versions import MavenVersion
2219
from univers.versions import NginxVersion
2320
from univers.versions import NugetVersion
@@ -26,6 +23,7 @@
2623
from univers.versions import RubygemsVersion
2724
from univers.versions import SemverVersion
2825
from univers.versions import Version
26+
from univers.config import config
2927

3028

3129
def test_version():
@@ -260,3 +258,24 @@ def test_libversion_version():
260258
assert LibversionVersion("1.2.3-alpha") < LibversionVersion("1.2.3")
261259
assert LibversionVersion("1.2.3-alpha") != LibversionVersion("1.2.3-beta")
262260
assert LibversionVersion("1.0") == LibversionVersion("1.0.0")
261+
262+
263+
def test_libversion_fallback_config():
264+
# Default: fallback disabled
265+
v1 = PypiVersion("1.2.3")
266+
v2 = PypiVersion("1.2.4")
267+
assert v1 < v2
268+
269+
# Enable globally
270+
config.use_libversion_fallback = True
271+
v3 = PypiVersion("1.2.3-invalid")
272+
v4 = PypiVersion("1.2.4-invalid")
273+
assert v3 < v4 # Uses fallback if needed
274+
275+
# Temporarily enable fallback
276+
config.use_libversion_fallback = False
277+
with config.libversion_fallback(enabled=True):
278+
v5 = PypiVersion("custom-1")
279+
v6 = PypiVersion("custom-2")
280+
assert v5 < v6 # Uses fallback if needed
281+

0 commit comments

Comments
 (0)