Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Normalization of VersionRange #108

Merged
merged 31 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
218c61a
Add support for NormalizedVersionRanges
keshav-space Mar 14, 2023
1a69d07
Test NormalizedVersionRanges
keshav-space Mar 15, 2023
3b3570c
Add ABOUT and LICENSE file for spans.py
keshav-space Mar 15, 2023
d88b80c
Fallback to builtin set when intbitset is not installed
keshav-space Apr 3, 2023
5ab9b3a
Added docs server script, dark mode & copybutton for docs
OmkarPh Oct 18, 2023
af7e542
Merge pull request #83 from OmkarPh/enhance/docs
AyanSinhaMahapatra Oct 18, 2023
0a9d983
Update CSS to widen page and handle mobile #84
johnmhoran Nov 21, 2023
4e36fc6
Delete theme_overrides_SUPERSEDED.css as no longer needed #84
johnmhoran Jan 16, 2024
7d74b8a
Fix top padding for rst content
AyanSinhaMahapatra Jan 18, 2024
0071028
Merge pull request #85 from nexB/84-widen-rtd-page
AyanSinhaMahapatra Jan 18, 2024
008d521
Update CI runners and python version
AyanSinhaMahapatra Feb 19, 2024
acf94b3
Merge pull request #87 from nexB/update-macos-runners
AyanSinhaMahapatra Feb 19, 2024
124da3d
Replace deprecated macos CI runners
AyanSinhaMahapatra Jul 1, 2024
be4e14d
Update minimum required python version to 3.8
AyanSinhaMahapatra Jul 1, 2024
5c3e935
Merge pull request #90 from nexB/update-ci-runners
keshav-space Jul 1, 2024
f0bac8c
Support both PURL and GitLab schema in from_gitlab_native
keshav-space Jul 19, 2024
629d03a
Use native impl for Maven and NuGet in from_gitlab_native
keshav-space Jul 19, 2024
1c70ea5
Use proper splitter for composer in from_gitlab_native
keshav-space Jul 19, 2024
d109a1b
Support splitting bracket notation ranges
keshav-space Jul 19, 2024
169a6a1
Add support for version range from snyk advisory
keshav-space Jul 22, 2024
e7d7c55
Add support for version range from discrete versions
keshav-space Jul 22, 2024
f8a6d70
Refactor NormalizedVersionRange
keshav-space Jul 23, 2024
d2904b0
Add multi vers test for normalized version range
keshav-space Jul 24, 2024
5a32eda
Fix the edge case resulting in incorrect `contains` resolution
keshav-space Jul 24, 2024
c833b97
Refactor VersionRange normalization without Span
keshav-space Jul 24, 2024
8f0d727
Add function to parse bracket notation constraints
keshav-space Jul 24, 2024
3d0de11
Use from_versions for getting vers from discrete versions
keshav-space Jul 24, 2024
fa7009a
Merge remote-tracking branch 'origin/main' into range_normalization
keshav-space Jul 24, 2024
fe35a34
Merge remote-tracking branch 'skeleton/main' into range_normalization
keshav-space Jul 24, 2024
594baf5
Set `shell` param to False while running code style tests
keshav-space Jul 24, 2024
b12572d
Use only macOS-14 image for macOS 14 CI
keshav-space Jul 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/univers/version_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,10 @@ def contains_version(version, constraints):
if not constraints:
return False

# If we end up with constraints list contains only one item.
if len(constraints) == 1:
return version in constraints[0]

# Iterate over the current and next contiguous constraints pairs (aka. pairwise)
# in the second list.
# For each current and next constraint:
Expand Down
196 changes: 186 additions & 10 deletions src/univers/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
#
# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download.

from typing import List
from typing import Union

import attr
import semantic_version
from packaging.specifiers import InvalidSpecifier
Expand Down Expand Up @@ -227,6 +230,37 @@ def __eq__(self, other):
and self.constraints == other.constraints
)

def normalize(self, known_versions: List[str]):
"""
Return a new VersionRange normalized and simplified using the universe of
``known_versions`` list of version strings.
"""
versions = sorted([self.version_class(i) for i in known_versions])

resolved = []
contiguous = []
for kv in versions:
if self.__contains__(kv):
contiguous.append(kv)
elif contiguous:
resolved.append(contiguous)
contiguous = []

if contiguous:
resolved.append(contiguous)

version_constraints = []
for contiguous_segment in resolved:
lower_bound = contiguous_segment[0]
upper_bound = contiguous_segment[-1]
if lower_bound == upper_bound:
version_constraints.append(VersionConstraint(version=lower_bound))
else:
version_constraints.append(VersionConstraint(comparator=">=", version=lower_bound))
version_constraints.append(VersionConstraint(comparator="<=", version=upper_bound))

return self.__class__(constraints=version_constraints)


def from_cve_v4(data, scheme):
"""
Expand Down Expand Up @@ -749,7 +783,8 @@ def from_native(cls, string):
comparator = ">"
constraints.append(
VersionConstraint(
comparator=comparator, version=cls.version_class(str(lower_bound))
comparator=comparator,
version=cls.version_class(str(lower_bound)),
)
)

Expand All @@ -760,7 +795,8 @@ def from_native(cls, string):
comparator = "<"
constraints.append(
VersionConstraint(
comparator=comparator, version=cls.version_class(str(upper_bound))
comparator=comparator,
version=cls.version_class(str(upper_bound)),
)
)

Expand Down Expand Up @@ -1122,19 +1158,31 @@ class MattermostVersionRange(VersionRange):


def from_gitlab_native(gitlab_scheme, string):
purl_scheme = PURL_TYPE_BY_GITLAB_SCHEME[gitlab_scheme]
purl_scheme = gitlab_scheme
if gitlab_scheme not in PURL_TYPE_BY_GITLAB_SCHEME.values():
purl_scheme = PURL_TYPE_BY_GITLAB_SCHEME[gitlab_scheme]

vrc = RANGE_CLASS_BY_SCHEMES[purl_scheme]
supported_native_implementations = [
ConanVersionRange,
MavenVersionRange,
NugetVersionRange,
]
if vrc in supported_native_implementations:
return vrc.from_native(string)
constraint_items = []
constraints = []

split = " "
split_by_comma_schemes = ["pypi", "composer"]
if purl_scheme in split_by_comma_schemes:
if purl_scheme == "pypi":
split = ","

# GitLab advisory for composer uses both `,` and space for separating constraints.
# https://gitlab.com/gitlab-org/security-products/gemnasium-db/-/blob/8ba4872b659cf5a306e0d47abdd0e428948bf41c/packagist/illuminate/cookie/GHSA-2867-6rrm-38gr.yml
# https://gitlab.com/gitlab-org/security-products/gemnasium-db/-/blob/8ba4872b659cf5a306e0d47abdd0e428948bf41c/packagist/contao-components/mediaelement/CVE-2016-4567.yml
if purl_scheme == "composer" and "," in string:
split = ","

pipe_separated_constraints = string.split("||")
for pipe_separated_constraint in pipe_separated_constraints:
space_seperated_constraints = pipe_separated_constraint.split(split)
Expand Down Expand Up @@ -1200,7 +1248,7 @@ def build_constraint_from_github_advisory_string(scheme: str, string: str):
return VersionConstraint(comparator=comparator, version=version)


def build_range_from_github_advisory_constraint(scheme: str, string: str):
def build_range_from_github_advisory_constraint(scheme: str, string: Union[str, List]):
"""
Github has a special syntax for version ranges.
For example:
Expand All @@ -1209,7 +1257,7 @@ def build_range_from_github_advisory_constraint(scheme: str, string: str):
Github native version range looks like:
``>= 1.0.0, < 1.0.1``

Return a VersionRange built from a ``string`` single github-native
Return a VersionRange built from a ``string`` single or multiple github-native
version relationship string.

For example::
Expand All @@ -1223,14 +1271,142 @@ def build_range_from_github_advisory_constraint(scheme: str, string: str):
>>> vr = build_range_from_github_advisory_constraint("pypi","= 9.0")
>>> assert str(vr) == "vers:pypi/9.0"
"""
constraint_strings = string.split(",")
if isinstance(string, str):
string = [string]

constraints = []
vrc = RANGE_CLASS_BY_SCHEMES[scheme]
for constraint in constraint_strings:
constraints.append(build_constraint_from_github_advisory_string(scheme, constraint))
for item in string:
constraint_strings = item.split(",")

for constraint in constraint_strings:
constraints.append(build_constraint_from_github_advisory_string(scheme, constraint))
return vrc(constraints=constraints)


vers_by_snyk_native_comparators = {
"==": "=",
"=": "=",
"!=": "!=",
"<=": "<=",
">=": ">=",
"<": "<",
">": ">",
}


def split_req_bracket_notation(string):
"""
Return a tuple of (vers comparator, version) strings given an bracket notation
version requirement ``string`` such as "(2.3" or "3.9]"

For example::

>>> assert split_req_bracket_notation(" 2.3 ]") == ("<=", "2.3")
>>> assert split_req_bracket_notation("( 3.9") == (">", "3.9")
"""
comparators_front = {"(": ">", "[": ">="}
comparators_rear = {")": "<", "]": "<="}

constraint_string = remove_spaces(string).strip()

for native_comparator, vers_comparator in comparators_front.items():
if constraint_string.startswith(native_comparator):
version = constraint_string.lstrip(native_comparator)
return vers_comparator, version

for native_comparator, vers_comparator in comparators_rear.items():
if constraint_string.endswith(native_comparator):
version = constraint_string.rstrip(native_comparator)
return vers_comparator, version

raise ValueError(f"Unknown comparator in version requirement: {string!r} ")


def build_range_from_snyk_advisory_string(scheme: str, string: Union[str, List]):
"""
Return a VersionRange built from a ``string`` single or multiple snyk
version relationship string.
Snyk version range looks like:
">=4.0.0, <4.0.10.16"
">=4.1.0 <4.4.15.7"
"[3.0.0,3.1.25)"
"(,9.21]"
"[1.4.5,)"

For example::

>>> vr = build_range_from_snyk_advisory_string("pypi", ">=4.0.0, <4.0.10")
>>> assert str(vr) == "vers:pypi/>=4.0.0|<4.0.10"
>>> vr = build_range_from_snyk_advisory_string("golang", ">=9.6.0-rc1 <9.8.1-rc1")
>>> assert str(vr) == "vers:golang/>=9.6.0-rc1|<9.8.1-rc1"
>>> vr = build_range_from_snyk_advisory_string("pypi", "(,9.21]")
>>> assert str(vr) == "vers:pypi/<=9.21"
"""
version_constraints = []
vrc = RANGE_CLASS_BY_SCHEMES[scheme]

if isinstance(string, str):
string = [string]

for item in string:
delimiter = "," if "," in item else " "
if delimiter == ",":
snyk_constraints = item.strip().replace(" ", "")
constraints = snyk_constraints.split(",")
else:
snyk_constraints = item.strip()
constraints = snyk_constraints.split(" ")

for constraint in constraints:
if any(comp in constraint for comp in "[]()"):
keshav-space marked this conversation as resolved.
Show resolved Hide resolved
comparator, version = split_req_bracket_notation(string=constraint)
else:
comparator, version = split_req(
string=constraint,
comparators=vers_by_snyk_native_comparators,
)
if comparator and version:
version = vrc.version_class(version)
version_constraints.append(
VersionConstraint(
comparator=comparator,
version=version,
)
)
return vrc(constraints=version_constraints)


def build_range_from_discrete_version_string(scheme: str, string: Union[str, List]):
keshav-space marked this conversation as resolved.
Show resolved Hide resolved
"""
Return VersionRange computed from discrete versions.
Discrete version range looks like:
["1.5","3.1.2","3.1-beta"]

For example::

# >>> vr = build_constraints_from_discrete_version_string("pypi", ["1.5","3.1.2","3.1-beta"])
# >>> assert str(vr) == "vers:pypi/1.5|3.1-beta|3.1.2"
# >>> vr = build_constraints_from_discrete_version_string("pypi","9.21")
# >>> assert str(vr) == "vers:pypi/9.21"
"""
version_constraints = []
vrc = RANGE_CLASS_BY_SCHEMES[scheme]

if isinstance(string, str):
string = [string]

for item in string:
version = item.strip()
version = vrc.version_class(version)
version_constraints.append(
VersionConstraint(
version=version,
)
)
return vrc(constraints=version_constraints)


RANGE_CLASS_BY_SCHEMES = {
"npm": NpmVersionRange,
"deb": DebianVersionRange,
Expand Down
2 changes: 1 addition & 1 deletion tests/data/composer_gitlab.json
Original file line number Diff line number Diff line change
Expand Up @@ -7533,7 +7533,7 @@
"test_index": 1256,
"scheme": "packagist",
"gitlab_native": "<=3.8.0||>=4.0.0-alpha <=4.0.0-rc2",
"expected_vers": "vers:composer/<=3.8.0|>=4.0.0-alpha--4.0.0-rc2"
"expected_vers": "vers:composer/<=3.8.0|>=4.0.0-alpha|<=4.0.0-rc2"
},
{
"test_index": 1257,
Expand Down
68 changes: 67 additions & 1 deletion tests/test_version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from univers.version_range import OpensslVersionRange
from univers.version_range import PypiVersionRange
from univers.version_range import VersionRange
from univers.version_range import build_range_from_discrete_version_string
from univers.version_range import build_range_from_snyk_advisory_string
from univers.version_range import from_gitlab_native
from univers.versions import InvalidVersion
from univers.versions import NugetVersion
Expand All @@ -43,7 +45,7 @@ def test_VersionRange_to_string(self):
assert str(version_range) == "vers:pypi/>=0.0.0|0.0.1|0.0.2|0.0.3|0.0.4|0.0.5|0.0.6"

def test_VersionRange_pypi_does_not_contain_basic(self):
vers = "vers:pypi/0.0.2|0.0.6|>=0.0.0|0.0.1|0.0.4|0.0.5|0.0.3"
vers = "vers:pypi/0.0.2|0.0.6|>=3.0.0|0.0.1|0.0.4|0.0.5|0.0.3"
version_range = VersionRange.from_string(vers)
assert not version_range.contains(PypiVersion("2.0.3"))

Expand Down Expand Up @@ -85,6 +87,11 @@ def test_VersionRange_contains_version_in_between(self):
version_range = VersionRange.from_string(vers)
assert version_range.contains(PypiVersion("1.5"))

def test_VersionRange_contains_filterd_constraint_edge_case(self):
vers = "vers:pypi/<=1.3.0|3.0.0"
version_range = VersionRange.from_string(vers)
assert version_range.contains(PypiVersion("1.0.0"))

def test_VersionRange_from_string_pypi(self):
vers = "vers:pypi/0.0.2|0.0.6|0.0.0|0.0.1|0.0.4|0.0.5|0.0.3"
version_range = VersionRange.from_string(vers)
Expand Down Expand Up @@ -456,3 +463,62 @@ def test_mattermost_version_range():
VersionConstraint(comparator=">=", version=SemverVersion("5.0")),
]
) == VersionRange.from_string("vers:mattermost/>=5.0")


def test_build_range_from_snyk_advisory_string():
expression = [">=4.0.0, <4.0.10", ">7.0.0, <8.0.1"]
vr = build_range_from_snyk_advisory_string("pypi", expression)
expected = "vers:pypi/>=4.0.0|<4.0.10|>7.0.0|<8.0.1"

assert str(vr) == expected


def test_build_range_from_snyk_advisory_string_bracket():
expression = ["[3.0.0,3.1.25)", "[1.0.0,1.0.5)"]
vr = build_range_from_snyk_advisory_string("nuget", expression)
expected = "vers:nuget/>=1.0.0|<1.0.5|>=3.0.0|<3.1.25"

assert str(vr) == expected


def test_build_range_from_snyk_advisory_string_spaced():
expression = [">=4.1.0 <4.4.1", ">2.1.0 <=3.2.7"]
vr = build_range_from_snyk_advisory_string("composer", expression)
expected = "vers:composer/>2.1.0|<=3.2.7|>=4.1.0|<4.4.1"

assert str(vr) == expected


def test_build_range_from_discrete_version_string():
expression = ["4.1.0", " 4.4.1", "2.1.0 ", " 3.2.7 "]
vr = build_range_from_discrete_version_string("pypi", expression)
expected = "vers:pypi/2.1.0|3.2.7|4.1.0|4.4.1"

assert str(vr) == expected


def test_version_range_normalize_case1():
known_versions = ["4.0.0", "3.0.0", "1.0.0", "2.0.0", "1.3.0", "1.1.0", "1.2.0"]

vr = VersionRange.from_string("vers:pypi/<=1.1.0|>=1.2.0|<=1.3.0|3.0.0")
nvr = vr.normalize(known_versions=known_versions)

assert str(nvr) == "vers:pypi/>=1.0.0|<=1.3.0|3.0.0"


def test_version_range_normalize_case2():
known_versions = ["4.0.0", "3.0.0", "1.0.0", "2.0.0", "1.3.0", "1.1.0", "1.2.0"]

vr = VersionRange.from_string("vers:pypi/<=1.3.0|3.0.0")
nvr = vr.normalize(known_versions=known_versions)

assert str(nvr) == "vers:pypi/>=1.0.0|<=1.3.0|3.0.0"


def test_version_range_normalize_case3():
known_versions = ["4.0.0", "3.0.0", "1.0.0", "2.0.0", "1.3.0", "1.1.0", "1.2.0"]

vr = VersionRange.from_string("vers:pypi/<2.0.0|3.0.0")
nvr = vr.normalize(known_versions=known_versions)

assert str(nvr) == "vers:pypi/>=1.0.0|<=1.3.0|3.0.0"
Loading