Skip to content

Commit 6d9badc

Browse files
committed
Add support for Nuget in univers
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 951c137 commit 6d9badc

11 files changed

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

src/univers/nuget.py.ABOUT

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
about_resource: nuget.py
2+
package_url: pkg:github/google/osv
3+
copyright: Copyright 2022 Google LLC
4+
download_url: https://raw.githubusercontent.com/google/osv/f5647ad2f746685b08debfba0293e442f2fb9945/lib/osv/nuget.py
5+
6+
license_expression: apache-2.0
7+
homepage_url: https://github.com/google/osv/
8+
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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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.
14+
#
15+
# pylint: disable=line-too-long
16+
# Many tests are ported from
17+
# https://github.com/NuGet/NuGet.Client/blob/dev/test/NuGet.Core.Tests/NuGet.Versioning.Test/VersionComparerTests.cs
18+
19+
import unittest
20+
from univers import nuget
21+
22+
23+
class NuGetTest(unittest.TestCase):
24+
"""NuGet version tests."""
25+
26+
def setUp(self):
27+
self.maxDiff = None # pylint: disable=invalid-name
28+
29+
def check_order(self, comparison, first, second):
30+
"""Check order."""
31+
comparison(nuget.Version.from_string(first), nuget.Version.from_string(second))
32+
33+
def test_equals(self):
34+
"""Test version equals."""
35+
self.check_order(self.assertEqual, "1.0.0", "1.0.0")
36+
self.check_order(self.assertEqual, "1.0.0-BETA", "1.0.0-beta")
37+
self.check_order(self.assertEqual, "1.0.0-BETA+AA", "1.0.0-beta+aa")
38+
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")
39+
self.check_order(self.assertEqual, "1.0.0", "1.0.0+beta")
40+
41+
self.check_order(self.assertEqual, "1.0", "1.0.0.0")
42+
self.check_order(self.assertEqual, "1.0+test", "1.0.0.0")
43+
self.check_order(self.assertEqual, "1.0.0.1-1.2.A", "1.0.0.1-1.2.A")
44+
self.check_order(self.assertEqual, "1.0.01", "1.0.1.0")
45+
46+
def test_not_equals(self):
47+
"""Test version not equals."""
48+
self.check_order(self.assertNotEqual, "1.0", "1.0.0.1")
49+
self.check_order(self.assertNotEqual, "1.0+test", "1.0.0.1")
50+
self.check_order(self.assertNotEqual, "1.0.0.1-1.2.A", "1.0.0.1-1.2.a.A+A")
51+
self.check_order(self.assertNotEqual, "1.0.01", "1.0.1.2")
52+
self.check_order(self.assertNotEqual, "0.0.0", "1.0.0")
53+
self.check_order(self.assertNotEqual, "1.1.0", "1.0.0")
54+
self.check_order(self.assertNotEqual, "1.0.1", "1.0.0")
55+
self.check_order(self.assertNotEqual, "1.0.0-BETA", "1.0.0-beta2")
56+
self.check_order(self.assertNotEqual, "1.0.0+AA", "1.0.0-beta+aa")
57+
self.check_order(
58+
self.assertNotEqual, "1.0.0-BETA.X.y.5.77.0+AA", "1.0.0-beta.x.y.5.79.0+aa"
59+
)
60+
61+
def test_less(self):
62+
"""Test version less."""
63+
self.check_order(self.assertLess, "0.0.0", "1.0.0")
64+
self.check_order(self.assertLess, "1.0.0", "1.1.0")
65+
self.check_order(self.assertLess, "1.0.0", "1.0.1")
66+
self.check_order(self.assertLess, "1.999.9999", "2.1.1")
67+
self.check_order(self.assertLess, "1.0.0-BETA", "1.0.0-beta2")
68+
self.check_order(self.assertLess, "1.0.0-beta+AA", "1.0.0+aa")
69+
self.check_order(self.assertLess, "1.0.0-BETA", "1.0.0-beta.1+AA")
70+
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")
71+
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")
72+
73+
self.check_order(self.assertLess, "1.0.0", "1.0.0.1")
74+
self.check_order(self.assertLess, "1.0.0.1-alpha", "1.0.0.1-pre")
75+
self.check_order(self.assertLess, "1.0.0-pre", "1.0.0.1-alpha")
76+
self.check_order(self.assertLess, "1.0.0", "1.0.0.1-alpha")
77+
self.check_order(self.assertLess, "0.9.9.1", "1.0.0")

tests/test_nuget.py.ABOUT

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

0 commit comments

Comments
 (0)