conan-io/conan

View on GitHub
conan/tools/cmake/presets.py

Summary

Maintainability
A
0 mins
Test Coverage
import json
import os
import platform

from conan.tools.cmake.layout import get_build_folder_custom_vars
from conan.tools.cmake.utils import is_multi_configuration
from conan.tools.microsoft import is_msvc
from conans.errors import ConanException
from conans.util.files import save, load


def _build_and_test_preset_fields(conanfile, multiconfig):
    build_type = conanfile.settings.get_safe("build_type")
    configure_preset_name = _configure_preset_name(conanfile, multiconfig)
    build_preset_name = _build_and_test_preset_name(conanfile)
    ret = {"name": build_preset_name,
           "configurePreset": configure_preset_name}
    if multiconfig:
        ret["configuration"] = build_type
    return ret


def _build_and_test_preset_name(conanfile):
    build_type = conanfile.settings.get_safe("build_type")
    custom_conf, user_defined_build = get_build_folder_custom_vars(conanfile)
    if user_defined_build:
        return custom_conf

    if custom_conf:
        if build_type:
            return "{}-{}".format(custom_conf, build_type.lower())
        else:
            return custom_conf
    return build_type.lower() if build_type else "default"


def _configure_preset_name(conanfile, multiconfig):
    build_type = conanfile.settings.get_safe("build_type")
    custom_conf, user_defined_build = get_build_folder_custom_vars(conanfile)

    if user_defined_build:
        return custom_conf

    if multiconfig or not build_type:
        return "default" if not custom_conf else custom_conf

    if custom_conf:
        return "{}-{}".format(custom_conf, str(build_type).lower())
    else:
        return str(build_type).lower()


def _configure_preset(conanfile, generator, cache_variables, toolchain_file, multiconfig):
    build_type = conanfile.settings.get_safe("build_type")
    name = _configure_preset_name(conanfile, multiconfig)
    if not multiconfig and build_type:
        cache_variables["CMAKE_BUILD_TYPE"] = build_type
    ret = {
            "name": name,
            "displayName": "'{}' config".format(name),
            "description": "'{}' configure using '{}' generator".format(name, generator),
            "generator": generator,
            "cacheVariables": cache_variables,
           }
    if "Ninja" in generator and is_msvc(conanfile):
        toolset_arch = conanfile.conf.get("tools.cmake.cmaketoolchain:toolset_arch")
        if toolset_arch:
            toolset_arch = "host={}".format(toolset_arch)
            ret["toolset"] = {
                "value": toolset_arch,
                "strategy": "external"
            }
        arch = {"x86": "x86",
                "x86_64": "x64",
                "armv7": "ARM",
                "armv8": "ARM64"}.get(conanfile.settings.get_safe("arch"))

        if arch:
            ret["architecture"] = {
                "value": arch,
                "strategy": "external"
            }

    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.
        # If we install a local conanfile: "conan install ." with a layout(), it will be available.
        ret["binaryDir"] = conanfile.build_folder

    def _format_val(val):
        return f'"{val}"' if type(val) == str and " " in val else f"{val}"

    # https://github.com/conan-io/conan/pull/12034#issuecomment-1253776285
    cache_variables_info = " ".join([f"-D{var}={_format_val(value)}" for var, value in cache_variables.items()])
    add_toolchain_cache = f"-DCMAKE_TOOLCHAIN_FILE={toolchain_file} " \
        if "CMAKE_TOOLCHAIN_FILE" not in cache_variables_info else ""

    conanfile.output.info(f"Preset '{name}' added to CMakePresets.json. Invoke it manually using "
                          f"'cmake --preset {name}'")
    conanfile.output.info(f"If your CMake version is not compatible with "
                          f"CMakePresets (<3.19) call cmake like: 'cmake <path> "
                          f"-G {_format_val(generator)} {add_toolchain_cache}"
                          f"{cache_variables_info}'")
    return ret


def _insert_preset(data, preset_name, preset):
    position = _get_already_existing_preset_index(preset["name"], data.setdefault(preset_name, []))
    if position is not None:
        data[preset_name][position] = preset
    else:
        data[preset_name].append(preset)


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):
    """
    Contents for the CMakePresets.json
    It uses schema version 3 unless it is forced to 2
    """
    multiconfig = is_multi_configuration(generator)
    conf = _configure_preset(conanfile, generator, cache_variables, toolchain_file, multiconfig)
    build = _build_and_test_preset_fields(conanfile, multiconfig)
    ret = {"version": _schema_version(conanfile, default=3),
           "vendor": {"conan": {}},
           "cmakeMinimumRequired": {"major": 3, "minor": 15, "patch": 0},
           "configurePresets": [conf],
           "buildPresets": [build],
           "testPresets": [build]
           }
    return ret


def write_cmake_presets(conanfile, toolchain_file, generator, cache_variables,
                        user_presets_path=None):
    cache_variables = cache_variables or {}
    if platform.system() == "Windows" and generator == "MinGW Makefiles":
        if "CMAKE_SH" not in cache_variables:
            cache_variables["CMAKE_SH"] = "CMAKE_SH-NOTFOUND"

        cmake_make_program = conanfile.conf.get("tools.gnu:make_program",
                                                default=cache_variables.get("CMAKE_MAKE_PROGRAM"))
        if cmake_make_program:
            cmake_make_program = cmake_make_program.replace("\\", "/")
            cache_variables["CMAKE_MAKE_PROGRAM"] = cmake_make_program

    if "CMAKE_POLICY_DEFAULT_CMP0091" not in cache_variables:
        cache_variables["CMAKE_POLICY_DEFAULT_CMP0091"] = "NEW"

    if "BUILD_TESTING" not in cache_variables:
        if conanfile.conf.get("tools.build:skip_test", check_type=bool):
            cache_variables["BUILD_TESTING"] = "OFF"

    preset_path = os.path.join(conanfile.generators_folder, "CMakePresets.json")
    multiconfig = is_multi_configuration(generator)
    if os.path.exists(preset_path) and multiconfig:
        data = json.loads(load(preset_path))
        build_preset = _build_and_test_preset_fields(conanfile, multiconfig)
        _insert_preset(data, "buildPresets", build_preset)
        _insert_preset(data, "testPresets", build_preset)
        configure_preset = _configure_preset(conanfile, generator, cache_variables, toolchain_file,
                                             multiconfig)
        # Conan generated presets should have only 1 configurePreset, no more, overwrite it
        data["configurePresets"] = [configure_preset]
    else:
        data = _contents(conanfile, toolchain_file, cache_variables, generator)

    preset_content = json.dumps(data, indent=4)
    save(preset_path, preset_content)
    _save_cmake_user_presets(conanfile, preset_path, user_presets_path)


def _save_cmake_user_presets(conanfile, preset_path, user_presets_path):
    if not user_presets_path:
        return

    # If generators folder is the same as source folder, do not create the user presets
    # we already have the CMakePresets.json right there
    if not (conanfile.source_folder and conanfile.source_folder != conanfile.generators_folder):
        return

    user_presets_path = os.path.join(conanfile.source_folder, user_presets_path)
    if os.path.isdir(user_presets_path):  # Allows user to specify only the folder
        output_dir = user_presets_path
        user_presets_path = os.path.join(user_presets_path, "CMakeUserPresets.json")
    else:
        output_dir = os.path.dirname(user_presets_path)

    if not os.path.exists(os.path.join(output_dir, "CMakeLists.txt")):
        return

    # It uses schema version 4 unless it is forced to 2
    if not os.path.exists(user_presets_path):
        data = {"version": _schema_version(conanfile, default=4),
                "vendor": {"conan": dict()}}
    else:
        data = json.loads(load(user_presets_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 _get_already_existing_preset_index(name, presets):
    """Get the index of a Preset with a given name, this is used to replace it with updated contents
    """
    positions = [index for index, p in enumerate(presets)
                 if p["name"] == name]
    if positions:
        return positions[0]
    return None


def _append_user_preset_path(conanfile, data, preset_path):
    """ - Appends a 'include' to preset_path if the schema supports it.
        - Otherwise it merges to "data" all the configurePresets, buildPresets etc from the
          read 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] = []

                position = _get_already_existing_preset_index(preset["name"], data[preset_type])
                if position is not None:
                    # If the preset already existed, replace the element with the new one
                    data[preset_type][position] = preset
                else:
                    data[preset_type].append(preset)
        return data


def load_cmake_presets(folder):
    try:
        tmp = load(os.path.join(folder, "CMakePresets.json"))
    except FileNotFoundError:
        # Issue: https://github.com/conan-io/conan/issues/12896
        raise ConanException(f"CMakePresets.json was not found in {folder} folder. Check that you "
                             f"are using CMakeToolchain as generator to ensure its correct "
                             f"initialization.")
    return json.loads(tmp)