diff --git a/conan/tools/cmake/__init__.py b/conan/tools/cmake/__init__.py index 7e4855b8e11..29c7dc5745c 100644 --- a/conan/tools/cmake/__init__.py +++ b/conan/tools/cmake/__init__.py @@ -1,3 +1,4 @@ from conan.tools.cmake.toolchain import CMakeToolchain from conan.tools.cmake.cmake import CMake from conan.tools.cmake.cmakedeps.cmakedeps import CMakeDeps +from conan.tools.cmake.file_api import CMakeFileAPI diff --git a/conan/tools/cmake/file_api.py b/conan/tools/cmake/file_api.py new file mode 100644 index 00000000000..f9272ae6dbe --- /dev/null +++ b/conan/tools/cmake/file_api.py @@ -0,0 +1,144 @@ +import json +import fnmatch +import os + +from conan.tools.files import CppPackage +from conans.util.files import save, load + + +class CMakeFileAPI(object): + """ + implements https://cmake.org/cmake/help/latest/manual/cmake-file-api.7.html + """ + CODEMODELV2 = "codemodel-v2" + SKIP_TARGETS = ["ZERO_CHECK", "ALL_BUILD"] + # https://cmake.org/cmake/help/v3.21/prop_tgt/TYPE.html + SUPPORTED_TARGET_TYPES = ['STATIC_LIBRARY', 'SHARED_LIBRARY', 'MODULE_LIBRARY'] + + def __init__(self, conanfile): + self._conanfile = conanfile + + @property + def api_dir(self): + """ + :return: api directory /.cmake/api/v1/ + """ + return os.path.join(self._conanfile.build_folder, ".cmake", "api", "v1") + + @property + def query_dir(self): + """ + :return: api query sub-directory /.cmake/api/v1/query + """ + return os.path.join(self.api_dir, "query") + + @property + def reply_dir(self): + """ + :return: api reply sub-directory /.cmake/api/v1/reply + """ + return os.path.join(self.api_dir, "reply") + + def query(self, query): + """ + prepare the CMake File API query (the actual query will be done during the configure step) + :param query: type of the CMake File API query (e.g. CODEMODELV2) + :return: new query object + """ + if query == self.CODEMODELV2: + # implements codemodel-v2 query + # https://cmake.org/cmake/help/latest/manual/cmake-file-api.7.html#codemodel-version-2 + os.makedirs(self.query_dir) + save(os.path.join(self.query_dir, "codemodel-v2"), "") + return + raise NotImplementedError() + + def reply(self, reply): + """ + obtain the CMake File API reply (which should have been made during the configure step) + :param reply: type of the CMake File API reply (e.g. CODEMODELV2) + :return: new reply object + """ + if reply == self.CODEMODELV2: + return self.CodeModelReplyV2(self) + raise NotImplementedError() + + @property + def build_type(self): + """ + :return: active build type (configuration) + """ + return self._conanfile.settings.get_safe("build_type") + + class CodeModelReplyV2(object): + """ + implements codemodel-v2 reply + https://cmake.org/cmake/help/latest/manual/cmake-file-api.7.html#codemodel-version-2 + """ + def __init__(self, api): + + def loadjs(filename): + return json.loads(load(filename)) + + codemodels = os.listdir(api.reply_dir) + codemodels = [c for c in codemodels if fnmatch.fnmatch(c, "codemodel-v2-*.json")] + assert len(codemodels) == 1 + self._codemodel = loadjs(os.path.join(api.reply_dir, codemodels[0])) + + self._configurations = dict() + for configuration in self._codemodel['configurations']: + if configuration["name"] != api.build_type: + continue + + self._components = dict() + for target in configuration["targets"]: + if target['name'] in CMakeFileAPI.SKIP_TARGETS: + continue + self._components[target["name"]] = \ + loadjs(os.path.join(api.reply_dir, target['jsonFile'])) + + @classmethod + def _name_on_disk2lib(cls, name_on_disk): + """ + convert raw library file name into conan-friendly one + :param name_on_disk: raw library file name read from target.json + :return: conan-friendly library name, without suffix and prefix + """ + if name_on_disk.endswith('.lib'): + return name_on_disk[:-4] + elif name_on_disk.endswith('.dll'): + return name_on_disk[:-4] + elif name_on_disk.startswith('lib') and name_on_disk.endswith('.a'): + return name_on_disk[3:-2] + else: + # FIXME: This fails to parse executables + raise Exception("don't know how to convert %s" % name_on_disk) + + @classmethod + def _parse_dep_name(cls, name): + """ + :param name: extract dependency name from the id like 'decoder::@5310dfab9e417c587352' + :return: dependency name (part before ::@ token) + """ + return name.split("::@")[0] + + def to_conan_package(self): + """ + converts codemodel-v2 into conan_package.json object + :return: ConanPackage instance + """ + + conan_package = CppPackage() + + for name, target in self._components.items(): + if target['type'] not in CMakeFileAPI.SUPPORTED_TARGET_TYPES: + continue + component = conan_package.add_component(name) + # TODO: CMakeDeps has nothing to do here + component.names["CMakeDeps"] = name + component.libs = [self._name_on_disk2lib(target['nameOnDisk'])] + deps = target["dependencies"] if 'dependencies' in target else [] + deps = [self._parse_dep_name(d['id']) for d in deps] + component.requires = [d for d in deps if d not in CMakeFileAPI.SKIP_TARGETS] + + return conan_package diff --git a/conan/tools/files/__init__.py b/conan/tools/files/__init__.py index e26cf55064e..bb235068c7b 100644 --- a/conan/tools/files/__init__.py +++ b/conan/tools/files/__init__.py @@ -1,3 +1,4 @@ from conan.tools.files.files import load, save, mkdir, ftp_download, download, get, rename, \ load_build_json, save_build_json from conan.tools.files.patches import patch, apply_conandata_patches +from conan.tools.files.cpp_package import CppPackage diff --git a/conan/tools/files/cpp_package.py b/conan/tools/files/cpp_package.py new file mode 100644 index 00000000000..7b3e4ac79f9 --- /dev/null +++ b/conan/tools/files/cpp_package.py @@ -0,0 +1,73 @@ +import json +from conans.util.files import load, save + + +class CppPackage(object): + """ + models conan_package.json, serializable object + """ + + DEFAULT_FILENAME = "cpp_package.json" + + def __init__(self): + self.components = dict() + + @classmethod + def load(cls, filename=DEFAULT_FILENAME): + """ + loads package model into memory from the conan_package.json file + :param filename: path to the cpp_package.json + :return: a new instance of CppPackage + """ + def from_json(o): + if "components" in o: + return {n: CppPackage.Component(c) for n, c in o["components"].items()} + else: + return o + conan_package = CppPackage() + conan_package.components = json.loads(load(filename), object_hook=from_json) + return conan_package + + def save(self, filename=DEFAULT_FILENAME): + """ + saves package model from memory into the cpp_package.json file + :param filename: path to the cpp_package.json + :return: None + """ + text = json.dumps(self, default=lambda o: o.__dict__, indent=4) + save(filename, text) + + def package_info(self, conanfile): + """ + performs an automatically generated package_info method on conanfile, populating + conanfile.package_info with the information available inside cpp_package.json + :return: None + """ + for cname, component in self.components.items(): + conanfile.cpp_info.components[cname].libs = component.libs + conanfile.cpp_info.components[cname].requires = component.requires + for generator, gname in component.names.items(): + conanfile.cpp_info.components[cname].set_property("cmake_target_name", gname, generator) + + def add_component(self, name): + """ + appens a new CppPackage.Configuration into the internal dictionary + :param name: name of the given configuration (e.g. Debug) + :return: a new CppPackage.Configuration instance (empty) + """ + self.components[name] = CppPackage.Component() + return self.components[name] + + class Component(object): + """ + represents a single component (aka target) within package configuration + """ + def __init__(self, values=None): + if values: + self.names = values["names"] + self.requires = values["requires"] + self.libs = values["libs"] + else: + self.names = dict() + self.requires = [] + self.libs = [] diff --git a/conans/test/assets/cmake.py b/conans/test/assets/cmake.py index 2d1b15d435d..687c5cedaa7 100644 --- a/conans/test/assets/cmake.py +++ b/conans/test/assets/cmake.py @@ -5,7 +5,7 @@ def gen_cmakelists(language="CXX", verify=True, project="project", libname="mylibrary", libsources=None, appname="myapp", appsources=None, cmake_version="3.15", - install=False, find_package=None, libtype=""): + install=False, find_package=None, libtype="", deps=None, public_header=None): """ language: C, C++, C/C++ project: the project name @@ -21,17 +21,31 @@ def gen_cmakelists(language="CXX", verify=True, project="project", libname="myli cmake_minimum_required(VERSION {{cmake_version}}) project({{project}} {{language}}) + {% if find_package is mapping %} + {% for s, c in find_package.items() %} + find_package({{s}} COMPONENTS {{c}} ) + {% endfor %} + {% else %} {% for s in find_package %} find_package({{s}}) {% endfor %} + {% endif %} {% if libsources %} add_library({{libname}} {{libtype}} {% for s in libsources %} {{s}} {% endfor %}) {% endif %} {% if libsources and find_package %} + {% if find_package is mapping %} + target_link_libraries({{libname}} {% for s, c in find_package.items() %} {{s}}::{{c}} {% endfor %}) + {% else %} target_link_libraries({{libname}} {% for s in find_package %} {{s}}::{{s}} {% endfor %}) {% endif %} + {% endif %} + + {% if libsources and deps %} + target_link_libraries({{libname}} {% for s in deps %} {{s}} {% endfor %}) + {% endif %} {% if appsources %} add_executable({{appname}} {% for s in appsources %} {{s}} {% endfor %}) @@ -42,11 +56,37 @@ def gen_cmakelists(language="CXX", verify=True, project="project", libname="myli {% endif %} {% if appsources and not libsources and find_package %} + {% if find_package is mapping %} + target_link_libraries({{appname}} {% for s, c in find_package.items() %} {{s}}::{{c}} {% endfor %}) + {% else %} target_link_libraries({{appname}} {% for s in find_package %} {{s}}::{{s}} {% endfor %}) {% endif %} + {% endif %} + + {% if appsources and deps %} + target_link_libraries({{appname}} {% for s in deps %} {{s}} {% endfor %}) + {% endif %} + + {% if libsources and public_header %} + set_target_properties({{libname}} PROPERTIES PUBLIC_HEADER "{{public_header}}") + {% endif %} {% if install %} - install(TARGETS {{appname}} {{libname}} DESTINATION ".") + {% if appsources %} + install(TARGETS {{appname}} DESTINATION ".") + {% endif %} + {% if libsources %} + install(TARGETS {{libname}} DESTINATION "." + {% if public_header %} + PUBLIC_HEADER DESTINATION include + {% endif %} + RUNTIME DESTINATION bin + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + FRAMEWORK DESTINATION Frameworks + BUNDLE DESTINATION bin + ) + {% endif %} {% endif %} """) @@ -61,5 +101,7 @@ def gen_cmakelists(language="CXX", verify=True, project="project", libname="myli "cmake_version": cmake_version, "install": install, "find_package": find_package or [], - "libtype": libtype + "libtype": libtype, + "public_header": public_header, + "deps": deps, }) diff --git a/conans/test/functional/toolchains/cmake/test_file_api.py b/conans/test/functional/toolchains/cmake/test_file_api.py new file mode 100644 index 00000000000..cc77bf2db85 --- /dev/null +++ b/conans/test/functional/toolchains/cmake/test_file_api.py @@ -0,0 +1,145 @@ +import os +import textwrap + +import pytest + +from conans.test.assets.cmake import gen_cmakelists +from conans.test.assets.sources import gen_function_cpp, gen_function_h +from conans.test.utils.tools import TestClient + + +@pytest.mark.tool_cmake +def test_file_api(): + """ + simple library providing 3 targets: + - decoder + - encoder + - transcoder (requires decoder and encoder) + generates the following targets: + - triunfo::decoder + - triunfo::encoder + - triunfo::transcoder (depends on triunfo::decoder and triunfo::encoder) + consumer uses find_package(triunfo COMPONENTS ) + """ + client = TestClient() + + conanfile = textwrap.dedent(""" + from conans import ConanFile + from conan.tools.cmake import CMake, CMakeFileAPI + from conan.tools.files import CppPackage + + class Triunfo(ConanFile): + name = "triunfo" + version = "1.0" + settings = "os", "compiler", "arch", "build_type" + exports_sources = "*CMakeLists.txt", "*.cpp", "*.h" + generators = "CMakeToolchain" + + def build(self): + file_api = CMakeFileAPI(self) + file_api.query(CMakeFileAPI.CODEMODELV2) + cmake = CMake(self) + cmake.configure() + reply = file_api.reply(CMakeFileAPI.CODEMODELV2) + package = reply.to_conan_package() + package.save() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + self.copy(CppPackage.DEFAULT_FILENAME) + + def package_info(self): + cpp_package = CppPackage.load(CppPackage.DEFAULT_FILENAME) + cpp_package.package_info(self) + """) + + decoder_cpp = gen_function_cpp(name="decoder", includes=["decoder"]) + encoder_cpp = gen_function_cpp(name="encoder", includes=["encoder"]) + transcoder_cpp = gen_function_cpp(name="transcoder", calls=["decoder", "encoder"], + includes=["transcoder", "../decoder/decoder", "../encoder/encoder"]) + decoder_h = gen_function_h(name="decoder") + encoder_h = gen_function_h(name="encoder") + transcoder_h = gen_function_h(name="transcoder") + decoder_cmake = gen_cmakelists(libname="decoder", libsources=["decoder.cpp"], install=True, + public_header="decoder.h") + encoder_cmake = gen_cmakelists(libname="encoder", libsources=["encoder.cpp"], install=True, + public_header="encoder.h") + transcoder_cmake = gen_cmakelists(libname="transcoder", libsources=["transcoder.cpp"], install=True, + public_header="transcoder.h", deps=["decoder", "encoder"]) + common_cmake = textwrap.dedent(""" + cmake_minimum_required(VERSION 2.8) + project(triunfo) + add_subdirectory(decoder) + add_subdirectory(encoder) + add_subdirectory(transcoder) + """) + + client.save({"conanfile.py": conanfile, + os.path.join("decoder", "decoder.cpp"): decoder_cpp, + os.path.join("encoder", "encoder.cpp"): encoder_cpp, + os.path.join("transcoder", "transcoder.cpp"): transcoder_cpp, + os.path.join("decoder", "decoder.h"): decoder_h, + os.path.join("encoder", "encoder.h"): encoder_h, + os.path.join("transcoder", "transcoder.h"): transcoder_h, + os.path.join("decoder", "CMakeLists.txt"): decoder_cmake, + os.path.join("encoder", "CMakeLists.txt"): encoder_cmake, + os.path.join("transcoder", "CMakeLists.txt"): transcoder_cmake, + "CMakeLists.txt": common_cmake, + }) + client.run("create .") + + conanfile = textwrap.dedent(""" + from conans import ConanFile + from conan.tools.cmake import CMake, CMakeFileAPI + from conan.tools.files import CppPackage + + class Elogio(ConanFile): + name = "elogio" + version = "1.0" + requires = "triunfo/1.0" + settings = "os", "compiler", "arch", "build_type" + exports_sources = "*CMakeLists.txt", "*.cpp", "*.h" + generators = "CMakeDeps", "CMakeToolchain" + + def build(self): + file_api = CMakeFileAPI(self) + file_api.query(CMakeFileAPI.CODEMODELV2) + cmake = CMake(self) + cmake.configure() + reply = file_api.reply(CMakeFileAPI.CODEMODELV2) + package = reply.to_conan_package() + package.save() + cmake.build() + """) + + use_decoder_cpp = gen_function_cpp(name="main", includes=["decoder"], calls=["decoder"]) + use_encoder_cpp = gen_function_cpp(name="main", includes=["encoder"], calls=["encoder"]) + use_transcoder_cpp = gen_function_cpp(name="main", includes=["transcoder"], calls=["transcoder"]) + use_decoder_cmake = gen_cmakelists(appname="use_decoder", appsources=["use_decoder.cpp"], + find_package={"triunfo": "decoder"}) + use_encoder_cmake = gen_cmakelists(appname="use_encoder", appsources=["use_encoder.cpp"], + find_package={"triunfo": "encoder"}) + use_transcoder_cmake = gen_cmakelists(appname="use_transcoder", appsources=["use_transcoder.cpp"], + find_package={"triunfo": "transcoder"}) + common_cmake = textwrap.dedent(""" + cmake_minimum_required(VERSION 2.8) + project(elogio) + add_subdirectory(use_decoder) + add_subdirectory(use_encoder) + add_subdirectory(use_transcoder) + """) + + client.save({"conanfile.py": conanfile, + os.path.join("use_decoder", "use_decoder.cpp"): use_decoder_cpp, + os.path.join("use_encoder", "use_encoder.cpp"): use_encoder_cpp, + os.path.join("use_transcoder", "use_transcoder.cpp"): use_transcoder_cpp, + os.path.join("use_decoder", "CMakeLists.txt"): use_decoder_cmake, + os.path.join("use_encoder", "CMakeLists.txt"): use_encoder_cmake, + os.path.join("use_transcoder", "CMakeLists.txt"): use_transcoder_cmake, + "CMakeLists.txt": common_cmake, + }, clean_first=True) + + client.run("install .") + client.run("build .") diff --git a/conans/test/functional/toolchains/cmake/test_ninja.py b/conans/test/functional/toolchains/cmake/test_ninja.py index 2b4a88cd8d1..d757175830a 100644 --- a/conans/test/functional/toolchains/cmake/test_ninja.py +++ b/conans/test/functional/toolchains/cmake/test_ninja.py @@ -78,7 +78,7 @@ def test_locally_build_linux(build_type, shared, client): assert 'cmake -G "Ninja"' in client.out assert "main: {}!".format(build_type) in client.out client.run("install hello/1.0@ -g=deploy -if=mydeploy {}".format(settings)) - ldpath = os.path.join(client.current_folder, "mydeploy", "hello") + ldpath = os.path.join(client.current_folder, "mydeploy", "hello", "lib") client.run_command("LD_LIBRARY_PATH='{}' ./mydeploy/hello/myapp".format(ldpath)) check_exe_run(client.out, ["main", "hello"], "gcc", None, build_type, "x86_64", cppstd=None)