diff --git a/requirements.txt b/requirements.txt index 938c44bc..98cf9723 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ attrs==21.2.0 packaging==21.0 pyparsing==2.4.7 semantic-version==2.8.5 +semver==2.13.0 diff --git a/setup.cfg b/setup.cfg index 47c68d52..dc721c76 100644 --- a/setup.cfg +++ b/setup.cfg @@ -60,6 +60,7 @@ install_requires = attrs packaging semantic-version + semver [options.packages.find] diff --git a/src/univers/nuget.py b/src/univers/nuget.py new file mode 100644 index 00000000..371ed69a --- /dev/null +++ b/src/univers/nuget.py @@ -0,0 +1,171 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import re + +import semver + +_PAD_WIDTH = 8 +_FAKE_PRE_WIDTH = 16 + + +def _strip_leading_v(version): + """Strip leading v from the version, if any.""" + # Versions starting with "v" aren't valid SemVer, but we handle them just in + # case. + if version.startswith("v"): + return version[1:] + + return version + + +def _remove_leading_zero(component): + """Remove leading zeros from a component.""" + if component[0] == ".": + return "." + str(int(component[1:])) + + return str(int(component)) + + +def coerce(version): + """Coerce a potentially invalid semver into valid semver.""" + version = _strip_leading_v(version) + version_pattern = re.compile(r"^(\d+)(\.\d+)?(\.\d+)?(.*)$") + match = version_pattern.match(version) + if not match: + return version + + return ( + _remove_leading_zero(match.group(1)) + + _remove_leading_zero(match.group(2) or ".0") + + _remove_leading_zero(match.group(3) or ".0") + + match.group(4) + ) + + +def is_valid(version): + """Returns whether or not the version is a valid semver.""" + return semver.VersionInfo.isvalid(_strip_leading_v(version)) + + +def parse(version): + """Parse a SemVer.""" + return semver.VersionInfo.parse(coerce(version)) + + +def normalize(version): + """Normalize semver version for indexing (to allow for lexical + sorting/filtering).""" + version = parse(version) + + # Precedence rules: https://semver.org/#spec-item-11 + + # 1. Precedence MUST be calculated by separating the version into major, + # minor, patch and pre-release identifiers in that order (Build metadata does + # not figure into precedence). + # + # Normalization: Per spec build metadata is ignored. + + # 2. Precedence is determined by the first difference when comparing each of + # these identifiers from left to right as follows: Major, minor, and patch + # versions are always compared numerically. + # + # Normalization: Pad the components with '0' to allow for lexical ordering of + # numbers. + core_parts = "{}.{}.{}".format( + str(version.major).rjust(_PAD_WIDTH, "0"), + str(version.minor).rjust(_PAD_WIDTH, "0"), + str(version.patch).rjust(_PAD_WIDTH, "0"), + ) + + # 3. When major, minor, and patch are equal, a pre-release version has lower + # precedence than a normal version: + # + # Normalization: Attach a very long fake prerelease version that is most + # likely to come after any real prelease version. + if not version.prerelease: + pre = "z" * _FAKE_PRE_WIDTH + return f"{core_parts}-{pre}" + + # 4. Precedence for two pre-release versions with the same major, minor, and + # patch version MUST be determined by comparing each dot separated identifier + # from left to right until a difference is found as follows: + # + # Normalization: Pad the components. + pre_components = [] + for component in version.prerelease.split("."): + # 3. Numeric identifiers always have lower precedence than non-numeric + # identifiers. + # + # Normalization: Pad numeric components with '0', and prefix alphanumeric + # with a single '1' (to ensure they always come after). + if component.isdigit(): + # 1. Identifiers consisting of only digits are compared numerically. + pre_components.append(component.rjust(_PAD_WIDTH, "0")) + else: + # 2. Identifiers with letters or hyphens are compared lexically in ASCII + # sort order. + pre_components.append("1" + component) + + # 4. A larger set of pre-release fields has a higher precedence than a smaller + # set, if all of the preceding identifiers are equal. + # + # Consistent with lexical sorting after normalization. + + pre = ".".join(pre_components) + return f"{core_parts}-{pre}" + + +def _extract_revision(str_version): + """Extract revision (4th component) from version number (if any).""" + # e.g. '1.0.0.0-prerelease' + pattern = re.compile(r"^(\d+)(\.\d+)(\.\d+)(\.\d+)(.*)") + match = pattern.match(str_version) + if not match: + return str_version, 0 + + return ( + "".join((match.group(1), match.group(2), match.group(3), match.group(5))), + int(match.group(4)[1:]), + ) + + +@functools.total_ordering +class Version: + """NuGet version.""" + + def __init__(self, base_semver, revision): + self._base_semver = base_semver + if self._base_semver.prerelease: + self._base_semver = self._base_semver.replace(prerelease=base_semver.prerelease.lower()) + self._revision = revision + + def __eq__(self, other): + return self._base_semver == other._base_semver and self._revision == other._revision + + def __lt__(self, other): + if self._base_semver.replace(prerelease="") == other._base_semver.replace(prerelease=""): + # If the first three components are the same, compare the revision. + if self._revision != other._revision: + return self._revision < other._revision + + # Revision is the same, so ignore it for comparison purposes. + return self._base_semver < other._base_semver + + @classmethod + def from_string(cls, str_version): + str_version = coerce(str_version) + str_version, revision = _extract_revision(str_version) + return Version(parse(str_version), revision) diff --git a/src/univers/nuget.py.ABOUT b/src/univers/nuget.py.ABOUT new file mode 100644 index 00000000..30de16ae --- /dev/null +++ b/src/univers/nuget.py.ABOUT @@ -0,0 +1,7 @@ +about_resource: nuget.py +package_url: pkg:github/google/osv@0.0.14#lib/osv/nuget.py +copyright: Copyright 2022 Google LLC +download_url: https://raw.githubusercontent.com/google/osv/f5647ad2f746685b08debfba0293e442f2fb9945/lib/osv/nuget.py +license_expression: apache-2.0 +homepage_url: https://github.com/google/osv/ +notice_file: nuget.py.NOTICE \ No newline at end of file diff --git a/src/univers/nuget.py.NOTICE b/src/univers/nuget.py.NOTICE new file mode 100644 index 00000000..6f36eecc --- /dev/null +++ b/src/univers/nuget.py.NOTICE @@ -0,0 +1,13 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. \ No newline at end of file diff --git a/src/univers/semver.py b/src/univers/univers_semver.py similarity index 100% rename from src/univers/semver.py rename to src/univers/univers_semver.py diff --git a/src/univers/version_range.py b/src/univers/version_range.py index 52c156c6..e1863c32 100644 --- a/src/univers/version_range.py +++ b/src/univers/version_range.py @@ -650,7 +650,7 @@ def from_natives(cls, strings): return cls(constraints=constraints) -class NugetVersionRange(VersionRange): +class NugetVersionRange(MavenVersionRange): """ NuGet range as in:[3.10.1,4) """ diff --git a/src/univers/versions.py b/src/univers/versions.py index 2d48a0c8..1450d03a 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -5,6 +5,8 @@ # Visit https://aboutcode.org and https://github.com/nexB/univers for support and download. from functools import total_ordering +import functools +import re import attr import semantic_version @@ -13,6 +15,7 @@ from univers import arch from univers import debian from univers import gem +from univers import nuget from univers import gentoo from univers import maven from univers import rpm @@ -284,10 +287,29 @@ def build_value(cls, string): return maven.Version(string) +# We will use total ordering to sort the versions, since these versions also consider prereleases. @attr.s(frozen=True, order=False, eq=False, hash=True) -class NugetVersion(SemverVersion): +@functools.total_ordering +class NugetVersion(Version): # See https://docs.microsoft.com/en-us/nuget/concepts/package-versioning - pass + + @classmethod + def build_value(cls, string): + return nuget.Version.from_string(string) + + @classmethod + def is_valid(cls, string): + try: + cls.build_value(string) + return True + except ValueError: + return False + + def __lt__(self, other): + return nuget.Version.from_string(self.string) < nuget.Version.from_string(other.string) + + def __eq__(self, other): + return nuget.Version.from_string(self.string) == nuget.Version.from_string(other.string) @attr.s(frozen=True, order=False, eq=False, hash=True) diff --git a/tests/test_nuget.py b/tests/test_nuget.py new file mode 100644 index 00000000..1d7bebd9 --- /dev/null +++ b/tests/test_nuget.py @@ -0,0 +1,78 @@ +# Copyright 2022 Google LLC +# Copyright (c) .NET Foundation. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# pylint: disable=line-too-long +# Many tests are ported from +# https://github.com/NuGet/NuGet.Client/blob/dev/test/NuGet.Core.Tests/NuGet.Versioning.Test/VersionComparerTests.cs + +import unittest +from univers import nuget + + +class NuGetTest(unittest.TestCase): + """NuGet version tests.""" + + def setUp(self): + self.maxDiff = None # pylint: disable=invalid-name + + def check_order(self, comparison, first, second): + """Check order.""" + comparison(nuget.Version.from_string(first), nuget.Version.from_string(second)) + + def test_equals(self): + """Test version equals.""" + self.check_order(self.assertEqual, "1.0.0", "1.0.0") + self.check_order(self.assertEqual, "1.0.0-BETA", "1.0.0-beta") + self.check_order(self.assertEqual, "1.0.0-BETA+AA", "1.0.0-beta+aa") + 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") + self.check_order(self.assertEqual, "1.0.0", "1.0.0+beta") + + self.check_order(self.assertEqual, "1.0", "1.0.0.0") + self.check_order(self.assertEqual, "1.0+test", "1.0.0.0") + self.check_order(self.assertEqual, "1.0.0.1-1.2.A", "1.0.0.1-1.2.A") + self.check_order(self.assertEqual, "1.0.01", "1.0.1.0") + + def test_not_equals(self): + """Test version not equals.""" + self.check_order(self.assertNotEqual, "1.0", "1.0.0.1") + self.check_order(self.assertNotEqual, "1.0+test", "1.0.0.1") + self.check_order(self.assertNotEqual, "1.0.0.1-1.2.A", "1.0.0.1-1.2.a.A+A") + self.check_order(self.assertNotEqual, "1.0.01", "1.0.1.2") + self.check_order(self.assertNotEqual, "0.0.0", "1.0.0") + self.check_order(self.assertNotEqual, "1.1.0", "1.0.0") + self.check_order(self.assertNotEqual, "1.0.1", "1.0.0") + self.check_order(self.assertNotEqual, "1.0.0-BETA", "1.0.0-beta2") + self.check_order(self.assertNotEqual, "1.0.0+AA", "1.0.0-beta+aa") + self.check_order( + self.assertNotEqual, "1.0.0-BETA.X.y.5.77.0+AA", "1.0.0-beta.x.y.5.79.0+aa" + ) + + def test_less(self): + """Test version less.""" + self.check_order(self.assertLess, "0.0.0", "1.0.0") + self.check_order(self.assertLess, "1.0.0", "1.1.0") + self.check_order(self.assertLess, "1.0.0", "1.0.1") + self.check_order(self.assertLess, "1.999.9999", "2.1.1") + self.check_order(self.assertLess, "1.0.0-BETA", "1.0.0-beta2") + self.check_order(self.assertLess, "1.0.0-beta+AA", "1.0.0+aa") + self.check_order(self.assertLess, "1.0.0-BETA", "1.0.0-beta.1+AA") + 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") + 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") + + self.check_order(self.assertLess, "1.0.0", "1.0.0.1") + self.check_order(self.assertLess, "1.0.0.1-alpha", "1.0.0.1-pre") + 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") diff --git a/tests/test_nuget.py.ABOUT b/tests/test_nuget.py.ABOUT new file mode 100644 index 00000000..fa11a10e --- /dev/null +++ b/tests/test_nuget.py.ABOUT @@ -0,0 +1,8 @@ +about_resource: test_nuget.py +package_url: pkg:github/google/osv@0.0.14#lib/osv/nuget_test.py +download_url: https://raw.githubusercontent.com/google/osv/f5647ad2f746685b08debfba0293e442f2fb9945/lib/osv/nuget_test.py +copyright: Copyright 2022 Google LLC + +license_expression: apache-2.0 +homepage_url: https://github.com/google/osv/ +notice_file: test_nuget.py.NOTICE \ No newline at end of file diff --git a/tests/test_nuget.py.NOTICE b/tests/test_nuget.py.NOTICE new file mode 100644 index 00000000..6f36eecc --- /dev/null +++ b/tests/test_nuget.py.NOTICE @@ -0,0 +1,13 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. \ No newline at end of file