diff --git a/conan/tools/cmake/layout.py b/conan/tools/cmake/layout.py index 24d75117de8..dad6ce31270 100644 --- a/conan/tools/cmake/layout.py +++ b/conan/tools/cmake/layout.py @@ -1,4 +1,5 @@ import os +import tempfile from conans.client.graph.graph import RECIPE_CONSUMER, RECIPE_EDITABLE from conan.errors import ConanException @@ -30,6 +31,15 @@ def cmake_layout(conanfile, generator=None, src_folder=".", build_folder="build" except ConanException: raise ConanException("'build_type' setting not defined, it is necessary for cmake_layout()") + try: # TODO: Refactor this repeated pattern to deduce "is-consumer" + if conanfile._conan_node.recipe in (RECIPE_CONSUMER, RECIPE_EDITABLE): + folder = "test_folder" if conanfile.tested_reference_str else "build_folder" + build_folder = conanfile.conf.get(f"tools.cmake.cmake_layout:{folder}") or build_folder + if build_folder == "$TMP" and folder == "test_folder": + build_folder = tempfile.mkdtemp() + except AttributeError: + pass + build_folder = build_folder if not subproject else os.path.join(subproject, build_folder) config_build_folder, user_defined_build = get_build_folder_custom_vars(conanfile) if config_build_folder: @@ -54,7 +64,8 @@ def get_build_folder_custom_vars(conanfile): conanfile_vars = conanfile.folders.build_folder_vars build_vars = conanfile.conf.get("tools.cmake.cmake_layout:build_folder_vars", check_type=list) if conanfile.tested_reference_str: - build_vars = build_vars or conanfile_vars or \ + if build_vars is None: # The user can define conf build_folder_vars = [] for no vars + build_vars = conanfile_vars or \ ["settings.compiler", "settings.compiler.version", "settings.arch", "settings.compiler.cppstd", "settings.build_type", "options.shared"] else: @@ -63,7 +74,8 @@ def get_build_folder_custom_vars(conanfile): except AttributeError: is_consumer = False if is_consumer: - build_vars = build_vars or conanfile_vars or [] + if build_vars is None: + build_vars = conanfile_vars or [] else: build_vars = conanfile_vars or [] diff --git a/conans/client/generators/__init__.py b/conans/client/generators/__init__.py index 1ff1d6888bd..749e9ec2c71 100644 --- a/conans/client/generators/__init__.py +++ b/conans/client/generators/__init__.py @@ -215,7 +215,10 @@ def relativize_paths(conanfile, placeholder): return None, None abs_base_path = os.path.join(abs_base_path, "") # For the trailing / to dissambiguate matches generators_folder = conanfile.generators_folder - rel_path = os.path.relpath(abs_base_path, generators_folder) + try: + rel_path = os.path.relpath(abs_base_path, generators_folder) + except ValueError: # In case the unit in Windows is different, path cannot be made relative + return None, None new_path = placeholder if rel_path == "." else os.path.join(placeholder, rel_path) new_path = os.path.join(new_path, "") # For the trailing / to dissambiguate matches return abs_base_path, new_path diff --git a/conans/model/conf.py b/conans/model/conf.py index 8174f963f8d..520c73b8f5f 100644 --- a/conans/model/conf.py +++ b/conans/model/conf.py @@ -73,6 +73,8 @@ "tools.cmake.cmaketoolchain:toolset_cuda": "(Experimental) Path to a CUDA toolset to use, or version if installed at the system level", "tools.cmake.cmaketoolchain:presets_environment": "String to define wether to add or not the environment section to the CMake presets. Empty by default, will generate the environment section in CMakePresets. Can take values: 'disabled'.", "tools.cmake.cmake_layout:build_folder_vars": "Settings and Options that will produce a different build folder and different CMake presets names", + "tools.cmake.cmake_layout:build_folder": "(Experimental) Allow configuring the base folder of the build for local builds", + "tools.cmake.cmake_layout:test_folder": "(Experimental) Allow configuring the base folder of the build for test_package", "tools.cmake:cmake_program": "Path to CMake executable", "tools.cmake:install_strip": "Add --strip to cmake.install()", "tools.deployer:symlinks": "Set to False to disable deployers copying symlinks", diff --git a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py index c90bd73883c..e34f896b75c 100644 --- a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py +++ b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py @@ -11,6 +11,7 @@ from conan.tools.microsoft.visual import vcvars_command from conans.test.assets.cmake import gen_cmakelists from conans.test.assets.genconanfile import GenConanfile +from conans.test.utils.test_files import temp_folder from conans.test.utils.tools import TestClient, TurboTestClient from conans.util.files import save, load, rmdir @@ -908,6 +909,33 @@ def test_cmake_presets_multiple_settings_multi_config(): assert "MSVC_LANG2017" in client.out +@pytest.mark.tool("cmake", "3.23") +@pytest.mark.skipif(platform.system() != "Windows", reason="Needs windows") +# Test both with a local folder and an absolute folder +@pytest.mark.parametrize("build", ["mybuild", "temp"]) +def test_cmake_presets_build_folder(build): + client = TestClient(path_with_spaces=False) + client.run("new cmake_exe -d name=hello -d version=0.1") + + build = temp_folder() if build == "temp" else build + settings_layout = f' -c tools.cmake.cmake_layout:build_folder="{build}" '\ + '-c tools.cmake.cmake_layout:build_folder_vars=' \ + '\'["settings.compiler.runtime", "settings.compiler.cppstd"]\'' + # But If we change, for example, the cppstd and the compiler version, the toolchain + # and presets will be different, but it will be appended to the UserPresets.json + settings = "-s compiler=msvc -s compiler.version=191 -s compiler.runtime=static " \ + "-s compiler.cppstd=17" + client.run("install . {} {}".format(settings, settings_layout)) + assert os.path.exists(os.path.join(client.current_folder, build, "static-17", "generators")) + + client.run_command("cmake . --preset conan-static-17") + client.run_command("cmake --build --preset conan-static-17-release") + client.run_command("ctest --preset conan-static-17-release") + client.run_command(f"{build}\\static-17\\Release\\hello") + assert "Hello World Release!" in client.out + 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/layout/test_cmake_build_folder.py b/conans/test/integration/layout/test_cmake_build_folder.py new file mode 100644 index 00000000000..afdbfff99b0 --- /dev/null +++ b/conans/test/integration/layout/test_cmake_build_folder.py @@ -0,0 +1,271 @@ +import os +import re +import textwrap + +import pytest + +from conans.test.assets.genconanfile import GenConanfile +from conans.test.utils.test_files import temp_folder +from conans.test.utils.tools import TestClient + + +@pytest.mark.parametrize("cmd", ["install", "build"]) +def test_cmake_layout_build_folder(cmd): + """ testing the tools.cmake.cmake_layout:build_folder config for + both build and install commands + """ + c = TestClient() + abs_build = temp_folder() + conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import cmake_layout + + class HelloTestConan(ConanFile): + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeToolchain" + def layout(self): + cmake_layout(self, generator="Ninja") # Ninja so same in all OSs, single-config + """) + + c.save({"conanfile.py": conanfile}) + c.run(f'{cmd} . -c tools.cmake.cmake_layout:build_folder="{abs_build}"') + assert os.path.exists(os.path.join(abs_build, "Release", "generators", "conan_toolchain.cmake")) + assert not os.path.exists(os.path.join(c.current_folder, "build")) + + # Make sure that a non-existing folder will not fail and will be created + new_folder = os.path.join(temp_folder(), "my build") + c.run(f'{cmd} . -c tools.cmake.cmake_layout:build_folder="{new_folder}"') + assert os.path.exists(os.path.join(new_folder, "Release", "generators", "conan_toolchain.cmake")) + assert not os.path.exists(os.path.join(c.current_folder, "build")) + + # Just in case we check that local build folder would be created if no arg is provided + c.run('build . ') + assert os.path.exists(os.path.join(c.current_folder, "build")) + + +@pytest.mark.parametrize("cmd", ["install", "build"]) +def test_cmake_layout_build_folder_relative(cmd): + """ Same as above, but with a relative folder, which is relative to the conanfile + """ + c = TestClient() + conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import cmake_layout + + class HelloTestConan(ConanFile): + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeToolchain" + def layout(self): + cmake_layout(self, generator="Ninja") # Ninja so same in all OSs, single-config + """) + + c.save({"pkg/conanfile.py": conanfile}) + c.run(f'{cmd} pkg -c tools.cmake.cmake_layout:build_folder=mybuild') + abs_build = os.path.join(c.current_folder, "pkg", "mybuild") + assert os.path.exists(os.path.join(abs_build, "Release", "generators", "conan_toolchain.cmake")) + assert not os.path.exists(os.path.join(c.current_folder, "pkg", "build")) + + # relative path, pointing to a sibling folder + c.run(f'{cmd} pkg -c tools.cmake.cmake_layout:build_folder=../mybuild') + abs_build = os.path.join(c.current_folder, "mybuild") + assert os.path.exists(os.path.join(abs_build, "Release", "generators", "conan_toolchain.cmake")) + assert not os.path.exists(os.path.join(c.current_folder, "build")) + + # Just in case we check that local build folder would be created if no arg is provided + c.run('build pkg ') + assert os.path.exists(os.path.join(c.current_folder, "pkg", "build")) + + +def test_test_cmake_layout_build_folder_test_package(): + """ relocate the test_package temporary build folders to elsewhere + """ + c = TestClient() + abs_build = temp_folder() + test_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import cmake_layout + + class HelloTestConan(ConanFile): + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeToolchain" + + def requirements(self): + self.requires(self.tested_reference_str) + + def layout(self): + cmake_layout(self, generator="Ninja") + + def test(self): + pass + """) + + c.save({"conanfile.py": GenConanfile("pkg", "0.1"), + "test_package/conanfile.py": test_conanfile}) + c.run(f'create . -c tools.cmake.cmake_layout:test_folder="{abs_build}" ' + '-c tools.cmake.cmake_layout:build_folder_vars=[]') + # Even if build_folder_vars=[] the "Release" folder is added always + assert os.path.exists(os.path.join(abs_build, "Release", "generators", "conan_toolchain.cmake")) + assert not os.path.exists(os.path.join(c.current_folder, "test_package", "build")) + + c.run(f'create . -c tools.cmake.cmake_layout:build_folder_vars=[]') + assert os.path.exists(os.path.join(c.current_folder, "test_package", "build")) + + +def test_test_cmake_layout_build_folder_test_package_temp(): + """ using always the same test_package build_folder will cause collisions. + We need a mechanism to relocate, still provide unique folders for each build + """ + c = TestClient() + test_conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import cmake_layout + + class HelloTestConan(ConanFile): + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeToolchain" + + def requirements(self): + self.requires(self.tested_reference_str) + + def layout(self): + cmake_layout(self) + + def test(self): + pass + """) + + profile = textwrap.dedent(""" + include(default) + [conf] + tools.cmake.cmake_layout:test_folder=$TMP + tools.cmake.cmake_layout:build_folder_vars=[] + """) + c.save({"conanfile.py": GenConanfile("pkg", "0.1"), + "test_package/conanfile.py": test_conanfile, + "profile": profile}) + c.run(f'create . -pr=profile') + build_folder = re.search(r"Test package build folder: (\S+)", str(c.out)).group(1) + assert os.path.exists(os.path.join(build_folder, "generators", "conan_toolchain.cmake")) + assert not os.path.exists(os.path.join(c.current_folder, "test_package", "build")) + + c.run(f'test test_package pkg/0.1 -pr=profile') + build_folder = re.search(r"Test package build folder: (\S+)", str(c.out)).group(1) + assert os.path.exists(os.path.join(build_folder, "generators", "conan_toolchain.cmake")) + assert not os.path.exists(os.path.join(c.current_folder, "test_package", "build")) + + c.run(f'create . -c tools.cmake.cmake_layout:build_folder_vars=[]') + assert os.path.exists(os.path.join(c.current_folder, "test_package", "build")) + + +def test_cmake_layout_build_folder_editable(): + """ testing how it works with editables. Layout + code + pkga # editable add . + pkgb # editable add . + app # install -c build_folder="../../mybuild -c build_folder_vars="['self.name']" + pkga-release-data.cmake + pkga_PACKAGE_FOLDER_RELEASE = /abs/path/to/code/pkga + pkga_INCLUDE_DIRS_RELEASE = ${pkga_PACKAGE_FOLDER_RELEASE}/include + pkga_LIB_DIRS_RELEASE = /abs/path/to/mybuild/pkga/Release + mybuild + pkga/Release/ + pkgb/Release/ + """ + c = TestClient() + base_folder = temp_folder() + c.current_folder = os.path.join(base_folder, "code").replace("\\", "/") + project_build_folder = os.path.join(base_folder, "mybuild").replace("\\", "/") + conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import cmake_layout + + class Pkg(ConanFile): + name = "{name}" + version = "0.1" + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeToolchain" + def layout(self): + cmake_layout(self, generator="Ninja") # Ninja so same in all OSs, single-config + """) + + c.save({"pkga/conanfile.py": conanfile.format(name="pkga"), + "pkgb/conanfile.py": conanfile.format(name="pkgb"), + "app/conanfile.py": GenConanfile().with_requires("pkga/0.1", "pkgb/0.1") + .with_settings("build_type") + .with_generator("CMakeDeps")}) + + c.run("editable add pkga") + c.run("editable add pkgb") + conf = f'-c tools.cmake.cmake_layout:build_folder="../../mybuild" ' \ + '-c tools.cmake.cmake_layout:build_folder_vars="[\'self.name\']"' + c.run(f'install app {conf} --build=editable') + + data = c.load("app/pkga-release-data.cmake") + + # The package is the ``code/pkga`` folder + assert f'pkga_PACKAGE_FOLDER_RELEASE "{c.current_folder}/pkga"' in data + # This is an absolute path, not relative, as it is not inside the package + assert f'set(pkga_LIB_DIRS_RELEASE "{project_build_folder}/pkga/Release' in data + data = c.load("app/pkgb-release-data.cmake") + assert f'set(pkgb_LIB_DIRS_RELEASE "{project_build_folder}/pkgb/Release' in data + + assert os.path.exists(os.path.join(project_build_folder, "pkga", "Release", "generators", + "conan_toolchain.cmake")) + assert os.path.exists(os.path.join(project_build_folder, "pkgb", "Release", "generators", + "conan_toolchain.cmake")) + + +def test_cmake_layout_editable_output_folder(): + """ testing how it works with editables, but --output-folder + code + pkga # editable add . --output-folder = ../mybuild + pkgb # editable add . --output-folder = ../mybuild + app # install -c build_folder_vars="['self.name']" + pkga-release-data.cmake + pkga_PACKAGE_FOLDER_RELEASE = /abs/path/to/mybuild + pkga_INCLUDE_DIRS_RELEASE = /abs/path/to/code/pkga/include + pkga_LIB_DIRS_RELEASE = pkga_PACKAGE_FOLDER_RELEASE/build/pkga/Release + mybuild + build + pkga/Release/ + pkgb/Release/ + """ + c = TestClient() + base_folder = temp_folder() + c.current_folder = os.path.join(base_folder, "code") + project_build_folder = os.path.join(base_folder, "mybuild").replace("\\", "/") + conanfile = textwrap.dedent(""" + from conan import ConanFile + from conan.tools.cmake import cmake_layout + + class Pkg(ConanFile): + name = "{name}" + version = "0.1" + settings = "os", "compiler", "build_type", "arch" + generators = "CMakeToolchain" + def layout(self): + cmake_layout(self, generator="Ninja") # Ninja so same in all OSs, single-config + """) + + c.save({"pkga/conanfile.py": conanfile.format(name="pkga"), + "pkgb/conanfile.py": conanfile.format(name="pkgb"), + "app/conanfile.py": GenConanfile().with_requires("pkga/0.1", "pkgb/0.1") + .with_settings("build_type") + .with_generator("CMakeDeps")}) + + c.run(f'editable add pkga --output-folder="../mybuild"') + c.run(f'editable add pkgb --output-folder="../mybuild"') + conf = f'-c tools.cmake.cmake_layout:build_folder_vars="[\'self.name\']"' + c.run(f'install app {conf} --build=editable') + + data = c.load("app/pkga-release-data.cmake") + assert f'set(pkga_PACKAGE_FOLDER_RELEASE "{project_build_folder}")' in data + # Thse folders are relative to the package + assert 'pkga_LIB_DIRS_RELEASE "${pkga_PACKAGE_FOLDER_RELEASE}/build/pkga/Release' in data + data = c.load("app/pkgb-release-data.cmake") + assert 'pkgb_LIB_DIRS_RELEASE "${pkgb_PACKAGE_FOLDER_RELEASE}/build/pkgb/Release' in data + + assert os.path.exists(os.path.join(project_build_folder, "build", "pkga", "Release", + "generators", "conan_toolchain.cmake")) + assert os.path.exists(os.path.join(project_build_folder, "build", "pkgb", "Release", + "generators", "conan_toolchain.cmake"))