diff --git a/conans/client/graph/range_resolver.py b/conans/client/graph/range_resolver.py index 45c488f2b8c..618ba7752da 100644 --- a/conans/client/graph/range_resolver.py +++ b/conans/client/graph/range_resolver.py @@ -1,23 +1,84 @@ -from conans.model.ref import ConanFileReference +import re + from conans.errors import ConanException +from conans.model.ref import ConanFileReference from conans.search.search import search_recipes +re_param = re.compile(r"^(?Pinclude_prerelease|loose)\s*=\s*(?PTrue|False)$") +re_version = re.compile(r"^((?!(include_prerelease|loose))[a-zA-Z0-9_+.\-~<>=|*^\(\)\s])*$") + + +def _parse_versionexpr(versionexpr, output): + expression = [it.strip() for it in versionexpr.split(",")] + if len(expression) > 4: + raise ConanException("Invalid expression for version_range '{}'".format(versionexpr)) + + include_prerelease = False + loose = True + version_range = [] + + for i, expr in enumerate(expression): + match_param = re_param.match(expr) + match_version = re_version.match(expr) + + if match_param == match_version: + raise ConanException("Invalid version range '{}', failed in " + "chunk '{}'".format(versionexpr, expr)) + + if match_version and i not in [0, 1]: + raise ConanException("Invalid version range '{}'".format(versionexpr)) + + if match_param and i not in [1, 2, 3]: + raise ConanException("Invalid version range '{}'".format(versionexpr)) + + if match_version: + version_range.append(expr) + + if match_param: + if match_param.group('function') == 'loose': + loose = match_param.group('value') == "True" + elif match_param.group('function') == 'include_prerelease': + include_prerelease = match_param.group('value') == "True" + else: + raise ConanException("Unexpected version range " + "parameter '{}'".format(match_param.group(1))) + + if len(version_range) > 1: + output.warn("Commas as separator in version '%s' range will are deprecated and will be removed in Conan 2.0" % + str(versionexpr)) + + version_range = " ".join(map(str, version_range)) + return version_range, loose, include_prerelease + + def satisfying(list_versions, versionexpr, output): """ returns the maximum version that satisfies the expression if some version cannot be converted to loose SemVer, it is discarded with a msg - This provides some woraround for failing comparisons like "2.1" not matching "<=2.1" + This provides some workaround for failing comparisons like "2.1" not matching "<=2.1" """ - from semver import SemVer, max_satisfying - version_range = versionexpr.replace(",", " ") + from semver import SemVer, Range, max_satisfying + + version_range, loose, include_prerelease = _parse_versionexpr(versionexpr, output) + + # Check version range expression + try: + act_range = Range(version_range, loose) + except ValueError: + raise ConanException("version range expression '%s' is not valid" % version_range) + + # Validate all versions candidates = {} for v in list_versions: try: - ver = SemVer(v, loose=True) + ver = SemVer(v, loose=loose) candidates[ver] = v except (ValueError, AttributeError): output.warn("Version '%s' is not semver, cannot be compared with a range" % str(v)) - result = max_satisfying(candidates, version_range, loose=True) + + # Search best matching version in range + result = max_satisfying(candidates, act_range, loose=loose, + include_prerelease=include_prerelease) return candidates.get(result) diff --git a/conans/requirements.txt b/conans/requirements.txt index 3d654c0b40e..0b91880f6c0 100644 --- a/conans/requirements.txt +++ b/conans/requirements.txt @@ -5,7 +5,7 @@ PyYAML>=3.11, <3.14.0 patch==1.16 fasteners>=0.14.1 six>=1.10.0 -node-semver==0.2.0 +node-semver==0.6.1 distro>=1.0.2, <1.2.0 pylint>=1.9.3 future==0.16.0 diff --git a/conans/test/model/version_ranges_test.py b/conans/test/model/version_ranges_test.py index 62f7a408eee..8a6267616a9 100644 --- a/conans/test/model/version_ranges_test.py +++ b/conans/test/model/version_ranges_test.py @@ -33,6 +33,24 @@ def prereleases_versions_test(self): result = satisfying(["4.2.2", "4.2.3-pre", "4.2.3"], "~4.2.3-", output) self.assertEqual(result, "4.2.3") + def loose_versions_test(self): + output = TestBufferConanOutput() + result = satisfying(["4.2.2", "4.2.3-pre"], "~4.2.1,loose=False", output) + self.assertEqual(result, "4.2.2") + result = satisfying(["1.1.1", "1.1.2", "1.2", "1.2.1", "1.3", "2.1"], "1.8||1.3,loose=False", output) + self.assertEqual(result, None) + result = satisfying(["1.1.1", "1.1.2", "1.2", "1.2.1", "1.3", "2.1"], "1.8||1.3, loose = False ", output) + self.assertEqual(result, None) + result = satisfying(["1.1.1", "1.1.2", "1.2", "1.2.1", "1.3", "2.1"], "1.8||1.3", output) + self.assertEqual(result, "1.3") + + def include_prerelease_versions_test(self): + output = TestBufferConanOutput() + result = satisfying(["4.2.2", "4.2.3-pre"], "~4.2.1,include_prerelease = True", output) + self.assertEqual(result, "4.2.3-pre") + result = satisfying(["4.2.2", "4.2.3-pre"], "~4.2.1", output) + self.assertEqual(result, "4.2.2") + def basic_test(self): output = TestBufferConanOutput() result = satisfying(["1.1", "1.2", "1.3", "2.1"], "", output) @@ -86,6 +104,14 @@ def basic_test(self): result = satisfying(["2.1.1"], ">2.1.0", output) self.assertEqual(result, "2.1.1") + # Invalid ranges + with self.assertRaises(ConanException): + satisfying(["2.1.1"], "2.3 3.2; include_prerelease=True, loose=False", output) + with self.assertRaises(ConanException): + satisfying(["2.1.1"], "2.3 3.2, include_prerelease=Ture, loose=False", output) + with self.assertRaises(ConanException): + satisfying(["2.1.1"], "~2.3, abc, loose=False", output) + hello_content = """ from conans import ConanFile diff --git a/conans/test/unittests/__init__.py b/conans/test/unittests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/conans/test/unittests/client/__init__.py b/conans/test/unittests/client/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/conans/test/unittests/client/graph/__init__.py b/conans/test/unittests/client/graph/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/conans/test/unittests/client/graph/test_range_resolver.py b/conans/test/unittests/client/graph/test_range_resolver.py new file mode 100644 index 00000000000..af79ad4c5ca --- /dev/null +++ b/conans/test/unittests/client/graph/test_range_resolver.py @@ -0,0 +1,65 @@ +import unittest + +from conans.client.graph.range_resolver import _parse_versionexpr +from conans.errors import ConanException +from conans.test.utils.tools import TestBufferConanOutput + + +class ParseVersionExprTest(unittest.TestCase): + def test_backwards_compatibility(self): + output = TestBufferConanOutput() + self.assertEqual(_parse_versionexpr("2.3, 3.2", output), ("2.3 3.2", True, False)) + self.assertTrue(str(output).startswith("WARN: Commas as separator")) + output = TestBufferConanOutput() + self.assertEqual(_parse_versionexpr("2.3, <=3.2", output), ("2.3 <=3.2", True, False)) + self.assertTrue(str(output).startswith("WARN: Commas as separator")) + + def test_only_spaces_without_warning(self): + output = TestBufferConanOutput() + self.assertEqual(_parse_versionexpr("2.3 3.2", output), ("2.3 3.2", True, False)) + self.assertEqual(str(output), "") + + def test_standard_semver(self): + output = TestBufferConanOutput() + self.assertEqual(_parse_versionexpr("*", output), ("*", True, False)) + self.assertEqual(_parse_versionexpr("", output), ("", True, False)) # Defaults to '*' + self.assertEqual(_parse_versionexpr("~1", output), ("~1", True, False)) + self.assertEqual(_parse_versionexpr("~1.2.3-beta.2", output), ("~1.2.3-beta.2", True, False)) + self.assertEqual(_parse_versionexpr("^0.0", output), ("^0.0", True, False)) + self.assertEqual(_parse_versionexpr("1.2.3 - 2.3.4", output), ("1.2.3 - 2.3.4", True, False)) + self.assertEqual(_parse_versionexpr(">=1.2.3 <1.(2+1).0", output), + (">=1.2.3 <1.(2+1).0", True, False)) + + def test_only_loose(self): + output = TestBufferConanOutput() + self.assertEqual(_parse_versionexpr("2.3 ,3.2, loose=True", output), ("2.3 3.2", True, False)) + self.assertEqual(_parse_versionexpr("2.3 3.2, loose=False", output), ("2.3 3.2", False, False)) + self.assertEqual(_parse_versionexpr("2.3 3.2, loose = False", output), ("2.3 3.2", False, False)) + self.assertEqual(_parse_versionexpr("2.3 3.2, loose = True", output), ("2.3 3.2", True, False)) + + def test_only_prerelease(self): + output = TestBufferConanOutput() + self.assertEqual(_parse_versionexpr("2.3, 3.2, include_prerelease=False", output), + ("2.3 3.2", True, False)) + self.assertEqual(_parse_versionexpr("2.3, 3.2, include_prerelease=True", output), + ("2.3 3.2", True, True)) + + def test_both(self): + output = TestBufferConanOutput() + self.assertEqual(_parse_versionexpr("2.3, 3.2, loose=False, include_prerelease=True", output), + ("2.3 3.2", False, True)) + self.assertEqual(_parse_versionexpr("2.3, 3.2, include_prerelease=True, loose=False", output), + ("2.3 3.2", False, True)) + + def test_invalid(self): + output = TestBufferConanOutput() + self.assertRaises(ConanException, _parse_versionexpr, "loose=False, include_prerelease=True", output) + self.assertRaises(ConanException, _parse_versionexpr, "2.3, 3.2, unexpected=True", output) + self.assertRaises(ConanException, _parse_versionexpr, "2.3, 3.2, loose=Other", output) + self.assertRaises(ConanException, _parse_versionexpr, "2.3, 3.2, ", output) + self.assertRaises(ConanException, _parse_versionexpr, "2.3, 3.2, 1.2.3", output) + self.assertRaises(ConanException, _parse_versionexpr, + "2.3 3.2; loose=True, include_prerelease=True", output) + self.assertRaises(ConanException, _parse_versionexpr, "loose=True, 2.3 3.3", output) + self.assertRaises(ConanException, _parse_versionexpr, + "2.3, 3.2, 1.4, loose=False, include_prerelease=True", output)