From 253812c1a486a05624a919c91b16a2b617908f42 Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Tue, 17 May 2022 15:24:45 +0530 Subject: [PATCH 1/4] make nuget version hashable Signed-off-by: Keshav Priyadarshi --- src/univers/nuget.py | 8 ++++++++ tests/test_nuget.py | 11 +++++++++++ tests/test_python_semver.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 tests/test_python_semver.py diff --git a/src/univers/nuget.py b/src/univers/nuget.py index eb34c877..ff9ab0ce 100644 --- a/src/univers/nuget.py +++ b/src/univers/nuget.py @@ -175,6 +175,14 @@ def __lt__(self, other): # Revision is the same, so ignore it for comparison purposes. return self._base_semver < other._base_semver + def __hash__(self): + return hash( + ( + self._base_semver.to_tuple(), + self._revision, + ) + ) + @classmethod def from_string(cls, str_version): if not str_version: diff --git a/tests/test_nuget.py b/tests/test_nuget.py index f4b9d2a9..bbf116e1 100644 --- a/tests/test_nuget.py +++ b/tests/test_nuget.py @@ -19,6 +19,7 @@ import unittest from univers.versions import NugetVersion +from univers.nuget import Version class NuGetTest(unittest.TestCase): @@ -76,3 +77,13 @@ def test_less(self): self.check_order(self.assertLess, "1.0.0-pre", "1.0.0.1-alpha") self.check_order(self.assertLess, "1.0.0", "1.0.0.1-alpha") self.check_order(self.assertLess, "0.9.9.1", "1.0.0") + + def test_NugetVersion_hash(self): + vers1 = NugetVersion("1.0.1+23") + vers2 = NugetVersion("1.0.1+23") + assert hash(vers1) == hash(vers2) + + def test_nuget_semver_hash(self): + vers1 = Version.from_string("51.0.0+2") + vers2 = Version.from_string("51.0.0+2") + assert hash(vers1) == hash(vers2) diff --git a/tests/test_python_semver.py b/tests/test_python_semver.py new file mode 100644 index 00000000..67da1462 --- /dev/null +++ b/tests/test_python_semver.py @@ -0,0 +1,16 @@ +# +# Copyright (c) nexB Inc. and others. +# SPDX-License-Identifier: Apache-2.0 +# +# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. + +from unittest import TestCase +import semver + + +class TestPythonSemver(TestCase): + def test_semver_hash(self): + # python-semver doesn't consider build while hashing + vers1 = semver.VersionInfo.parse("1.2.3") + vers2 = semver.VersionInfo.parse("1.2.3+1") + assert hash(vers1) == hash(vers2) From b72e697781f42f9e1298332d9f235d319cab9c41 Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Wed, 11 May 2022 19:12:00 +0530 Subject: [PATCH 2/4] refactor GemVersion Signed-off-by: Keshav Priyadarshi --- src/univers/gem.py | 119 +++++++++++------------------ tests/test_rubygems_gem_version.py | 12 +-- 2 files changed, 49 insertions(+), 82 deletions(-) diff --git a/src/univers/gem.py b/src/univers/gem.py index b59d32aa..0a421319 100644 --- a/src/univers/gem.py +++ b/src/univers/gem.py @@ -199,19 +199,14 @@ def __init__(self, version): if not self.is_correct(version): raise InvalidVersionError(version) - # If version is an empty string convert it to 0 version = str(version).strip() - self.original = version + # If version is an empty string convert it to 0 if not version: version = "0" self.version = version.replace("-", ".pre.") - self._segments = () - self._canonical_segments = () - self._bump = None - self._release = None def __str__(self): return self.original @@ -225,10 +220,10 @@ def equal_strictly(self, other): return self.version == other.version def __hash__(self): - return hash(self.canonical_segments) + return hash(tuple(list(self.canonical_segments))) def __eq__(self, other): - return self.canonical_segments == other.canonical_segments + return list(self.canonical_segments) == list(other.canonical_segments) def __lt__(self, other): return self.__cmp__(other) < 0 @@ -242,6 +237,26 @@ def __gt__(self, other): def __ge__(self, other): return self.__cmp__(other) >= 0 + @property + def segments(self): + """ + Yield segments for this version where segments are + ints or strings parsed from the original version string. + """ + find_segments = re.compile(r"[0-9]+|[a-z]+", re.IGNORECASE).findall + for seg in find_segments(self.version): + yield int(seg) if seg.isdigit() else seg + + @property + def canonical_segments(self): + """ + Yield "canonical segments" for this version using + the Rubygems way for canonicalization. + """ + for segments in self.split_segments(): + segs = list(dropwhile(lambda s: s == 0, reversed(segments))) + yield from reversed(segs) + def bump(self): """ Return a new version object where the next to the last revision number @@ -252,22 +267,19 @@ def bump(self): >>> assert GemVersion("5.3.1").bump() == GemVersion("5.4"), repr(GemVersion("5.3.1").bump()) >>> assert GemVersion("5.3.1.4-2").bump() == GemVersion("5.3.2"), GemVersion("5.3.1.4-2").bump() """ - if not self._bump: - segments = [] - for seg in self.segments: - if isinstance(seg, str): - break - else: - segments.append(seg) - - if len(segments) > 1: - segments.pop() + segments = [] + for seg in self.segments: + if isinstance(seg, str): + break + else: + segments.append(seg) - segments[-1] += 1 - segments = [str(r) for r in segments] - self._bump = GemVersion(".".join(segments)) + if len(segments) > 1: + segments.pop() - return self._bump + segments[-1] += 1 + segments = [str(r) for r in segments] + return GemVersion(".".join(segments)) def release(self): """ @@ -275,17 +287,13 @@ def release(self): 1.2.0.a -> 1.2.0). Non-prerelease versions return themselves. A release is composed only of numeric segments. """ - if not self._release: - if self.prerelease(): - segments = self.segments - while any(isinstance(s, str) for s in segments): - segments.pop() - segments = (str(s) for s in segments) - self._release = GemVersion(".".join(segments)) - else: - self._release = self - - return self._release + if self.prerelease(): + segments = list(self.segments) + while any(isinstance(s, str) for s in segments): + segments.pop() + segments = (str(s) for s in segments) + return GemVersion(".".join(segments)) + return self def prerelease(self): """ @@ -294,47 +302,6 @@ def prerelease(self): """ return any(not str(s).isdigit() for s in self.segments) - @property - def segments(self): - """ - Return a new sequence of segments for this version where segments are - ints or strings parsed from the original version string. - """ - if not self._segments: - self._segments = self.get_segments() - return list(self._segments) - - def get_segments(self): - """ - Return a sequence of segments for this version where segments are ints - or strings parsed from the original version string. - """ - find_segments = re.compile(r"[0-9]+|[a-z]+", re.IGNORECASE).findall - segments = [] - for seg in find_segments(self.version): - if seg.isdigit(): - seg = int(seg) - segments.append(seg) - return tuple(segments) - - @property - def canonical_segments(self): - if not self._canonical_segments: - self._canonical_segments = self.get_canonical_segments() - return list(self._canonical_segments) - - def get_canonical_segments(self): - """ - Return a new sequence of "canonical segments" for this version using - the Rubygems way. - """ - canonical_segments = [] - for segments in self.split_segments(): - segs = list(dropwhile(lambda s: s == 0, reversed(segments))) - segs = reversed(segs) - canonical_segments.extend(segs) - return tuple(canonical_segments) - def split_segments(self): """ Return a two-tuple of segments: @@ -380,11 +347,11 @@ def __cmp__(self, other, trace=False): if self.version == other.version: return 0 - lhsegments = self.canonical_segments + lhsegments = list(self.canonical_segments) if trace: print(f" lhsegments: canonical_segments: {lhsegments!r}") - rhsegments = other.canonical_segments + rhsegments = list(other.canonical_segments) if trace: print(f" rhsegments: canonical_segments: {rhsegments!r}") diff --git a/tests/test_rubygems_gem_version.py b/tests/test_rubygems_gem_version.py index 6c14a00d..d65cbce9 100644 --- a/tests/test_rubygems_gem_version.py +++ b/tests/test_rubygems_gem_version.py @@ -168,12 +168,12 @@ def test_semver(): def test_segments(): # modifying the segments of a version should not affect the segments of the cached version object - ver = GemVersion("9.8.7") - secondseg = ver.segments[2] + ver_segment = list(GemVersion("9.8.7").segments) + secondseg = ver_segment[2] secondseg += 1 refute_version_eql("9.8.8", "9.8.7") - assert GemVersion("9.8.7").segments == [9, 8, 7] + assert list(GemVersion("9.8.7").segments) == [9, 8, 7] def test_split_segments(): @@ -181,9 +181,9 @@ def test_split_segments(): def test_canonical_segments(): - assert GemVersion("1.0.0").canonical_segments == [1] - assert GemVersion("1.0.0.a.1.0").canonical_segments == [1, "a", 1] - assert GemVersion("1.2.3-1").canonical_segments == [1, 2, 3, "pre", 1] + assert list(GemVersion("1.0.0").canonical_segments) == [1] + assert list(GemVersion("1.0.0.a.1.0").canonical_segments) == [1, "a", 1] + assert list(GemVersion("1.2.3-1").canonical_segments) == [1, 2, 3, "pre", 1] def test_frozen_version(): From df342605e2b94d5ae0276037c2c6040ef8e0f03e Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Wed, 11 May 2022 19:17:44 +0530 Subject: [PATCH 3/4] fix the constraint duplication in VersionRange - add test for constraint duplicaton - closes: https://github.com/nexB/univers/issues/45 Signed-off-by: Keshav Priyadarshi --- src/univers/version_range.py | 2 +- tests/test_version_range.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/univers/version_range.py b/src/univers/version_range.py index b6ac12b6..74604af7 100644 --- a/src/univers/version_range.py +++ b/src/univers/version_range.py @@ -51,7 +51,7 @@ class VersionRange: constraints = attr.ib(type=tuple, default=attr.Factory(tuple)) def __attrs_post_init__(self, *args, **kwargs): - constraints = tuple(sorted(self.constraints)) + constraints = tuple(sorted(set(self.constraints))) # 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, "constraints", constraints) diff --git a/tests/test_version_range.py b/tests/test_version_range.py index d56b7198..5b37b553 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -18,6 +18,7 @@ from univers.version_range import RANGE_CLASS_BY_SCHEMES from univers.version_range import NpmVersionRange from univers.version_range import OpensslVersionRange +from univers.version_range import NginxVersionRange from univers.versions import PypiVersion from univers.versions import NugetVersion from univers.versions import RubygemsVersion @@ -278,6 +279,24 @@ def test_nuget_version_range(self): assert version_range == expected assert version_range.to_string() == "vers:nuget/>=1.0.0|<2.0.0" + def test_version_range_constraint_duplication(self): + version_range = VersionRange( + constraints=( + VersionConstraint(comparator=">=", version=SemverVersion(string="1.4.0")), + VersionConstraint(comparator=">=", version=SemverVersion(string="1.4.0")), + VersionConstraint(comparator="=", version=SemverVersion(string="2.5.0")), + VersionConstraint(comparator="=", version=SemverVersion(string="2.5.0")), + ) + ) + + expected = VersionRange( + constraints=( + VersionConstraint(comparator=">=", version=SemverVersion(string="1.4.0")), + VersionConstraint(comparator="=", version=SemverVersion(string="2.5.0")), + ) + ) + assert version_range == expected + VERSION_RANGE_TESTS_BY_SCHEME = { "nginx": ["0.8.40+", "0.7.52-0.8.39", "0.9.10", "1.5.0+, 1.4.1+"], From 0fc20b65cbb4bc42b3c5a4b3ebb1ee8b05a52eb8 Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Wed, 25 May 2022 20:32:47 +0530 Subject: [PATCH 4/4] Temporary fix for semver-unstable sort - Revert this once this is fixed in upstream - https://github.com/rbarrois/python-semanticversion/issues/132 Signed-off-by: Keshav Priyadarshi --- src/univers/utils.py | 14 ++++++++++++++ src/univers/versions.py | 10 +++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/univers/utils.py b/src/univers/utils.py index b283d983..21806044 100644 --- a/src/univers/utils.py +++ b/src/univers/utils.py @@ -4,6 +4,8 @@ # # Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. +import semantic_version + def remove_spaces(string): return "".join(string.split()) @@ -27,3 +29,15 @@ def cmp(x, y): else: # note that this is the minimal replacement function return (x > y) - (x < y) + + +class SortableSemverVersion(semantic_version.Version): + """ + TODO: This is temporary workaround for unstable sort + Revert this and associated changes once the upstream is fixed. + https://github.com/rbarrois/python-semanticversion/issues/132 + """ + + @property + def precedence_key(self): + return super().precedence_key + (self.build,) diff --git a/src/univers/versions.py b/src/univers/versions.py index 6a06b40d..f0b4efc8 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -8,7 +8,7 @@ import functools import attr -import semantic_version +from univers.utils import SortableSemverVersion from packaging import version as packaging_version from univers import arch @@ -209,7 +209,7 @@ class SemverVersion(Version): @classmethod def build_value(cls, string): - return semantic_version.Version.coerce(string) + return SortableSemverVersion.coerce(string) @classmethod def is_valid(cls, string): @@ -431,14 +431,14 @@ def __gt__(self, other): class ComposerVersion(SemverVersion): @classmethod def build_value(cls, string): - return semantic_version.Version.coerce(string.lstrip("vV")) + return SortableSemverVersion.coerce(string.lstrip("vV")) @attr.s(frozen=True, order=False, eq=False, hash=True) class GolangVersion(SemverVersion): @classmethod def build_value(cls, string): - return semantic_version.Version.coerce(string.lstrip("vV")) + return SortableSemverVersion.coerce(string.lstrip("vV")) @attr.s(frozen=True, order=False, eq=False, hash=True) @@ -605,7 +605,7 @@ def is_valid_new(cls, string): True """ if SemverVersion.is_valid(string): - sem = semantic_version.Version.coerce(string) + sem = SortableSemverVersion.coerce(string) return sem.major >= 3 @classmethod