Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
99 changes: 99 additions & 0 deletions src/univers/version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#
# Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download.

import re
from typing import List
from typing import Union

Expand Down Expand Up @@ -772,6 +773,104 @@ def from_native(cls, string):

return cls(constraints=constraints)

@classmethod
def from_ossa_native(cls, string):
"""
Returns a PypiVersionRange built from an OpenStack Security Advisory (OSSA) version constraint ``string``.

See: https://github.com/openstack/ossa

For example::

>>> str(PypiVersionRange.from_ossa_native("<=5.0.3, >=6.0.0 <=6.1.0 and ==7.0.0"))
'vers:pypi/<=5.0.3|>=6.0.0|<=6.1.0|7.0.0'

>>> str(PypiVersionRange.from_ossa_native("<=14.0.10, >=15.0.0 <=15.0.8, >=16.0.0 <=16.0.3"))
'vers:pypi/<=14.0.10|>=15.0.0|<=15.0.8|>=16.0.0|<=16.0.3'

>>> str(PypiVersionRange.from_ossa_native("<20.2.1, >=21.0.0 <21.2.1, ==22.0.0"))
'vers:pypi/<20.2.1|>=21.0.0|<21.2.1|22.0.0'

>>> str(PypiVersionRange.from_ossa_native(">=1.15.0<1.15.2, 1.16.0"))
'vers:pypi/>=1.15.0|<1.15.2|1.16.0'
"""

# Normalize "and" keyword to comma
# "<=5.0.3, >=6.0.0 <=6.1.0 and ==7.0.0" -> "<=5.0.3, >=6.0.0 <=6.1.0, ==7.0.0"
string = string.replace(" and ", ",")

# Split on commas then whitespace to get individual tokens
# "<=5.0.3 >=6.0.0 " -> ["<=5.0.3", ">=6.0.0"]
raw_tokens = [token for chunk in string.split(",") for token in chunk.split()]

if not raw_tokens:
raise InvalidVersionRange(f"Empty or invalid OSSA version string: {string!r}")

# Merge tokens where the operator and version are separated by a space
# ["<=", "5.0.3", ">=", "6.0.0"] -> ["<=5.0.3", ">=6.0.0"]
raw_iter = iter(raw_tokens)
fused_tokens = []
for current in raw_iter:
is_bare_operator = all(c in "<>=!" for c in current)
if is_bare_operator:
fused_tokens.append(current + next(raw_iter))
else:
fused_tokens.append(current)

# Split tokens that contain multiple concatenated constraints without separator
# ">=1.15.0<1.15.2" -> [">=1.15.0", "<1.15.2"]
parts = []
for token in fused_tokens:

token_len = len(token)
pos = 0
while pos < token_len and token[pos] in "<>=!":
pos += 1

segment_start = 0
while pos < token_len:
if token[pos] in "<>=!":
parts.append(token[segment_start:pos])
segment_start = pos
while pos < token_len and token[pos] in "<>=!":
pos += 1
else:
pos += 1
parts.append(token[segment_start:])

constraints = []
for part in parts:

# Default to exact match for bare version numbers
# "1.16.0" -> "=1.16.0"
comparator = "="
version = part

for op, vers_op in cls.vers_by_native_comparators.items():
if part.startswith(op):
comparator = vers_op
version = part[len(op) :]
break

# Handle bare "=" for exact match
# "=18.0.0" -> "18.0.0"
if version.startswith("="):
version = version[1:]

try:
constraints.append(
VersionConstraint(
comparator=comparator,
version=cls.version_class(version),
)
)
except (ValueError, TypeError) as e:
raise InvalidVersionRange(
f"Invalid version constraint {part!r} in OSSA version string {string!r}: {e}"
) from e

return cls(constraints=constraints)
Comment thread
Samk1710 marked this conversation as resolved.


class MavenVersionRange(VersionRange):
"""
Expand Down
42 changes: 42 additions & 0 deletions tests/test_version_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,48 @@ def test_PypiVersionRange_raises_ivr_for_unsupported_and_invalid_ranges(range, w
assert expected == str(PypiVersionRange.from_native(range))


@pytest.mark.parametrize(
"string, expected",
[
( # OSSA-2016-013
"<=5.0.3, >=6.0.0 <=6.1.0 and ==7.0.0",
"vers:pypi/<=5.0.3|>=6.0.0|<=6.1.0|7.0.0",
),
( # OSSA-2017-005
"<=14.0.10, >=15.0.0 <=15.0.8, >=16.0.0 <=16.0.3",
"vers:pypi/<=14.0.10|>=15.0.0|<=15.0.8|>=16.0.0|<=16.0.3",
),
( # OSSA-2019-003
"<17.0.12, >=18.0.0 <18.2.2, >=19.0.0 <19.0.2",
"vers:pypi/<17.0.12|>=18.0.0|<18.2.2|>=19.0.0|<19.0.2",
),
( # OSSA-2020-006
"<19.3.1, >=20.0.0 <20.3.1, ==21.0.0",
"vers:pypi/<19.3.1|>=20.0.0|<20.3.1|21.0.0",
),
( # OSSA-2026-001
">=10.5.0 <10.7.2, >=10.8.0 <10.9.1, >=10.10.0 <10.12.1",
"vers:pypi/>=10.5.0|<10.7.2|>=10.8.0|<10.9.1|>=10.10.0|<10.12.1",
),
( # OSSA-2021-001
"<16.3.3, >=17.0.0 <17.1.3, =18.0.0",
"vers:pypi/<16.3.3|>=17.0.0|<17.1.3|18.0.0",
),
( # empty string should raise InvalidVersionRange
"",
"InvalidVersionRange",
),
],
)
def test_PypiVersionRange_from_ossa_native(string, expected):
if expected == "InvalidVersionRange":
with pytest.raises(InvalidVersionRange):
PypiVersionRange.from_ossa_native(string)
else:
result = PypiVersionRange.from_ossa_native(string)
assert expected == str(result)


def test_invert():
vers_with_equal_operator = VersionRange.from_string("vers:gem/1.0")
assert str(vers_with_equal_operator.invert()) == "vers:gem/!=1.0"
Expand Down