Repository: conan-io/conan

Issue:
[feature] CMakePresets toolchain inclusion
The `toolchainFile` element requires CMake >=3.21, a cacheVariables with CMAKE_TOOLCHAIN_FILE could be used instead.

Might be opt-in with the same conf used for https://github.com/conan-io/conan/issues/11648

Related https://github.com/conan-io/conan/issues/11623
 

Discussion:

Patch:
diff --git a/conan/tools/cmake/presets.py b/conan/tools/cmake/presets.py
index 918f6a831..b50aa1724 100644
--- a/conan/tools/cmake/presets.py
+++ b/conan/tools/cmake/presets.py
@@ -53,8 +53,13 @@ def _add_configure_preset(conanfile, generator, cache_variables, toolchain_file,
             "description": "'{}' configure using '{}' generator".format(name, generator),
             "generator": generator,
             "cacheVariables": cache_variables,
-            "toolchainFile": toolchain_file,
+
            }
+    if not _forced_schema_2(conanfile):
+        ret["toolchainFile"] = toolchain_file
+    else:
+        ret["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"] = toolchain_file
+
     if conanfile.build_folder:
         # If we are installing a ref: "conan install <ref>", we don't have build_folder, because
         # we don't even have a conanfile with a `layout()` to determine the build folder.
@@ -63,8 +68,34 @@ def _add_configure_preset(conanfile, generator, cache_variables, toolchain_file,
     return ret
 
 
+def _forced_schema_2(conanfile):
+    version = conanfile.conf.get("tools.cmake.cmaketoolchain.presets:max_schema_version",
+                                 check_type=int)
+    if not version:
+        return False
+
+    if version < 2:
+        raise ConanException("The minimum value for 'tools.cmake.cmaketoolchain.presets:"
+                             "schema_version' is 2")
+    if version < 4:
+        return True
+
+    return False
+
+
+def _schema_version(conanfile, default):
+    if _forced_schema_2(conanfile):
+        return 2
+
+    return default
+
+
 def _contents(conanfile, toolchain_file, cache_variables, generator):
-    ret = {"version": 3,
+    """
+    Contents for the CMakePresets.json
+    It uses schema version 3 unless it is forced to 2
+    """
+    ret = {"version": _schema_version(conanfile, default=3),
            "cmakeMinimumRequired": {"major": 3, "minor": 15, "patch": 0},
            "configurePresets": [],
            "buildPresets": [],
@@ -117,25 +148,51 @@ def write_cmake_presets(conanfile, toolchain_file, generator, cache_variables):
 
     data = json.dumps(data, indent=4)
     save(preset_path, data)
+    save_cmake_user_presets(conanfile, preset_path)
 
+
+def save_cmake_user_presets(conanfile, preset_path):
     # Try to save the CMakeUserPresets.json if layout declared and CMakeLists.txt found
     if conanfile.source_folder and conanfile.source_folder != conanfile.generators_folder:
         if os.path.exists(os.path.join(conanfile.source_folder, "CMakeLists.txt")):
+            """
+                Contents for the CMakeUserPresets.json
+                It uses schema version 4 unless it is forced to 2
+            """
             user_presets_path = os.path.join(conanfile.source_folder, "CMakeUserPresets.json")
             if not os.path.exists(user_presets_path):
-                data = {"version": 4, "include": [preset_path], "vendor": {"conan": dict()}}
+                data = {"version": _schema_version(conanfile, default=4),
+                        "vendor": {"conan": dict()}}
             else:
                 data = json.loads(load(user_presets_path))
-                if "conan" in data.get("vendor", {}):
-                    # Clear the folders that have been deleted
-                    data["include"] = [i for i in data.get("include", []) if os.path.exists(i)]
-                    if preset_path not in data["include"]:
-                        data["include"].append(preset_path)
-
+                if "conan" not in data.get("vendor", {}):
+                    # The file is not ours, we cannot overwrite it
+                    return
+            data = _append_user_preset_path(conanfile, data, preset_path)
             data = json.dumps(data, indent=4)
             save(user_presets_path, data)
 
 
+def _append_user_preset_path(conanfile, data, preset_path):
+    if not _forced_schema_2(conanfile):
+        if "include" not in data:
+            data["include"] = []
+        # Clear the folders that have been deleted
+        data["include"] = [i for i in data.get("include", []) if os.path.exists(i)]
+        if preset_path not in data["include"]:
+            data["include"].append(preset_path)
+        return data
+    else:
+        # Merge the presets
+        cmake_preset = json.loads(load(preset_path))
+        for preset_type in ("configurePresets", "buildPresets", "testPresets"):
+            for preset in cmake_preset.get(preset_type, []):
+                if preset_type not in data:
+                    data[preset_type] = []
+                data[preset_type].append(preset)
+        return data
+
+
 def load_cmake_presets(folder):
     tmp = load(os.path.join(folder, "CMakePresets.json"))
     return json.loads(tmp)
diff --git a/conan/tools/system/package_manager.py b/conan/tools/system/package_manager.py
index bcef638e4..b868965b2 100644
--- a/conan/tools/system/package_manager.py
+++ b/conan/tools/system/package_manager.py
@@ -11,6 +11,9 @@ class _SystemPackageManagerTool(object):
     install_command = ""
     update_command = ""
     check_command = ""
+    accepted_install_codes = [0]
+    accepted_update_codes = [0]
+    accepted_check_codes = [0, 1]
 
     def __init__(self, conanfile):
         self._conanfile = conanfile
@@ -61,6 +64,12 @@ class _SystemPackageManagerTool(object):
         if self._active_tool == self.__class__.tool_name:
             return method(*args, **kwargs)
 
+    def _conanfile_run(self, command, accepted_returns):
+        ret = self._conanfile.run(command, ignore_errors=True)
+        if ret not in accepted_returns:
+            raise ConanException("Command '%s' failed" % command)
+        return ret
+
     def install_substitutes(self, *args, **kwargs):
         return self.run(self._install_substitutes, *args, **kwargs)
 
@@ -109,7 +118,7 @@ class _SystemPackageManagerTool(object):
                                                       tool=self.tool_name,
                                                       packages=" ".join(packages_arch),
                                                       **kwargs)
-                return self._conanfile.run(command)
+                return self._conanfile_run(command, self.accepted_install_codes)
         else:
             self._conanfile.output.info("System requirements: {} already "
                                         "installed".format(" ".join(packages)))
@@ -124,7 +133,7 @@ class _SystemPackageManagerTool(object):
                                  "'-c tools.system.package_manager:mode={1}'".format(self.mode_check,
                                                                                      self.mode_install))
         command = self.update_command.format(sudo=self.sudo_str, tool=self.tool_name)
-        return self._conanfile.run(command)
+        return self._conanfile_run(command, self.accepted_update_codes)
 
     def _check(self, packages):
         missing = [pkg for pkg in packages if self.check_package(self.get_package_name(pkg)) != 0]
@@ -133,7 +142,7 @@ class _SystemPackageManagerTool(object):
     def check_package(self, package):
         command = self.check_command.format(tool=self.tool_name,
                                             package=package)
-        return self._conanfile.run(command, ignore_errors=True)
+        return self._conanfile_run(command, self.accepted_check_codes)
 
 
 class Apt(_SystemPackageManagerTool):
@@ -167,6 +176,7 @@ class Yum(_SystemPackageManagerTool):
     install_command = "{sudo}{tool} install -y {packages}"
     update_command = "{sudo}{tool} check-update -y"
     check_command = "rpm -q {package}"
+    accepted_update_codes = [0, 100]
 
     def __init__(self, conanfile, arch_names=None):
         super(Yum, self).__init__(conanfile)
diff --git a/conans/model/conf.py b/conans/model/conf.py
index 1c37c95e3..968bcd87b 100644
--- a/conans/model/conf.py
+++ b/conans/model/conf.py
@@ -21,6 +21,7 @@ BUILT_IN_CONFS = {
     "tools.cmake.cmaketoolchain:system_name": "Define CMAKE_SYSTEM_NAME in CMakeToolchain",
     "tools.cmake.cmaketoolchain:system_version": "Define CMAKE_SYSTEM_VERSION in CMakeToolchain",
     "tools.cmake.cmaketoolchain:system_processor": "Define CMAKE_SYSTEM_PROCESSOR in CMakeToolchain",
+    "tools.cmake.cmaketoolchain.presets:max_schema_version": "Generate CMakeUserPreset.json compatible with the supplied schema version",
     "tools.env.virtualenv:auto_use": "Automatically activate virtualenv file generation",
     "tools.cmake.cmake_layout:build_folder_vars": "Settings and Options that will produce a different build folder and different CMake presets names",
     "tools.files.download:retry": "Number of retries in case of failure when downloading",


Test patch:
diff --git a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
index 095ae5c61..c4b25b272 100644
--- a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
+++ b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
@@ -76,7 +76,8 @@ def test_cmake_toolchain_custom_toolchain():
                     reason="Single config test, Linux CI still without 3.23")
 @pytest.mark.tool_cmake(version="3.23")
 @pytest.mark.parametrize("existing_user_presets", [None, "user_provided", "conan_generated"])
-def test_cmake_user_presets_load(existing_user_presets):
+@pytest.mark.parametrize("schema2", [True, False])
+def test_cmake_user_presets_load(existing_user_presets, schema2):
     """
     Test if the CMakeUserPresets.cmake is generated and use CMake to use it to verify the right
     syntax of generated CMakeUserPresets.cmake and CMakePresets.cmake. If the user already provided
@@ -813,6 +814,48 @@ def test_cmake_presets_multiple_settings_multi_config():
     assert "MSVC_LANG2017" in client.out
 
 
+@pytest.mark.tool_cmake(version="3.23")
+def test_user_presets_version2():
+    client = TestClient(path_with_spaces=False)
+    client.run("new hello/0.1 --template=cmake_exe")
+    configs = ["-c tools.cmake.cmaketoolchain.presets:max_schema_version=2 ",
+               "-c tools.cmake.cmake_layout:build_folder_vars='[\"settings.compiler.cppstd\"]'"]
+    client.run("install . {} -s compiler.cppstd=14".format(" ".join(configs)))
+    client.run("install . {} -s compiler.cppstd=17".format(" ".join(configs)))
+    client.run("install . {} -s compiler.cppstd=20".format(" ".join(configs)))
+
+    if platform.system() == "Windows":
+        client.run_command("cmake . --preset 14")
+        client.run_command("cmake --build --preset 14-release")
+        client.run_command(r"build\14\Release\hello.exe")
+    else:
+        client.run_command("cmake . --preset 14-release")
+        client.run_command("cmake --build --preset 14-release")
+        client.run_command("./build/14/Release/hello")
+
+    assert "Hello World Release!" in client.out
+
+    if platform.system() != "Windows":
+        assert "__cplusplus2014" in client.out
+    else:
+        assert "MSVC_LANG2014" in client.out
+
+    if platform.system() == "Windows":
+        client.run_command("cmake . --preset 17")
+        client.run_command("cmake --build --preset 17-release")
+        client.run_command(r"build\17\Release\hello.exe")
+    else:
+        client.run_command("cmake . --preset 17-release")
+        client.run_command("cmake --build --preset 17-release")
+        client.run_command("./build/17/Release/hello")
+
+    assert "Hello World Release!" in client.out
+    if platform.system() != "Windows":
+        assert "__cplusplus2017" in client.out
+    else:
+        assert "MSVC_LANG2017" in client.out
+
+
 @pytest.mark.tool_cmake
 def test_cmaketoolchain_sysroot():
     client = TestClient(path_with_spaces=False)
diff --git a/conans/test/integration/toolchains/cmake/test_cmaketoolchain.py b/conans/test/integration/toolchains/cmake/test_cmaketoolchain.py
index 657e6eff3..cbfbb48a7 100644
--- a/conans/test/integration/toolchains/cmake/test_cmaketoolchain.py
+++ b/conans/test/integration/toolchains/cmake/test_cmaketoolchain.py
@@ -492,6 +492,66 @@ def test_android_c_library():
     client.run("create . foo/1.0@ -s os=Android -s os.api_level=23 -c tools.android:ndk_path=/foo")
 
 
+def test_user_presets_version2():
+    client = TestClient()
+    conanfile = textwrap.dedent("""
+                from conan import ConanFile
+                from conan.tools.cmake import cmake_layout
+
+                class Conan(ConanFile):
+                    name = "foo"
+                    version = "1.0"
+                    settings = "os", "arch", "compiler", "build_type"
+                    generators = "CMakeToolchain"
+
+                    def layout(self):
+                        cmake_layout(self)
+
+            """)
+    client.save({"conanfile.py": conanfile, "CMakeLists.txt": "foo"})
+    configs = ["-c tools.cmake.cmaketoolchain.presets:max_schema_version=2 ",
+               "-c tools.cmake.cmake_layout:build_folder_vars='[\"settings.compiler.cppstd\"]'"]
+    client.run("install . {} -s compiler.cppstd=14".format(" ".join(configs)))
+    client.run("install . {} -s compiler.cppstd=17".format(" ".join(configs)))
+
+    presets = json.loads(client.load("CMakeUserPresets.json"))
+    assert len(presets["configurePresets"]) == 2
+    assert presets["version"] == 2
+    assert "build/14/generators/conan_toolchain.cmake" \
+           in presets["configurePresets"][0]["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("\\",
+                                                                                               "/")
+    assert "build/17/generators/conan_toolchain.cmake" \
+           in presets["configurePresets"][1]["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("\\",
+                                                                                               "/")
+
+
+def test_user_presets_version2_no_overwrite_user():
+
+    client = TestClient()
+    conanfile = textwrap.dedent("""
+            from conan import ConanFile
+            from conan.tools.cmake import cmake_layout
+
+            class Conan(ConanFile):
+                name = "foo"
+                version = "1.0"
+                settings = "os", "arch", "compiler", "build_type"
+                generators = "CMakeToolchain"
+
+                def layout(self):
+                    cmake_layout(self)
+
+            """)
+    client.save({"conanfile.py": conanfile, "CMakeLists.txt": "foo",
+                 "CMakeUserPresets.json": '{"from_user": 1}'})
+    configs = ["-c tools.cmake.cmaketoolchain.presets:max_schema_version=2 ",
+               "-c tools.cmake.cmake_layout:build_folder_vars='[\"settings.compiler.cppstd\"]'"]
+    client.run("install . {} -s compiler.cppstd=14".format(" ".join(configs)))
+
+    presets = json.loads(client.load("CMakeUserPresets.json"))
+    assert presets == {"from_user": 1}
+
+
 @pytest.mark.skipif(platform.system() != "Windows", reason="Only Windows")
 def test_presets_paths_correct():
     client = TestClient()
@@ -506,12 +566,18 @@ def test_presets_paths_correct():
                 def layout(self):
                     cmake_layout(self)
             """)
-    client.save({"conanfile.py": conanfile})
-    client.run("install . ")
-    contents = json.loads(client.load("build/generators/CMakePresets.json"))
-    toolchain_file = contents["configurePresets"][0]["toolchainFile"]
-    assert "/" not in toolchain_file
-
-    binary_dir = contents["configurePresets"][0]["binaryDir"]
-    assert "/" not in binary_dir
+    client.save({"conanfile.py": conanfile, "CMakeLists.txt": "foo"})
+    configs = ["-c tools.cmake.cmaketoolchain.presets:max_schema_version=2 ",
+               "-c tools.cmake.cmake_layout:build_folder_vars='[\"settings.compiler.cppstd\"]'"]
+    client.run("install . {} -s compiler.cppstd=14".format(" ".join(configs)))
+    client.run("install . {} -s compiler.cppstd=17".format(" ".join(configs)))
 
+    presets = json.loads(client.load("CMakeUserPresets.json"))
+    assert len(presets["configurePresets"]) == 2
+    assert presets["version"] == 2
+    assert "build/14/generators/conan_toolchain.cmake" \
+           in presets["configurePresets"][0]["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("\\",
+                                                                                               "/")
+    assert "build/17/generators/conan_toolchain.cmake" \
+           in presets["configurePresets"][1]["cacheVariables"]["CMAKE_TOOLCHAIN_FILE"].replace("\\",
+                                                                                               "/")
diff --git a/conans/test/integration/tools/system/package_manager_test.py b/conans/test/integration/tools/system/package_manager_test.py
index b5becec39..cbab2a846 100644
--- a/conans/test/integration/tools/system/package_manager_test.py
+++ b/conans/test/integration/tools/system/package_manager_test.py
@@ -1,4 +1,5 @@
 import platform
+from unittest.mock import MagicMock, patch
 
 import mock
 import pytest
@@ -111,7 +112,11 @@ def test_tools_install_mode_check(tool_class):
         context_mock.return_value = "host"
         tool = tool_class(conanfile)
         with pytest.raises(ConanException) as exc_info:
-            tool.install(["package1", "package2"])
+            def fake_check(*args, **kwargs):
+                return ["package1", "package2"]
+            from conan.tools.system.package_manager import _SystemPackageManagerTool
+            with patch.object(_SystemPackageManagerTool, 'check', MagicMock(side_effect=fake_check)):
+                tool.install(["package1", "package2"])
         assert exc_info.value.args[0] == "System requirements: 'package1, package2' are missing but " \
                                          "can't install because tools.system.package_manager:mode is " \
                                          "'check'.Please update packages manually or set " \
@@ -140,7 +145,6 @@ def test_tools_update_mode_check(tool_class):
                                          "'-c tools.system.package_manager:mode=install'"
 
 
-
 @pytest.mark.parametrize("tool_class, result", [
     (Apt, "apt-get update"),
     (Yum, "yum check-update -y"),
@@ -165,6 +169,42 @@ def test_tools_update_mode_install(tool_class, result):
     assert tool._conanfile.command == result
 
 
+@pytest.mark.parametrize("tool_class, result", [
+    (Yum, "yum check-update -y"),
+    (Dnf, "dnf check-update -y"),
+])
+def test_dnf_yum_return_code_100(tool_class, result):
+    # https://github.com/conan-io/conan/issues/11661
+    conanfile = ConanFileMock()
+    conanfile.conf = Conf()
+    conanfile.settings = Settings()
+    conanfile.conf["tools.system.package_manager:tool"] = tool_class.tool_name
+    conanfile.conf["tools.system.package_manager:mode"] = "install"
+    with mock.patch('conans.ConanFile.context', new_callable=PropertyMock) as context_mock:
+        context_mock.return_value = "host"
+        tool = tool_class(conanfile)
+
+        def fake_run(command, win_bash=False, subsystem=None, env=None, ignore_errors=False):
+            assert command == result
+            return 100 if "check-update" in command else 0
+
+        conanfile.run = fake_run
+        tool.update()
+
+    #check that some random return code fails
+    with mock.patch('conans.ConanFile.context', new_callable=PropertyMock) as context_mock:
+        context_mock.return_value = "host"
+        tool = tool_class(conanfile)
+
+        def fake_run(command, win_bash=False, subsystem=None, env=None, ignore_errors=False):
+            return 55 if "check-update" in command else 0
+
+        conanfile.run = fake_run
+        with pytest.raises(ConanException) as exc_info:
+            tool.update()
+        assert f"Command '{result}' failed" == str(exc_info.value)
+
+
 @pytest.mark.parametrize("tool_class, result", [
     (Apt, 'apt-get install -y --no-install-recommends package1 package2'),
     (Yum, 'yum install -y package1 package2'),
@@ -185,8 +225,12 @@ def test_tools_install_mode_install(tool_class, result):
     with mock.patch('conans.ConanFile.context', new_callable=PropertyMock) as context_mock:
         context_mock.return_value = "host"
         tool = tool_class(conanfile)
-        tool.install(["package1", "package2"])
-    assert tool._conanfile.command == result
+
+        def fake_check(*args, **kwargs):
+            return ["package1", "package2"]
+        from conan.tools.system.package_manager import _SystemPackageManagerTool
+        with patch.object(_SystemPackageManagerTool, 'check', MagicMock(side_effect=fake_check)):
+            tool.install(["package1", "package2"])
 
 
 @pytest.mark.parametrize("tool_class, result", [
@@ -209,4 +253,3 @@ def test_tools_check(tool_class, result):
         context_mock.return_value = "host"
         tool = tool_class(conanfile)
         tool.check(["package"])
-    assert tool._conanfile.command == result
diff --git a/conans/test/utils/mocks.py b/conans/test/utils/mocks.py
index 4103bc4f3..08ae59928 100644
--- a/conans/test/utils/mocks.py
+++ b/conans/test/utils/mocks.py
@@ -197,6 +197,7 @@ class ConanFileMock(ConanFile):
         self.command = command
         self.path = os.environ["PATH"]
         self.captured_env = {key: value for key, value in os.environ.items()}
+        return 0
 
 
 MockOptions = MockSettings