Skip to content

Commit 3e89f8c

Browse files
committed
Add nuget version support in univers
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 951c137 commit 3e89f8c

11 files changed

Lines changed: 299 additions & 3 deletions

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ attrs==21.2.0
22
packaging==21.0
33
pyparsing==2.4.7
44
semantic-version==2.8.5
5+
semver==2.13.0

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ install_requires =
6060
attrs
6161
packaging
6262
semantic-version
63+
semver
6364

6465

6566
[options.packages.find]

src/univers/nuget.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#
2+
# Copyright (c) nexB Inc. and others.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download.
6+
7+
import functools
8+
import re
9+
10+
import semver
11+
12+
_PAD_WIDTH = 8
13+
_FAKE_PRE_WIDTH = 16
14+
15+
16+
def _strip_leading_v(version):
17+
"""Strip leading v from the version, if any."""
18+
# Versions starting with "v" aren't valid SemVer, but we handle them just in
19+
# case.
20+
if version.startswith("v"):
21+
return version[1:]
22+
23+
return version
24+
25+
26+
def _remove_leading_zero(component):
27+
"""Remove leading zeros from a component."""
28+
if component[0] == ".":
29+
return "." + str(int(component[1:]))
30+
31+
return str(int(component))
32+
33+
34+
def coerce(version):
35+
"""Coerce a potentially invalid semver into valid semver."""
36+
version = _strip_leading_v(version)
37+
version_pattern = re.compile(r"^(\d+)(\.\d+)?(\.\d+)?(.*)$")
38+
match = version_pattern.match(version)
39+
if not match:
40+
return version
41+
42+
return (
43+
_remove_leading_zero(match.group(1))
44+
+ _remove_leading_zero(match.group(2) or ".0")
45+
+ _remove_leading_zero(match.group(3) or ".0")
46+
+ match.group(4)
47+
)
48+
49+
50+
def is_valid(version):
51+
"""Returns whether or not the version is a valid semver."""
52+
return semver.VersionInfo.isvalid(_strip_leading_v(version))
53+
54+
55+
def parse(version):
56+
"""Parse a SemVer."""
57+
return semver.VersionInfo.parse(coerce(version))
58+
59+
60+
def normalize(version):
61+
"""Normalize semver version for indexing (to allow for lexical
62+
sorting/filtering)."""
63+
version = parse(version)
64+
65+
# Precedence rules: https://semver.org/#spec-item-11
66+
67+
# 1. Precedence MUST be calculated by separating the version into major,
68+
# minor, patch and pre-release identifiers in that order (Build metadata does
69+
# not figure into precedence).
70+
#
71+
# Normalization: Per spec build metadata is ignored.
72+
73+
# 2. Precedence is determined by the first difference when comparing each of
74+
# these identifiers from left to right as follows: Major, minor, and patch
75+
# versions are always compared numerically.
76+
#
77+
# Normalization: Pad the components with '0' to allow for lexical ordering of
78+
# numbers.
79+
core_parts = "{}.{}.{}".format(
80+
str(version.major).rjust(_PAD_WIDTH, "0"),
81+
str(version.minor).rjust(_PAD_WIDTH, "0"),
82+
str(version.patch).rjust(_PAD_WIDTH, "0"),
83+
)
84+
85+
# 3. When major, minor, and patch are equal, a pre-release version has lower
86+
# precedence than a normal version:
87+
#
88+
# Normalization: Attach a very long fake prerelease version that is most
89+
# likely to come after any real prelease version.
90+
if not version.prerelease:
91+
pre = "z" * _FAKE_PRE_WIDTH
92+
return f"{core_parts}-{pre}"
93+
94+
# 4. Precedence for two pre-release versions with the same major, minor, and
95+
# patch version MUST be determined by comparing each dot separated identifier
96+
# from left to right until a difference is found as follows:
97+
#
98+
# Normalization: Pad the components.
99+
pre_components = []
100+
for component in version.prerelease.split("."):
101+
# 3. Numeric identifiers always have lower precedence than non-numeric
102+
# identifiers.
103+
#
104+
# Normalization: Pad numeric components with '0', and prefix alphanumeric
105+
# with a single '1' (to ensure they always come after).
106+
if component.isdigit():
107+
# 1. Identifiers consisting of only digits are compared numerically.
108+
pre_components.append(component.rjust(_PAD_WIDTH, "0"))
109+
else:
110+
# 2. Identifiers with letters or hyphens are compared lexically in ASCII
111+
# sort order.
112+
pre_components.append("1" + component)
113+
114+
# 4. A larger set of pre-release fields has a higher precedence than a smaller
115+
# set, if all of the preceding identifiers are equal.
116+
#
117+
# Consistent with lexical sorting after normalization.
118+
119+
pre = ".".join(pre_components)
120+
return f"{core_parts}-{pre}"
121+
122+
123+
def _extract_revision(str_version):
124+
"""Extract revision (4th component) from version number (if any)."""
125+
# e.g. '1.0.0.0-prerelease'
126+
pattern = re.compile(r"^(\d+)(\.\d+)(\.\d+)(\.\d+)(.*)")
127+
match = pattern.match(str_version)
128+
if not match:
129+
return str_version, 0
130+
131+
return (
132+
"".join((match.group(1), match.group(2), match.group(3), match.group(5))),
133+
int(match.group(4)[1:]),
134+
)
135+
136+
137+
@functools.total_ordering
138+
class Version:
139+
"""NuGet version."""
140+
141+
def __init__(self, base_semver, revision):
142+
self._base_semver = base_semver
143+
if self._base_semver.prerelease:
144+
self._base_semver = self._base_semver.replace(prerelease=base_semver.prerelease.lower())
145+
self._revision = revision
146+
147+
def __eq__(self, other):
148+
return self._base_semver == other._base_semver and self._revision == other._revision
149+
150+
def __lt__(self, other):
151+
if self._base_semver.replace(prerelease="") == other._base_semver.replace(prerelease=""):
152+
# If the first three components are the same, compare the revision.
153+
if self._revision != other._revision:
154+
return self._revision < other._revision
155+
156+
# Revision is the same, so ignore it for comparison purposes.
157+
return self._base_semver < other._base_semver
158+
159+
@classmethod
160+
def from_string(cls, str_version):
161+
str_version = coerce(str_version)
162+
str_version, revision = _extract_revision(str_version)
163+
return Version(parse(str_version), revision)

src/univers/nuget.py.ABOUT

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
about_resource: maven.py
2+
package_url: pkg:github/google/osv
3+
copyright: |
4+
Copyright 2022 Google LLC
5+
6+
notes: This has been substantially modified and enhanced from the original
7+
pymaven code to extract the version comparison code.
8+
9+
license_expression: apache-2.0
10+
homepage_url: https://github.com/google/osv/
11+
notice_file: nuget.py.NOTICE

src/univers/nuget.py.NOTICE

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.

src/univers/version_range.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ def from_natives(cls, strings):
650650
return cls(constraints=constraints)
651651

652652

653-
class NugetVersionRange(VersionRange):
653+
class NugetVersionRange(MavenVersionRange):
654654
"""
655655
NuGet range as in:[3.10.1,4)
656656
"""

src/univers/versions.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download.
66

77
from functools import total_ordering
8+
import functools
9+
import re
810

911
import attr
1012
import semantic_version
@@ -13,6 +15,7 @@
1315
from univers import arch
1416
from univers import debian
1517
from univers import gem
18+
from univers import nuget
1619
from univers import gentoo
1720
from univers import maven
1821
from univers import rpm
@@ -284,10 +287,29 @@ def build_value(cls, string):
284287
return maven.Version(string)
285288

286289

290+
# We will use total ordering to sort the versions, since these versions also consider prereleases.
287291
@attr.s(frozen=True, order=False, eq=False, hash=True)
288-
class NugetVersion(SemverVersion):
292+
@functools.total_ordering
293+
class NugetVersion(Version):
289294
# See https://docs.microsoft.com/en-us/nuget/concepts/package-versioning
290-
pass
295+
296+
@classmethod
297+
def build_value(cls, string):
298+
return nuget.Version.from_string(string)
299+
300+
@classmethod
301+
def is_valid(cls, string):
302+
try:
303+
cls.build_value(string)
304+
return True
305+
except ValueError:
306+
return False
307+
308+
def __lt__(self, other):
309+
return nuget.Version.from_string(self.string) < nuget.Version.from_string(other.string)
310+
311+
def __eq__(self, other):
312+
return nuget.Version.from_string(self.string) == nuget.Version.from_string(other.string)
291313

292314

293315
@attr.s(frozen=True, order=False, eq=False, hash=True)

tests/test_nuget.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#
2+
# Copyright (c) nexB Inc. and others.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download.
6+
7+
import unittest
8+
from univers import nuget
9+
10+
11+
class NuGetTest(unittest.TestCase):
12+
"""NuGet version tests."""
13+
14+
def setUp(self):
15+
self.maxDiff = None # pylint: disable=invalid-name
16+
17+
def check_order(self, comparison, first, second):
18+
"""Check order."""
19+
comparison(nuget.Version.from_string(first), nuget.Version.from_string(second))
20+
21+
def test_equals(self):
22+
"""Test version equals."""
23+
self.check_order(self.assertEqual, "1.0.0", "1.0.0")
24+
self.check_order(self.assertEqual, "1.0.0-BETA", "1.0.0-beta")
25+
self.check_order(self.assertEqual, "1.0.0-BETA+AA", "1.0.0-beta+aa")
26+
self.check_order(self.assertEqual, "1.0.0-BETA.X.y.5.77.0+AA", "1.0.0-beta.x.y.5.77.0+aa")
27+
self.check_order(self.assertEqual, "1.0.0", "1.0.0+beta")
28+
29+
self.check_order(self.assertEqual, "1.0", "1.0.0.0")
30+
self.check_order(self.assertEqual, "1.0+test", "1.0.0.0")
31+
self.check_order(self.assertEqual, "1.0.0.1-1.2.A", "1.0.0.1-1.2.A")
32+
self.check_order(self.assertEqual, "1.0.01", "1.0.1.0")
33+
34+
def test_not_equals(self):
35+
"""Test version not equals."""
36+
self.check_order(self.assertNotEqual, "1.0", "1.0.0.1")
37+
self.check_order(self.assertNotEqual, "1.0+test", "1.0.0.1")
38+
self.check_order(self.assertNotEqual, "1.0.0.1-1.2.A", "1.0.0.1-1.2.a.A+A")
39+
self.check_order(self.assertNotEqual, "1.0.01", "1.0.1.2")
40+
self.check_order(self.assertNotEqual, "0.0.0", "1.0.0")
41+
self.check_order(self.assertNotEqual, "1.1.0", "1.0.0")
42+
self.check_order(self.assertNotEqual, "1.0.1", "1.0.0")
43+
self.check_order(self.assertNotEqual, "1.0.0-BETA", "1.0.0-beta2")
44+
self.check_order(self.assertNotEqual, "1.0.0+AA", "1.0.0-beta+aa")
45+
self.check_order(
46+
self.assertNotEqual, "1.0.0-BETA.X.y.5.77.0+AA", "1.0.0-beta.x.y.5.79.0+aa"
47+
)
48+
49+
def test_less(self):
50+
"""Test version less."""
51+
self.check_order(self.assertLess, "0.0.0", "1.0.0")
52+
self.check_order(self.assertLess, "1.0.0", "1.1.0")
53+
self.check_order(self.assertLess, "1.0.0", "1.0.1")
54+
self.check_order(self.assertLess, "1.999.9999", "2.1.1")
55+
self.check_order(self.assertLess, "1.0.0-BETA", "1.0.0-beta2")
56+
self.check_order(self.assertLess, "1.0.0-beta+AA", "1.0.0+aa")
57+
self.check_order(self.assertLess, "1.0.0-BETA", "1.0.0-beta.1+AA")
58+
self.check_order(self.assertLess, "1.0.0-BETA.X.y.5.77.0+AA", "1.0.0-beta.x.y.5.79.0+aa")
59+
self.check_order(self.assertLess, "1.0.0-BETA.X.y.5.79.0+AA", "1.0.0-beta.x.y.5.790.0+abc")
60+
61+
self.check_order(self.assertLess, "1.0.0", "1.0.0.1")
62+
self.check_order(self.assertLess, "1.0.0.1-alpha", "1.0.0.1-pre")
63+
self.check_order(self.assertLess, "1.0.0-pre", "1.0.0.1-alpha")
64+
self.check_order(self.assertLess, "1.0.0", "1.0.0.1-alpha")
65+
self.check_order(self.assertLess, "0.9.9.1", "1.0.0")

tests/test_nuget.py.ABOUT

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
about_resource: maven.py
2+
package_url: pkg:github/google/osv
3+
copyright: Copyright 2022 Google LLC
4+
5+
license_expression: apache-2.0
6+
homepage_url: https://github.com/google/osv/
7+
notice_file: test_nuget.py.NOTICE

0 commit comments

Comments
 (0)