commit 15576b74ea2f7ec272a1b2722257ab8bf148a2f3 Author: Rogiel Sulzbach Date: Mon Oct 28 12:47:01 2019 -0300 Initial commit diff --git a/CMakeDependency.cmake b/CMakeDependency.cmake new file mode 100644 index 0000000..fc7df68 --- /dev/null +++ b/CMakeDependency.cmake @@ -0,0 +1,61 @@ +# CMake dependency: this is a little and lightweight helper tool that +# automatically downloads, extracts and configures dependencies based on a +# declaration on a .json file. +# +# To manually add a dependency, call `import_dependency( URL ). After +# this call, CMake will have downloaded and extracted the dependency source. The +# extracted files are located at `${_SOURCE_DIR}`. +# +# Also, it is possible to import a dependency JSON file by calling +# `import_dependencies_from_json()`. All dependencies are +# automatically configured as declared in the JSON file. CMake will also +# regenerate any configuration needed if the JSON is ever changed. + +set(CMDEP_GENERATOR_SCRIPT ${CMAKE_CURRENT_LIST_DIR}/DependencyGenerator.py) +set(CMDEP_DEPENDENCY_DECL_TMPL ${CMAKE_CURRENT_LIST_DIR}/DependencyDeclaration.cmake.in) + +set(CMDEP_ROOT_DIR + ${CMAKE_CURRENT_SOURCE_DIR} + CACHE PATH "A path that points to the location in which CMake dependency files should be downloaded and extracted to.") +set(CMDEP_ZIP_DIR + ${CMDEP_ROOT_DIR}/zips + CACHE PATH "A path that points to the location in which ZIP files should be downloaded to.") + +function(import_dependency name) + set(options) + set(oneValueArgs URL DOWNLOAD_NAME) + set(multiValueArgs) + cmake_parse_arguments(DEPENDENCY "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN} ) + cmake_parse_arguments(${name} "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN} ) + + set(DEPENDENCY_NAME ${name}) + + configure_file(${CMDEP_DEPENDENCY_DECL_TMPL} ${CMAKE_CURRENT_BINARY_DIR}/${name}.dep.cmake @ONLY) + + include(${CMAKE_CURRENT_BINARY_DIR}/${name}.dep.cmake) +endfunction() + +function(import_dependencies_from_json json) + set(json_dep_file ${json}) + set(cmake_dep_file ${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.deps.txt) + + find_package(Python3 COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND) + list(APPEND CMAKE_CONFIGURE_DEPENDS ${json_dep_file}) + + file(LOCK ${CMAKE_CURRENT_SOURCE_DIR} DIRECTORY GUARD FILE) + if(${json_dep_file} IS_NEWER_THAN ${cmake_dep_file} OR + ${CMDEP_GENERATOR_SCRIPT} IS_NEWER_THAN ${cmake_dep_file}) + execute_process( + COMMAND ${Python3_EXECUTABLE} ${CMDEP_GENERATOR_SCRIPT} + INPUT_FILE ${json_dep_file} + OUTPUT_FILE ${cmake_dep_file}) + endif() + file(LOCK ${CMAKE_CURRENT_SOURCE_DIR} DIRECTORY RELEASE) + else() + message(STATUS "Python interpreter not found. CMake dependencies will NOT update automatically.") + endif() + include(${cmake_dep_file}) +endfunction() + +list(APPEND CMAKE_CONFIGURE_DEPENDS ${CMDEP_GENERATOR_SCRIPT}) diff --git a/DependencyDeclaration.cmake.in b/DependencyDeclaration.cmake.in new file mode 100644 index 0000000..e6813a9 --- /dev/null +++ b/DependencyDeclaration.cmake.in @@ -0,0 +1,94 @@ +set(@DEPENDENCY_NAME@_URL + "@DEPENDENCY_URL@" + CACHE STRING "A path that points to a @DEPENDENCY_NAME@ URL to be downloaded. CMake will use this URL to download the file if needed.") +set(@DEPENDENCY_NAME@_DIR + ${CMDEP_ROOT_DIR}/@DEPENDENCY_NAME@ + CACHE PATH "A path that points to a @DEPENDENCY_NAME@ directory containing it's sources.") +set(@DEPENDENCY_NAME@_LOCK ${CMDEP_ROOT_DIR}) + +set(@DEPENDENCY_NAME@_SOURCE_DIR + ${@DEPENDENCY_NAME@_DIR} + CACHE PATH "A path that points to a @DEPENDENCY_NAME@ source directory. If manually set, no downloading or extraction will take place and this will be used instead.") +set(@DEPENDENCY_NAME@_BINARY_DIR ${CMAKE_CURRENT_BINARY_DIR}/@DEPENDENCY_NAME@) + +get_filename_component(@DEPENDENCY_NAME@_DOWNLOAD_NAME "${@DEPENDENCY_NAME@_URL}" NAME CACHE) +set(@DEPENDENCY_NAME@_ZIP + ${CMDEP_ZIP_DIR}/${@DEPENDENCY_NAME@_DOWNLOAD_NAME} + CACHE PATH "A path that points to a @DEPENDENCY_NAME@ zip file. If this file does not exists, it will be downloaded.") + +# To prevent other concurrent CMake instances from trying to download or extract +# the file, we lock the source directory. This will guarantee that only a single +# CMake instance downloads or extracts a file. +# +file(LOCK ${@DEPENDENCY_NAME@_LOCK} DIRECTORY GUARD FILE) + +if(NOT EXISTS ${@DEPENDENCY_NAME@_SOURCE_DIR}) + if(NOT EXISTS ${@DEPENDENCY_NAME@_ZIP}) + get_filename_component(@DEPENDENCY_NAME@_ZIP_DIRECTORY "${@DEPENDENCY_NAME@_ZIP}" DIRECTORY) + + if(NOT EXISTS ${@DEPENDENCY_NAME@_ZIP_DIRECTORY}) + file(MAKE_DIRECTORY ${@DEPENDENCY_NAME@_ZIP_DIRECTORY}) + endif() + + message(STATUS "@DEPENDENCY_NAME@: Downloading from ${@DEPENDENCY_NAME@_URL}") + + file(DOWNLOAD ${@DEPENDENCY_NAME@_URL} + ${@DEPENDENCY_NAME@_ZIP} + SHOW_PROGRESS + # no TIMEOUT + STATUS status + LOG log) + + list(GET status 0 status_code) + list(GET status 1 status_string) + + if(status_code EQUAL 0) + message(STATUS "@DEPENDENCY_NAME@: Download complete") + else() + message(FATAL_ERROR "@DEPENDENCY_NAME@: error: downloading from '${@DEPENDENCY_NAME@_URL}' failed with error code ${status_code} (${status_string}) -- ${log}") + endif() + endif() + + # Prepare a space for extracting: + # + set(tmp_dir "${CMAKE_CURRENT_BINARY_DIR}/@DEPENDENCY_NAME@-tmp") + file(MAKE_DIRECTORY "${tmp_dir}") + + # Extract it: + # + message(STATUS "@DEPENDENCY_NAME@: extracting...") + execute_process(COMMAND ${CMAKE_COMMAND} -E tar xfz ${@DEPENDENCY_NAME@_ZIP} + WORKING_DIRECTORY ${tmp_dir} + RESULT_VARIABLE rv) + if(NOT rv EQUAL 0) + message(STATUS "@DEPENDENCY_NAME@: extracting... [error clean up]") + file(REMOVE_RECURSE "${tmp_dir}") + message(FATAL_ERROR "@DEPENDENCY_NAME@: error: extract of '${@DEPENDENCY_NAME@_DOWNLOAD_NAME}' failed") + endif() + + # Analyze what came out of the tar file: + # + message(STATUS "@DEPENDENCY_NAME@: extracting... [analysis]") + file(GLOB contents "${tmp_dir}/*") + list(REMOVE_ITEM contents "${tmp_dir}/.DS_Store") + list(LENGTH contents n) + if(NOT n EQUAL 1 OR NOT IS_DIRECTORY "${contents}") + set(contents "${tmp_dir}") + endif() + + # Move "the one" directory to the final directory: + # + message(STATUS "@DEPENDENCY_NAME@: extracting... [rename]") + file(REMOVE_RECURSE ${@DEPENDENCY_NAME@_DIR}) + get_filename_component(contents ${contents} ABSOLUTE) + file(RENAME ${contents} ${@DEPENDENCY_NAME@_DIR}) + + # Clean up: + # + message(STATUS "@DEPENDENCY_NAME@: extracting... [clean up]") + file(REMOVE_RECURSE "${tmp_dir}") +endif() + +# We are now done. We can unlock the CMake directory and proceed. +# +file(LOCK ${@DEPENDENCY_NAME@_LOCK} DIRECTORY RELEASE) diff --git a/DependencyGenerator.py b/DependencyGenerator.py new file mode 100644 index 0000000..d5b73ee --- /dev/null +++ b/DependencyGenerator.py @@ -0,0 +1,115 @@ +import json, os, sys +from urllib.parse import urlparse + +tab = " "*4 +nl_indent = "\n"+tab*2 + + +def escape(s, n): + return ' ' * n + s + + +def generate_dependency_decl(name, keys): + max_key_length = 0 + for (key, value) in keys.items(): + max_key_length = max(max_key_length, len(key)) + + s = "import_dependency(" + name + nl_indent + (nl_indent).join( + map(lambda k: k[0] + " " + escape(k[1], max_key_length - len(k[0]) + 3), keys.items())) + ")" + return s + + +j = json.load(sys.stdin) + +for (name, dep_info) in j.items(): + sys.stderr.write('Updating dependency definitions for ' + name + '\n') + target_info = dep_info["target"] + + target_type = target_info["type"] + src_dir = "${" + name + "_SOURCE_DIR}/" + + dep_args = {} + if "git" in dep_info: + git_info = dep_info["git"] + git_repo = git_info["repository"] + git_tag = git_info["tag"] + + git_url_parsed = urlparse(git_repo) + if git_url_parsed.hostname == 'github.com': + parts = git_url_parsed.path.split("/") + user = parts[1] + repo = parts[2] + repo = repo[:-4] if repo.endswith('.git') else repo + dep_args["URL"] = "https://github.com/" + user + "/" + repo + "/archive/" + git_tag + ".zip" + dep_args["DOWNLOAD_NAME"] = repo + "-" + git_tag + ".zip" + else: + print('git is only supported for github repositories at the moment.', file=sys.stderr) + exit(-1) + elif "url" in dep_info: + dep_args["URL"] = " ".join(dep_info["url"]) + + if 'download_name' in dep_info: + dep_args["DOWNLOAD_NAME"] = dep_info['download_name'] + + print("# -- Dependency: " + name) + print(generate_dependency_decl(name, dep_args)) + + public_decl_type = "PUBLIC" + if target_type == "static": + if isinstance(target_info["srcs"], list): + srcs = (nl_indent).join(map(lambda x: src_dir + x,target_info["srcs"])) + else: + print("file(GLOB " + name + "_SRCS " + src_dir + target_info["srcs"] + ")") + srcs = "${" + name + "_SRCS}" + print("add_library("+name+" STATIC "+nl_indent+srcs+")") + + if target_type == "interface": + print("add_library("+name+" INTERFACE)") + public_decl_type = "INTERFACE" + + if target_type == "subdirectory": + if "cache" in target_info: + for (var_name, value) in target_info["cache"].items(): + if isinstance(value, bool): + print("set("+var_name+" "+("ON" if value else "OFF")+" CACHE INTERNAL \"\" FORCE)") + + print("add_subdirectory(${" + name + "_SOURCE_DIR} ${" + name + "_BINARY_DIR})") + + if target_type == "cmake": + print("include(${PROJECT_SOURCE_DIR}/" + target_info["file"]+")\n") + continue + + includes = [] + if "public_includes" in target_info: + includes += list(map(lambda x: public_decl_type + " " + src_dir + x, target_info["public_includes"])) + if "private_includes" in target_info: + includes += list(map(lambda x: "PRIVATE " + src_dir + x, target_info["private_includes"])) + if len(includes) != 0: + print("target_include_directories("+name+" "+nl_indent+ + nl_indent.join(includes) + +")") + + defines = [] + if "public_defines" in target_info: + defines += list(map(lambda x: public_decl_type + " " + x, target_info["public_defines"])) + if "private_defines" in target_info: + defines += list(map(lambda x: "PRIVATE " + x, target_info["private_defines"])) + if len(defines) != 0: + print("target_compile_definitions("+name+" "+nl_indent+ + nl_indent.join(defines) + +")") + + if "links" in target_info: + print("target_link_libraries("+name+" "+nl_indent+ + nl_indent.join(map(lambda x: public_decl_type + ' ' + x, target_info["links"])) + +")") + + if "aliases" in target_info: + for (tgt, src) in target_info["aliases"].items(): + print("add_library("+tgt+" ALIAS "+src+")") + + if "extra_cmake" in target_info: + print() + print("include(${PROJECT_SOURCE_DIR}/" + target_info["extra_cmake"]+")") + print() + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9b6f61 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# CMakeDependency + +A little and lightweight CMake helper tool that automatically downloads, extracts and configures dependencies based on a declaration on a .json file. + +## Getting started + +First you need to declare a dependency in your `dependencies.json` file: + +```json +{ + "JSON": { + "url": [ + "https://github.com/nlohmann/json/releases/download/v3.7.0/include.zip" + ], + "download_name": "json-3.7.0.zip", + "target": { + "type": "interface", + "public_includes": [ + "." + ] + } + } +} +``` + +In your CMakeLists.txt all you need is to include the module and import all dependencies from your JSON file: + +```cmake +# Include the CMakeDependency module +include(CMakeDependency/CMakeDependency.cmake) + +# Import dependencies from JSON +import_dependencies_from_json(${PROJECT_SOURCE_DIR}/dependencies.json) +``` + +You are done! `JSON` is now available as a target in CMake! + +```cmake +target_link_libraries(MyTarget PUBLIC JSON) +``` + +Note that CMakeDependency will create a file called `CMakeLists.deps.txt` in `CMAKE_CURRENT_SOURCE_DIR`. This file contains the preprocessed JSON file that is included by CMake. You can commit this file into your repository if you don't want to depend on Python 3 when compiling. + +## Dependencies + +This CMake module requires **CMake 3.15** and up. **Python3** is required if you want to automatically generate the CMake dependencies from the JSON file. + +## Declaring dependencies manually in CMake + +You don't have to use the JSON file if you don't want to. You can import dependencies directly in CMake by calling `import_dependency`: + +```cmake +import_dependency(JSON + URL https://github.com/nlohmann/json/releases/download/v3.7.0/include.zip + DOWNLOAD_NAME json-3.7.0.zip) +``` + +Note that `DOWNLOAD_NAME` is optional. If not given, CMakeDependency will resolve a name based on the given `URL`. + +In this mode, no target is created but the ZIP file is downloaded and extracted. The extracted contents are in `JSON_SOURCE_DIR`. If this library is a CMake library, you can simply call `add_subdirectory` and be done with it. + +```cmake +add_subdirectory(${JSON_SOURCE_DIR} ${JSON_BINARY_DIR}) +``` + +or if the dependency is **NOT** a CMake project, you can manually create a target: + +```cmake +add_library(JSON INTERFACE) +target_include_directories(JSON INTERFACE ${JSON_SOURCE_DIR}) +``` + +## Overriding a dependency + +If you don't want to duplicate your dependencies across multiple projects you can override the dependency source dir by setting `JSON_DIR` cache variable. You can do so when invoking CMake: + +```shell script +cmake -DJSON_DIR="/my/json/lib" [...] +``` + +or from within CMake itself, before calling `import_dependency` or `import_dependencies_from_json`: + +```cmake +set(JSON_DIR "/my/json/lib" CACHE PATH "" FORCE) +``` + +Node that when setting `JSON_DIR`, if the dependency is not downloaded it will be downloaded and imported to the given directory. If this is not something you want, for example, you want to use a system provided dependency, you can override the directory by setting `JSON_SOURCE_DIR`. + +Additionally, you can override just the zip file by setting `JSON_ZIP`. + +## `dependencies.json` by example + +### Boost + +This example downloads the Boost source zip and configures it by building the `system` library. + +```json +{ + "Boost": { + "url": [ + "https://dl.bintray.com/boostorg/release/1.71.0/source/boost_1_71_0.zip" + ], + "target": { + "type": "static", + "srcs": [ + "libs/system/src/error_code.cpp" + ], + "public_includes": [ + "." + ], + "public_defines": { + "BOOST_ALL_NO_LIB": "1", + "BOOST_ASIO_NO_TYPEID": "1" + }, + "links": [ + "$<$:bcrypt.dll>" + ] + } + } +} +``` + +### [Naios/continuable](https://github.com/Naios/continuable) + +This will [Naios/continuable](https://github.com/Naios/continuable) and it's dependency [Naios/function2](https://github.com/Naios/function2) create both targets as a CMake interface library and link `Function2` into `Continuable`. As a consumer, you can simply link against `Continuabe` and CMake will add `Function2` transitively fot you. + +```json +{ + "Continuable": { + "git": { + "repository": "https://github.com/Naios/continuable.git", + "tag": "cacb84371a5e486567b4aed244ce742ea6509d7d" + }, + "target": { + "type": "interface", + "public_includes": [ + "include" + ], + "links": [ + "Function2" + ] + } + }, + "Function2": { + "git": { + "repository": "https://github.com/Naios/function2.git", + "tag": "7cd95374b0f1c941892bfc40f0ebb6564d33fdb9" + }, + "target": { + "type": "interface", + "public_includes": [ + "include" + ] + } + } +} +``` + +### [Rogiel/PacketBuffer](https://github.com/Rogiel/PacketBuffer) + +This will download [Rogiel/PacketBuffer](https://github.com/Rogiel/PacketBuffer) and import it by using CMake's `add_subdirectory` command. + +```json +{ + "PacketBuffer": { + "git": { + "repository": "https://github.com/Rogiel/PacketBuffer.git", + "tag": "37e76659eaf4ed7d4407d1ccc5e31dee5454ef2c" + }, + "target": { + "type": "subdirectory" + } + } +} +``` + +### Poco + +This example will download the Poco library and include `Poco.cmake` for further configuration. You can use `Poco_SOURCE_DIR` and `Poco_BINARY_DIR` if needed to configure your target. + +This allows very flexible build configuration for dependencies. + +```json +{ + "Poco": { + "url": [ + "https://pocoproject.org/releases/poco-1.9.4/poco-1.9.4.zip" + ], + "target": { + "type": "cmake", + "file": "Poco.cmake" + } + } +} +``` + +## Advanced features + +### Global cache variables + - **`CMDEP_ROOT_DIR`**: A path that points to the location in which CMake dependency files should be downloaded and extracted to. Defaults to `${CMAKE_CURRENT_SOURCE_DIR}`. + - **`CMDEP_ZIP_DIR`**: A path that points to the location in which ZIP files should be downloaded to. Defaults to `${CMDEP_ROOT_DIR}/zips`. + +### Dependency cache variables + - **`_URL`**: A path that points to a URL to be downloaded. CMake will use this URL to download the file if needed. Defaults to the value given in `import_dependency`. + - **`_DIR`**: A path that points to a directory containing the dependency sources. Defaults to `${CMDEP_ROOT_DIR}/`. + - **`_SOURCE_DIR`**: A path that points to a source directory. If manually set, no downloading or extraction will take place and this will be used instead. Defaults to `${_DIR}`. + - **`_ZIP`**: A path that points to a zip file. If this file does not exists, it will be downloaded. You can use this to override the download and point to an already existing ZIP file. Defaults to `${CMDEP_ZIP_DIR}/.zip`. + +## Concurrency with CMake + +Some IDEs, such as CLion, use multiple CMake invocations for each configuration. This could cause problems when downloading dependencies to a common location. To avoid such problems, `CMakeDependency` uses CMake's advisory locking mechanism to prevent multiple CMake instances from downloading the same file. + +This lock is acquired in the `CMDEP_ROOT_DIR` directory. Beware that CMake will create a `cmake.lock` file in this directory and you should exclude it from your source control if needed. + +## TODO + + - Improve documentation on `dependencies.json` syntax.