diff --git a/conan/api/subapi/graph.py b/conan/api/subapi/graph.py index 0c886a95de7..41990cf3478 100644 --- a/conan/api/subapi/graph.py +++ b/conan/api/subapi/graph.py @@ -167,7 +167,7 @@ def load_graph(self, root_node, profile_host, profile_build, lockfile=None, remo assert profile_build is not None remotes = remotes or [] - builder = DepsGraphBuilder(app.proxy, app.loader, app.range_resolver, remotes, + builder = DepsGraphBuilder(app.proxy, app.loader, app.range_resolver, app.cache, remotes, update, check_update) deps_graph = builder.load_graph(root_node, profile_host, profile_build, lockfile) return deps_graph diff --git a/conans/client/conf/required_version.py b/conans/client/conf/required_version.py index ee4ae37fc30..86005747778 100644 --- a/conans/client/conf/required_version.py +++ b/conans/client/conf/required_version.py @@ -10,7 +10,7 @@ def validate_conan_version(required_range): version_range = VersionRange(required_range) for conditions in version_range.condition_sets: conditions.prerelease = True - if clientver not in version_range: + if not version_range.contains(clientver, resolve_prerelease=None): raise ConanException("Current Conan version ({}) does not satisfy " "the defined one ({}).".format(clientver, required_range)) diff --git a/conans/client/graph/graph_builder.py b/conans/client/graph/graph_builder.py index 079841ad35f..c2b1bf3074b 100644 --- a/conans/client/graph/graph_builder.py +++ b/conans/client/graph/graph_builder.py @@ -17,13 +17,15 @@ class DepsGraphBuilder(object): - def __init__(self, proxy, loader, resolver, remotes, update, check_update): + def __init__(self, proxy, loader, resolver, cache, remotes, update, check_update): self._proxy = proxy self._loader = loader self._resolver = resolver + self._cache = cache self._remotes = remotes # TODO: pass as arg to load_graph() self._update = update self._check_update = check_update + self._resolve_prereleases = self._cache.new_config.get('core.version_ranges:resolve_prereleases') def load_graph(self, root_node, profile_host, profile_build, graph_lock=None): assert profile_host is not None @@ -82,7 +84,7 @@ def _expand_require(self, require, node, graph, profile_host, profile_build, gra require.ref = prev_ref else: self._conflicting_version(require, node, prev_require, prev_node, - prev_ref, base_previous) + prev_ref, base_previous, self._resolve_prereleases) if prev_node is None: # new node, must be added and expanded (node -> new_node) @@ -99,7 +101,7 @@ def _expand_require(self, require, node, graph, profile_host, profile_build, gra @staticmethod def _conflicting_version(require, node, - prev_require, prev_node, prev_ref, base_previous): + prev_require, prev_node, prev_ref, base_previous, resolve_prereleases): version_range = require.version_range prev_version_range = prev_require.version_range if prev_node is None else None if version_range: @@ -107,14 +109,14 @@ def _conflicting_version(require, node, if prev_version_range is not None: pass # Do nothing, evaluate current as it were a fixed one else: - if prev_ref.version in version_range: + if version_range.contains(prev_ref.version, resolve_prereleases): require.ref = prev_ref else: raise GraphError.conflict(node, require, prev_node, prev_require, base_previous) elif prev_version_range is not None: - # TODO: CHeck user/channel conflicts first - if require.ref.version not in prev_version_range: + # TODO: Check user/channel conflicts first + if not prev_version_range.contains(require.ref.version, resolve_prereleases): raise GraphError.conflict(node, require, prev_node, prev_require, base_previous) else: def _conflicting_refs(ref1, ref2): @@ -162,7 +164,7 @@ def _initialize_requires(self, node, graph, graph_lock): # This is the first pass over one recipe requires if graph_lock is not None: for require in node.conanfile.requires.values(): - graph_lock.resolve_locked(node, require) + graph_lock.resolve_locked(node, require, self._resolve_prereleases) for require in node.conanfile.requires.values(): self._resolve_alias(node, require, graph) @@ -215,7 +217,7 @@ def _resolve_recipe(self, ref, graph_lock): return new_ref, dep_conanfile, recipe_status, remote @staticmethod - def _resolved_system_tool(node, require, profile_build, profile_host): + def _resolved_system_tool(node, require, profile_build, profile_host, resolve_prereleases): if node.context == CONTEXT_HOST and not require.build: # Only for tool_requires return system_tool = profile_build.system_tools if node.context == CONTEXT_BUILD \ @@ -225,13 +227,14 @@ def _resolved_system_tool(node, require, profile_build, profile_host): for d in system_tool: if require.ref.name == d.name: if version_range: - if d.version in version_range: + if version_range.contains(d.version, resolve_prereleases): return d, ConanFile(str(d)), RECIPE_SYSTEM_TOOL, None elif require.ref.version == d.version: return d, ConanFile(str(d)), RECIPE_SYSTEM_TOOL, None def _create_new_node(self, node, require, graph, profile_host, profile_build, graph_lock): - resolved = self._resolved_system_tool(node, require, profile_build, profile_host) + resolved = self._resolved_system_tool(node, require, profile_build, profile_host, + self._resolve_prereleases) if resolved is None: try: diff --git a/conans/client/graph/range_resolver.py b/conans/client/graph/range_resolver.py index 481a83ed5f3..7f7b8858599 100644 --- a/conans/client/graph/range_resolver.py +++ b/conans/client/graph/range_resolver.py @@ -12,6 +12,7 @@ def __init__(self, conan_app): self._cached_cache = {} # Cache caching of search result, so invariant wrt installations self._cached_remote_found = {} # dict {ref (pkg/*): {remote_name: results (pkg/1, pkg/2)}} self.resolved_ranges = {} + self._resolve_prereleases = self._cache.new_config.get('core.version_ranges:resolve_prereleases') def resolve(self, require, base_conanref, remotes, update): version_range = require.version_range @@ -55,7 +56,7 @@ def _resolve_local(self, search_ref, version_range): and ref.channel == search_ref.channel] self._cached_cache[pattern] = local_found if local_found: - return self._resolve_version(version_range, local_found) + return self._resolve_version(version_range, local_found, self._resolve_prereleases) def _search_remote_recipes(self, remote, search_ref): pattern = str(search_ref) @@ -73,18 +74,20 @@ def _resolve_remote(self, search_ref, version_range, remotes, update): update_candidates = [] for remote in remotes: remote_results = self._search_remote_recipes(remote, search_ref) - resolved_version = self._resolve_version(version_range, remote_results) + resolved_version = self._resolve_version(version_range, remote_results, + self._resolve_prereleases) if resolved_version: if not update: - return resolved_version # Return first valid occurence in first remote + return resolved_version # Return first valid occurrence in first remote else: update_candidates.append(resolved_version) if len(update_candidates) > 0: # pick latest from already resolved candidates - resolved_version = self._resolve_version(version_range, update_candidates) + resolved_version = self._resolve_version(version_range, update_candidates, + self._resolve_prereleases) return resolved_version @staticmethod - def _resolve_version(version_range, refs_found): + def _resolve_version(version_range, refs_found, resolve_prereleases): for ref in reversed(sorted(refs_found)): - if ref.version in version_range: + if version_range.contains(ref.version, resolve_prereleases): return ref diff --git a/conans/model/conf.py b/conans/model/conf.py index 1bf9483750b..1c8b8089d5c 100644 --- a/conans/model/conf.py +++ b/conans/model/conf.py @@ -13,6 +13,7 @@ "core:default_profile": "Defines the default host profile ('default' by default)", "core:default_build_profile": "Defines the default build profile (None by default)", "core:allow_uppercase_pkg_names": "Temporarily (will be removed in 2.X) allow uppercase names", + "core.version_ranges:resolve_prereleases": "Whether version ranges can resolve to pre-releases or not", "core.upload:retry": "Number of retries in case of failure when uploading to Conan server", "core.upload:retry_wait": "Seconds to wait between upload attempts to Conan server", "core.download:parallel": "Number of concurrent threads to download packages", diff --git a/conans/model/graph_lock.py b/conans/model/graph_lock.py index 7ad1f6f34fc..526ebd1d0cf 100644 --- a/conans/model/graph_lock.py +++ b/conans/model/graph_lock.py @@ -201,12 +201,12 @@ def serialize(self): result["alias"] = {repr(k): repr(v) for k, v in self._alias.items()} return result - def resolve_locked(self, node, require): + def resolve_locked(self, node, require, resolve_prereleases): if require.build or node.context == CONTEXT_BUILD: locked_refs = self._build_requires.refs() else: locked_refs = self._requires.refs() - self._resolve(require, locked_refs) + self._resolve(require, locked_refs, resolve_prereleases) def resolve_prev(self, node): if node.context == CONTEXT_BUILD: @@ -216,14 +216,14 @@ def resolve_prev(self, node): if prevs: return prevs.get(node.package_id) - def _resolve(self, require, locked_refs): + def _resolve(self, require, locked_refs, resolve_prereleases): version_range = require.version_range ref = require.ref matches = [r for r in locked_refs if r.name == ref.name and r.user == ref.user and r.channel == ref.channel] if version_range: for m in matches: - if m.version in version_range: + if version_range.contains(m.version, resolve_prereleases): require.ref = m break else: @@ -250,6 +250,6 @@ def _resolve(self, require, locked_refs): if ref not in matches and not self.partial: raise ConanException(f"Requirement '{repr(ref)}' not in lockfile") - def resolve_locked_pyrequires(self, require): + def resolve_locked_pyrequires(self, require, resolve_prereleases=None): locked_refs = self._python_requires.refs() # CHANGE - self._resolve(require, locked_refs) + self._resolve(require, locked_refs, resolve_prereleases) diff --git a/conans/model/version_range.py b/conans/model/version_range.py index 1bdadbe77ad..62e98947adc 100644 --- a/conans/model/version_range.py +++ b/conans/model/version_range.py @@ -1,4 +1,5 @@ from collections import namedtuple +from typing import Optional from conans.errors import ConanException from conans.model.recipe_ref import Version @@ -56,9 +57,14 @@ def first_non_zero(main): else: return [_Condition(operator, Version(version))] - def valid(self, version): + def _valid(self, version, conf_resolve_prepreleases): if version.pre: - if not self.prerelease: + # Follow the expression desires only if core.version_ranges:resolve_prereleases is None, + # else force to the conf's value + if conf_resolve_prepreleases is None: + if not self.prerelease: + return False + elif conf_resolve_prepreleases is False: return False for condition in self.conditions: if condition.operator == ">": @@ -83,7 +89,7 @@ class VersionRange: def __init__(self, expression): self._expression = expression tokens = expression.split(",") - prereleases = None + prereleases = False for t in tokens[1:]: if "include_prerelease" in t: prereleases = True @@ -96,9 +102,19 @@ def __init__(self, expression): def __str__(self): return self._expression - def __contains__(self, version): + def contains(self, version: Version, resolve_prerelease: Optional[bool]): + """ + Whether is inside the version range + + :param version: Version to check against + :param resolve_prerelease: If ``True``, ensure prereleases can be resolved in this range + If ``False``, prerelases can NOT be resolved in this range + If ``None``, prereleases are resolved only if this version range expression says so + :return: Whether the version is inside the range + """ assert isinstance(version, Version), type(version) for condition_set in self.condition_sets: - if condition_set.valid(version): + if condition_set._valid(version, resolve_prerelease): return True return False + diff --git a/conans/test/integration/graph/version_ranges/version_range_conf.py b/conans/test/integration/graph/version_ranges/version_range_conf.py new file mode 100644 index 00000000000..0e111e4d792 --- /dev/null +++ b/conans/test/integration/graph/version_ranges/version_range_conf.py @@ -0,0 +1,76 @@ +from conans.test.assets.genconanfile import GenConanfile +from utils.tools import TestClient +import textwrap + + +def test_version_range_conf_nonexplicit_expression(): + tc = TestClient() + + base = textwrap.dedent(""" + from conan import ConanFile + + class Base(ConanFile): + name = "base" + """) + + tc.save({"base/conanfile.py": base}) + tc.run("create base/conanfile.py --version=1.5.1") + tc.run("create base/conanfile.py --version=2.5.0-pre") + + tc.save({"v1/conanfile.py": GenConanfile("pkg", "1.0").with_requires("base/[>1 <2]"), + "v2/conanfile.py": GenConanfile("pkg", "2.0").with_requires("base/[>2 <3]")}) + + tc.save({"global.conf": "core.version_ranges:resolve_prereleases=False"}, path=tc.cache.cache_folder) + tc.run("create v1/conanfile.py") + assert "base/[>1 <2]: base/1.5.1" in tc.out + tc.run("create v2/conanfile.py", assert_error=True) + assert "Package 'base/[>2 <3]' not resolved" in tc.out + + tc.save({"global.conf": "core.version_ranges:resolve_prereleases=True"}, path=tc.cache.cache_folder) + tc.run("create v1/conanfile.py") + assert "base/[>1 <2]: base/1.5.1" in tc.out + tc.run("create v2/conanfile.py") + assert "base/[>2 <3]: base/2.5.0-pre" in tc.out + + tc.save({"global.conf": "core.version_ranges:resolve_prereleases=None"}, path=tc.cache.cache_folder) + tc.run("create v1/conanfile.py") + assert "base/[>1 <2]: base/1.5.1" in tc.out + + tc.run("create v2/conanfile.py", assert_error=True) + assert "Package 'base/[>2 <3]' not resolved" in tc.out + + +def test_version_range_conf_explicit_expression(): + tc = TestClient() + + base = textwrap.dedent(""" + from conan import ConanFile + + class Base(ConanFile): + name = "base" + """) + + tc.save({"base/conanfile.py": base}) + tc.run("create base/conanfile.py --version=1.5.1") + tc.run("create base/conanfile.py --version=2.5.0-pre") + + tc.save({"v1/conanfile.py": GenConanfile("pkg", "1.0").with_requires("base/[>1- <2]"), + "v2/conanfile.py": GenConanfile("pkg", "2.0").with_requires("base/[>2- <3]")}) + + tc.save({"global.conf": "core.version_ranges:resolve_prereleases=False"}, path=tc.cache.cache_folder) + tc.run("create v1/conanfile.py") + assert "base/[>1- <2]: base/1.5.1" in tc.out + tc.run("create v2/conanfile.py", assert_error=True) + assert "Package 'base/[>2- <3]' not resolved" in tc.out + + tc.save({"global.conf": "core.version_ranges:resolve_prereleases=True"}, path=tc.cache.cache_folder) + tc.run("create v1/conanfile.py") + assert "base/[>1- <2]: base/1.5.1" in tc.out + tc.run("create v2/conanfile.py") + assert "base/[>2- <3]: base/2.5.0-pre" in tc.out + + tc.save({"global.conf": "core.version_ranges:resolve_prereleases=None"}, path=tc.cache.cache_folder) + tc.run("create v1/conanfile.py") + assert "base/[>1- <2]: base/1.5.1" in tc.out + tc.run("create v2/conanfile.py") + assert "base/[>2- <3]: base/2.5.0-pre" in tc.out diff --git a/conans/test/unittests/model/version/test_version_range.py b/conans/test/unittests/model/version/test_version_range.py index 1392d783dcd..5d1fef7146f 100644 --- a/conans/test/unittests/model/version/test_version_range.py +++ b/conans/test/unittests/model/version/test_version_range.py @@ -47,10 +47,45 @@ def test_range(version_range, conditions, versions_in, versions_out): assert condition.version == expected_condition[1] for v in versions_in: - assert Version(v) in r + assert r.contains(Version(v), None) for v in versions_out: - assert Version(v) not in r + assert not r.contains(Version(v), None) + + +@pytest.mark.parametrize("version_range, resolve_prereleases, versions_in, versions_out", [ + ['*', True, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []], + ['*', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + ['*', None, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + + ['*-', True, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []], + ['*-', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + ['*-', None, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []], + + ['*, include_prerelease', True, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []], + ['*, include_prerelease', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + ['*, include_prerelease', None, ["1.5.1", "1.5.1-pre1", "2.1-pre1"], []], + + ['>1 <2.0', True, ["1.5.1", "1.5.1-pre1"], ["2.1-pre1"]], + ['>1 <2.0', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + ['>1 <2.0', None, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + + ['>1- <2.0', True, ["1.5.1", "1.5.1-pre1"], ["2.1-pre1"]], + ['>1- <2.0', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + ['>1- <2.0', None, ["1.5.1", "1.5.1-pre1"], ["2.1-pre1"]], + + ['>1 <2.0, include_prerelease', True, ["1.5.1", "1.5.1-pre1"], ["2.1-pre1"]], + ['>1 <2.0, include_prerelease', False, ["1.5.1"], ["1.5.1-pre1", "2.1-pre1"]], + ['>1 <2.0, include_prerelease', None, ["1.5.1", "1.5.1-pre1"], ["2.1-pre1"]], +]) +def test_range_prereleases_conf(version_range, resolve_prereleases, versions_in, versions_out): + r = VersionRange(version_range) + + for v in versions_in: + assert r.contains(Version(v), resolve_prereleases), f"Expected '{version_range}' to contain '{v}' (conf.ranges_resolve_prereleases={resolve_prereleases})" + + for v in versions_out: + assert not r.contains(Version(v), resolve_prereleases), f"Expected '{version_range}' NOT to contain '{v}' (conf.ranges_resolve_prereleases={resolve_prereleases})" def test_wrong_range_syntax():