Repository: conan-io/conan
Pull request #17405 (2024-12-03 11:28:18)

Warn on empty version ranges
Changelog: Fix: Add warning for empty version ranges
Docs: Omit

For https://github.com/conan-io/conan/issues/17403



Patch:
diff --git a/conan/__init__.py b/conan/__init__.py
index b824fa904..1b584d3ab 100644
--- a/conan/__init__.py
+++ b/conan/__init__.py
@@ -1,5 +1,5 @@
 from conans.model.conan_file import ConanFile
 from conan.tools.scm import Version as _Version
 
-__version__ = '2.10.0-dev'
+__version__ = '2.11.0-dev'
 conan_version = _Version(__version__)
diff --git a/conan/cli/printers/graph.py b/conan/cli/printers/graph.py
index 931848037..2c4f3493c 100644
--- a/conan/cli/printers/graph.py
+++ b/conan/cli/printers/graph.py
@@ -68,6 +68,10 @@ def print_graph_basic(graph):
                        "it might be removed in 3.0.")
         output.warning("Consider using version-ranges instead.")
     _format_resolved("Resolved version ranges", graph.resolved_ranges)
+    for req in graph.resolved_ranges:
+        if str(req.version) == "[]":
+            output.warning("Empty version range usage is discouraged. Use [*] instead", warn_tag="deprecated")
+            break
 
     overrides = graph.overrides()
     if overrides:
diff --git a/conans/client/rest_client_local_recipe_index.py b/conans/client/rest_client_local_recipe_index.py
index 8615781bd..876b00c21 100644
--- a/conans/client/rest_client_local_recipe_index.py
+++ b/conans/client/rest_client_local_recipe_index.py
@@ -200,6 +200,16 @@ class _LocalRecipesIndexLayout:
         ret = []
         excluded = set()
 
+        original_pattern = pattern
+        try:
+            pattern_ref = RecipeReference.loads(pattern)
+            # We don't care about user/channel for now, we'll check for those below,
+            # just add all candidates for now
+            pattern = f"{pattern_ref.name}/{pattern_ref.version}"
+        except:
+            # pattern = pattern
+            pass
+
         loader = ConanFileLoader(None)
         for r in recipes:
             if r.startswith("."):
@@ -232,7 +242,9 @@ class _LocalRecipesIndexLayout:
                     ref.channel = recipe.channel
                 except Exception as e:
                     ConanOutput().warning(f"Couldn't load recipe {conanfile}: {e}")
-                ret.append(ref)
+                if ref.matches(original_pattern, None):
+                    # Check again the pattern with the user/channel
+                    ret.append(ref)
         if excluded:
             ConanOutput().warning(f"Excluding recipes not Conan 2.0 ready: {', '.join(excluded)}")
         return ret
diff --git a/conans/model/build_info.py b/conans/model/build_info.py
index dc8e79a6c..dc180a96d 100644
--- a/conans/model/build_info.py
+++ b/conans/model/build_info.py
@@ -510,8 +510,7 @@ class _Component:
                         # then os.path.realpath("libmylib.1.0.0.dylib") == "libmylib.dylib"
                         # Note: os.readlink() returns the path which the symbolic link points to.
                         real_path = os.path.realpath(full_path)
-                        if pattern.match(os.path.basename(real_path)):
-                            ret.add(real_path)
+                        ret.add(real_path)
                     else:
                         ret.add(full_path)
             return list(ret)
diff --git a/conans/model/dependencies.py b/conans/model/dependencies.py
index 82e9a0e95..443eeee54 100644
--- a/conans/model/dependencies.py
+++ b/conans/model/dependencies.py
@@ -92,15 +92,19 @@ class ConanFileDependencies(UserRequirementsDict):
         d = OrderedDict((require, ConanFileInterface(transitive.node.conanfile))
                         for require, transitive in node.transitive_deps.items())
         if node.replaced_requires:
+            cant_be_removed = set()
             for old_req, new_req in node.replaced_requires.items():
                 # Two different replaced_requires can point to the same real requirement
                 existing = d[new_req]
                 added_req = new_req.copy_requirement()
                 added_req.ref = RecipeReference.loads(old_req)
                 d[added_req] = existing
+                if new_req.ref.name == added_req.ref.name:
+                    cant_be_removed.add(new_req)
             # Now remove the replaced from dependencies dict
             for new_req in node.replaced_requires.values():
-                d.pop(new_req, None)
+                if new_req not in cant_be_removed:
+                    d.pop(new_req, None)
         return ConanFileDependencies(d)
 
     def filter(self, require_filter, remove_system=True):


Test patch:
diff --git a/.github/workflows/linux-tests.yml b/.github/workflows/linux-tests.yml
index fb229df97..6fa3f8606 100644
--- a/.github/workflows/linux-tests.yml
+++ b/.github/workflows/linux-tests.yml
@@ -8,6 +8,7 @@ on:
   pull_request:
     branches:
       - '*'
+      - 'release/*'
   workflow_dispatch:
 
 concurrency:
@@ -69,7 +70,7 @@ jobs:
       matrix:
         python-version: ['3.12.3', '3.9.2', '3.8.6', '3.6.15']
         test-type: ['unittests', 'integration', 'functional']
-    name: Conan ${{ matrix.test-type }} (${{ matrix.python-version }}) 
+    name: Conan ${{ matrix.test-type }} (${{ matrix.python-version }})
     steps:
       - name: Checkout code
         uses: actions/checkout@v4
diff --git a/.github/workflows/osx-tests.yml b/.github/workflows/osx-tests.yml
index f54c904f9..550bad222 100644
--- a/.github/workflows/osx-tests.yml
+++ b/.github/workflows/osx-tests.yml
@@ -8,6 +8,7 @@ on:
   pull_request:
     branches:
       - '*'
+      - 'release/*'
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
diff --git a/.github/workflows/win-tests.yml b/.github/workflows/win-tests.yml
index 6ddbf7678..044af919d 100644
--- a/.github/workflows/win-tests.yml
+++ b/.github/workflows/win-tests.yml
@@ -8,6 +8,7 @@ on:
   pull_request:
     branches:
       - '*'
+      - 'release/*'
   workflow_dispatch:
 
 concurrency:
diff --git a/test/integration/graph/core/test_version_ranges.py b/test/integration/graph/core/test_version_ranges.py
index 17c0fef89..98a10595c 100644
--- a/test/integration/graph/core/test_version_ranges.py
+++ b/test/integration/graph/core/test_version_ranges.py
@@ -437,3 +437,13 @@ def test_bad_options_syntax(version_range, should_warn):
         assert "its presence unconditionally enables prereleases" in tc.out
     else:
         assert "its presence unconditionally enables prereleases" not in tc.out
+
+
+def test_empty_version_ranger():
+    tc = TestClient(light=True)
+    tc.save({"lib/conanfile.py": GenConanfile("lib", "1.0"),
+             "app/conanfile.py": GenConanfile("app", "1.0").with_requires("lib/[]")})
+    tc.run("export lib")
+    tc.run("graph info app")
+    assert "lib/[]: lib/1.0" in tc.out
+    assert "Empty version range usage is discouraged" in tc.out
diff --git a/test/integration/graph/test_replace_requires.py b/test/integration/graph/test_replace_requires.py
index 7f9d4bdd4..affa15e94 100644
--- a/test/integration/graph/test_replace_requires.py
+++ b/test/integration/graph/test_replace_requires.py
@@ -36,6 +36,8 @@ from conan.test.utils.tools import TestClient
     ("dep/[>=1.0 <2]", "dep/2.1", "dep/2.1@system", "dep/1.1"),
     # PATTERN - PATTERN REPLACE
     ("dep/[>=1.3 <2]", "dep/*", "dep/[>=1.0 <1.9]", "dep/1.1"),
+    # DIRECT REPLACE OF PINNED VERSIONS
+    ("dep/1.3", "dep/1.3", "dep/1.5", "dep/1.5"),
 ])
 @pytest.mark.parametrize("tool_require", [False, True])
 class TestReplaceRequires:
@@ -160,14 +162,16 @@ def test_replace_requires_test_requires():
     assert "gtest/0.1: gtest/0.2" in c.out  # The replacement happens
 
 
-def test_replace_requires_consumer_references():
+# We test even replacing by itself, not great, but shouldn't crash
+@pytest.mark.parametrize("name, version", [("zlib", "0.1"), ("zlib", "0.2"), ("zlib-ng", "0.1")])
+def test_replace_requires_consumer_references(name, version):
     c = TestClient()
     # IMPORTANT: The replacement package must be target-compatible
-    zlib_ng = textwrap.dedent("""
+    dep = textwrap.dedent(f"""
         from conan import ConanFile
         class ZlibNG(ConanFile):
-            name = "zlib-ng"
-            version = "0.1"
+            name = "{name}"
+            version = "{version}"
             def package_info(self):
                 self.cpp_info.set_property("cmake_file_name", "ZLIB")
                 self.cpp_info.set_property("cmake_target_name", "ZLIB::ZLIB")
@@ -189,14 +193,14 @@ def test_replace_requires_consumer_references():
                 self.output.info(f"DEP ZLIB package_info: {self.dependencies['zlib'].ref.name}!")
                 self.cpp_info.requires = ["zlib::zlib"]
         """)
-    c.save({"zlibng/conanfile.py": zlib_ng,
+    c.save({"dep/conanfile.py": dep,
             "app/conanfile.py": conanfile,
-            "profile": "[replace_requires]\nzlib/0.1: zlib-ng/0.1"})
-    c.run("create zlibng")
+            "profile": f"[replace_requires]\nzlib/0.1: {name}/{version}"})
+    c.run("create dep")
     c.run("build app -pr=profile")
-    assert "zlib/0.1: zlib-ng/0.1" in c.out
-    assert "conanfile.py (app/0.1): DEP ZLIB generate: zlib-ng!" in c.out
-    assert "conanfile.py (app/0.1): DEP ZLIB build: zlib-ng!" in c.out
+    assert f"zlib/0.1: {name}/{version}" in c.out
+    assert f"conanfile.py (app/0.1): DEP ZLIB generate: {name}!" in c.out
+    assert f"conanfile.py (app/0.1): DEP ZLIB build: {name}!" in c.out
     # Check generated CMake code. If the targets are NOT compatible, then the replacement
     # Cannot happen
     assert "find_package(ZLIB)" in c.out
@@ -204,9 +208,63 @@ def test_replace_requires_consumer_references():
     cmake = c.load("app/ZLIBTargets.cmake")
     assert "add_library(ZLIB::ZLIB INTERFACE IMPORTED)" in cmake
     c.run("create app -pr=profile")
-    assert "zlib/0.1: zlib-ng/0.1" in c.out
-    assert "app/0.1: DEP ZLIB generate: zlib-ng!" in c.out
-    assert "app/0.1: DEP ZLIB build: zlib-ng!" in c.out
+    assert f"zlib/0.1: {name}/{version}" in c.out
+    assert f"app/0.1: DEP ZLIB generate: {name}!" in c.out
+    assert f"app/0.1: DEP ZLIB build: {name}!" in c.out
+
+
+def test_replace_requires_consumer_references_error_multiple():
+    # https://github.com/conan-io/conan/issues/17407
+    c = TestClient()
+    # IMPORTANT: The replacement package must be target-compatible
+    zlib = textwrap.dedent("""
+        from conan import ConanFile
+        class ZlibNG(ConanFile):
+            name = "zlib"
+            version = "0.2"
+            def package_info(self):
+                self.cpp_info.set_property("cmake_file_name", "ZLIB")
+                self.cpp_info.set_property("cmake_target_name", "ZLIB::ZLIB")
+        """)
+    conanfile = textwrap.dedent("""
+        from conan import ConanFile
+        class App(ConanFile):
+            name = "app"
+            version = "0.1"
+            settings = "build_type"
+            requires = "zlib/0.1", "bzip2/0.1"
+            generators = "CMakeDeps"
+
+            def generate(self):
+                self.output.info(f"DEP ZLIB generate: {self.dependencies['zlib'].ref.name}!")
+                self.output.info(f"DEP BZIP2 generate: {self.dependencies['bzip2'].ref.name}!")
+            def build(self):
+                self.output.info(f"DEP ZLIB build: {self.dependencies['zlib'].ref.name}!")
+                self.output.info(f"DEP BZIP2 build: {self.dependencies['bzip2'].ref.name}!")
+            def package_info(self):
+                self.output.info(f"DEP ZLIB package_info: {self.dependencies['zlib'].ref.name}!")
+                self.cpp_info.requires = ["zlib::zlib", "bzip2::bzip2"]
+        """)
+    c.save({"zlib/conanfile.py": zlib,
+            "app/conanfile.py": conanfile,
+            "profile": "[replace_requires]\nzlib/0.1: zlib/0.2\nbzip2/0.1: zlib/0.2"})
+    c.run("create zlib")
+    c.run("build app -pr=profile")
+    assert "zlib/0.1: zlib/0.2" in c.out
+    assert "conanfile.py (app/0.1): DEP ZLIB generate: zlib!" in c.out
+    assert "conanfile.py (app/0.1): DEP ZLIB build: zlib!" in c.out
+    assert "conanfile.py (app/0.1): DEP BZIP2 generate: zlib!" in c.out
+    assert "conanfile.py (app/0.1): DEP BZIP2 build: zlib!" in c.out
+    # Check generated CMake code. If the targets are NOT compatible, then the replacement
+    # Cannot happen
+    assert "find_package(ZLIB)" in c.out
+    assert "target_link_libraries(... ZLIB::ZLIB ZLIB::ZLIB)" in c.out
+    cmake = c.load("app/ZLIBTargets.cmake")
+    assert "add_library(ZLIB::ZLIB INTERFACE IMPORTED)" in cmake
+    c.run("create app -pr=profile")
+    assert "zlib/0.1: zlib/0.2" in c.out
+    assert "app/0.1: DEP ZLIB generate: zlib!" in c.out
+    assert "app/0.1: DEP ZLIB build: zlib!" in c.out
 
 
 def test_replace_requires_consumer_components_options():
diff --git a/test/integration/remote/test_local_recipes_index.py b/test/integration/remote/test_local_recipes_index.py
index 31103c014..12123742c 100644
--- a/test/integration/remote/test_local_recipes_index.py
+++ b/test/integration/remote/test_local_recipes_index.py
@@ -360,3 +360,50 @@ class TestPythonRequires:
         c.run("install --requires=pkg/1.0 --build missing -vvv")
         assert "pyreq/1.0#a0d63ca853edefa33582a24a1bb3c75f - Downloaded (local)" in c.out
         assert "pkg/1.0: Created package" in c.out
+
+class TestUserChannel:
+    @pytest.fixture(scope="class")
+    def c3i_user_channel_folder(self):
+        folder = temp_folder()
+        recipes_folder = os.path.join(folder, "recipes")
+        config = textwrap.dedent("""
+                versions:
+                  "1.0":
+                    folder: all
+                  "2.0":
+                    folder: other
+                """)
+        pkg = str(GenConanfile("pkg").with_class_attribute("user='myuser'")\
+                                    .with_class_attribute("channel='mychannel'"))
+        save_files(recipes_folder,
+                   {"pkg/config.yml": config,
+                    "pkg/all/conanfile.py": pkg,
+                    "pkg/other/conanfile.py": str(GenConanfile("pkg"))})
+        return folder
+
+    def test_user_channel_requirement(self, c3i_user_channel_folder):
+        tc = TestClient(light=True)
+        tc.run(f"remote add local '{c3i_user_channel_folder}'")
+        tc.run("graph info --requires=pkg/[*]@myuser/mychannel")
+        assert "Version range '*' from requirement 'pkg/[*]@myuser/mychannel'" not in tc.out
+        assert "pkg/[*]@myuser/mychannel: pkg/1.0@myuser/mychannel" in tc.out
+        assert "pkg/1.0@myuser/mychannel#0b23a5938afb0457079e41aac8991595 - Downloaded (local)" in tc.out
+
+    @pytest.mark.parametrize("user_channel", ["@foo/bar", "@foo"])
+    def test_user_channel_requirement_no_match(self, c3i_user_channel_folder, user_channel):
+        tc = TestClient(light=True)
+        tc.run(f"remote add local '{c3i_user_channel_folder}'")
+        tc.run(f"graph info --requires=pkg/[*]{user_channel}", assert_error=True)
+        assert f"Version range '*' from requirement 'pkg/[*]{user_channel}' required by 'None' could not be resolved." in tc.out
+
+    def test_user_channel_requirement_only_at(self, c3i_user_channel_folder):
+        tc = TestClient(light=True)
+        tc.run(f"remote add local '{c3i_user_channel_folder}'")
+        tc.run(f"graph info --requires=pkg/[*]@")
+        assert f"pkg/[*]: pkg/2.0" in tc.out
+
+        tc.run(f"graph info --requires=pkg/[<2]@", assert_error=True)
+        assert f" Package 'pkg/[<2]' not resolved" in tc.out
+
+        tc.run(f"graph info --requires=pkg/[<2]", assert_error=True)
+        assert f" Package 'pkg/[<2]' not resolved" in tc.out
diff --git a/test/unittests/model/build_info/test_deduce_locations.py b/test/unittests/model/build_info/test_deduce_locations.py
index 5aeeff77f..33dfd1b2a 100644
--- a/test/unittests/model/build_info/test_deduce_locations.py
+++ b/test/unittests/model/build_info/test_deduce_locations.py
@@ -160,26 +160,33 @@ def test_windows_several_shared_link_locations(lib_info, conanfile):
         assert result.components[f"_{lib_name}"].type == "shared-library"
 
 
+@pytest.mark.parametrize("lib, symlinks", [
+    # symlinks == "real_file <- symlink1 <- symlink2 <- ... <- symlinkN"
+    # Issue related: https://github.com/conan-io/conan/issues/17417
+    ("png", "libpng16.so.16.44.0 <- libpng16.so.16 <- libpng16.so <- libpng.so")
+])
 @pytest.mark.skipif(platform.system() == "Windows", reason="Can't apply symlink on Windows")
-def test_shared_link_locations_symlinks(conanfile):
+def test_shared_link_locations_symlinks(lib, symlinks, conanfile):
     """
     Tests auto deduce location is able to find the real path of
     any symlink created in the libs folder
     """
     folder = temp_folder()
+    all_files = symlinks.split(" <- ")  # [real_one, sym1, sym2, ...]
     # forcing a folder that it's not going to be analysed by the deduce_location() function
-    real_location = os.path.join(folder, "other", "mylib.so")
+    real_location = os.path.join(folder, "other", all_files.pop(0))
     save(real_location, "")
     # Symlinks
     os.makedirs(os.path.join(folder, "libdir"))
-    sym_1 = os.path.join(folder, "libdir", "mylib.1.0.0.so")
-    sym_2 = os.path.join(folder, "libdir", "mylib.2.0.0.so")
-    os.symlink(real_location, sym_1)
-    os.symlink(sym_1, sym_2)
+    prev_path = real_location
+    for file in all_files:
+        sym = os.path.join(folder, "libdir", file)
+        os.symlink(prev_path, sym)
+        prev_path = sym
 
     cppinfo = CppInfo()
     cppinfo.libdirs = ["libdir"]
-    cppinfo.libs = ["mylib"]
+    cppinfo.libs = [lib]
     cppinfo.set_relative_base_folder(folder)
 
     result = cppinfo.deduce_full_cpp_info(conanfile)
diff --git a/test/unittests/model/version/test_version_range.py b/test/unittests/model/version/test_version_range.py
index 5ac8d0850..b380de47b 100644
--- a/test/unittests/model/version/test_version_range.py
+++ b/test/unittests/model/version/test_version_range.py
@@ -112,6 +112,10 @@ def test_range(version_range, conditions, versions_in, versions_out):
     ['>1 <=2.0', False, ["1.1", "1.9", "2.0"], ["0.9", "1.0-pre.1", "1.0", "1.1-pre.1", "2.0-pre"]],
     # This should be old and new behaviors remain the same
     ['>1 <=2.0', True, ["1.1-pre.1", "1.1", "1.9", "2.0", "2.0-pre"], ["0.9", "1.0", "1.0-pre.1"]],
+
+    # Codified quirks
+    ['', False, ["1.0", "2.0"], ["1.0-pre.1", "2.0-pre.1"]],
+    ['', True, ["1.0", "2.0", "1.0-pre.1", "2.0-pre.1"], []],
 ])
 def test_range_prereleases_conf(version_range, resolve_prereleases, versions_in, versions_out):
     r = VersionRange(version_range)