diff --git a/src/univers/version_range.py b/src/univers/version_range.py index 8db5253e..996bd4c7 100644 --- a/src/univers/version_range.py +++ b/src/univers/version_range.py @@ -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 @@ -772,6 +773,71 @@ 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' + """ + + # 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 ", ",") + + # Remove spaces around operators + # "<=5.0.3, >=6.0.0 <=6.1.0, ==7.0.0" -> "<=5.0.3,>=6.0.0<=6.1.0,==7.0.0" + string = re.sub(r"\s+([<>=!]+)", r"\1", string) + string = re.sub(r"([<>=!]+)\s+", r"\1", string) + + # Insert comma between consecutive constraints + # "<=5.0.3,>=6.0.0<=6.1.0,==7.0.0" -> "<=5.0.3,>=6.0.0,<=6.1.0,==7.0.0" + string = re.sub(r"(\d)([<>=!])", r"\1,\2", string) + + constraints = [] + for part in string.split(","): + + # 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) + class MavenVersionRange(VersionRange): """ diff --git a/tests/test_version_range.py b/tests/test_version_range.py index ed3f8a28..a38a0935 100644 --- a/tests/test_version_range.py +++ b/tests/test_version_range.py @@ -255,6 +255,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"