From 2750dd7f3ab32ad16616f9083b6bbcfaf184c946 Mon Sep 17 00:00:00 2001 From: khodowany Date: Fri, 4 Sep 2026 19:30:11 -0500 Subject: [PATCH] Initial public khodo snapshot --- .clang-format | 1 + .clangd | 9 + .dockerignore | 14 + .gitea/workflows/ci.yml | 105 ++ .gitignore | 25 + CMakeLists.txt | 71 ++ LICENSE | 21 + README.md | 69 ++ cmake/modulus_config.cmake.in | 6 + cmake/modulus_third_party.cmake | 70 ++ conanfile.py | 171 ++++ include/modulus.hpp | 9 + include/modulus/config.hpp | 32 + include/modulus/error.hpp | 40 + include/modulus/json_config.hpp | 72 ++ include/modulus/log.hpp | 79 ++ include/modulus/result.hpp | 29 + include/modulus/source_location.hpp | 55 ++ include/modulus/types.hpp | 25 + modulus.json | 16 + modulus/__init__.py | 2 + modulus/cli.py | 1077 +++++++++++++++++++++ modulus/config.py | 639 ++++++++++++ modulus/export.py | 88 ++ pyrightconfig.json | 14 + schemas/modulus.schema.json | 311 ++++++ src/config.cpp | 234 +++++ src/error.cpp | 95 ++ src/json_config.cpp | 191 ++++ src/log.cpp | 149 +++ tests/CMakeLists.txt | 26 + tests/test.cpp | 271 ++++++ tests/test.hpp | 4 + tests/test_cli.py | 961 ++++++++++++++++++ tests/test_export.py | 352 +++++++ tools/build.py | 5 + tools/conan/profiles/arm_64_gcc12_debug | 14 + tools/conan/profiles/arm_64_gcc12_release | 14 + tools/conan/profiles/raylib_drm_kms | 4 + tools/conan/profiles/x86_64_gcc12_debug | 14 + tools/conan/profiles/x86_64_gcc12_release | 14 + tools/configure.py | 5 + tools/docker/Dockerfile | 48 + tools/editor/clang-format | 1 + tools/editor/clangd.yaml | 9 + tools/editor/pyrightconfig.json | 14 + tools/lsp.py | 5 + tools/modulus_tool.py | 20 + tools/package.py | 5 + tools/run.py | 5 + tools/test.py | 5 + 51 files changed, 5515 insertions(+) create mode 100644 .clang-format create mode 100644 .clangd create mode 100644 .dockerignore create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmake/modulus_config.cmake.in create mode 100644 cmake/modulus_third_party.cmake create mode 100644 conanfile.py create mode 100644 include/modulus.hpp create mode 100644 include/modulus/config.hpp create mode 100644 include/modulus/error.hpp create mode 100644 include/modulus/json_config.hpp create mode 100644 include/modulus/log.hpp create mode 100644 include/modulus/result.hpp create mode 100644 include/modulus/source_location.hpp create mode 100644 include/modulus/types.hpp create mode 100644 modulus.json create mode 100644 modulus/__init__.py create mode 100644 modulus/cli.py create mode 100644 modulus/config.py create mode 100644 modulus/export.py create mode 100644 pyrightconfig.json create mode 100644 schemas/modulus.schema.json create mode 100644 src/config.cpp create mode 100644 src/error.cpp create mode 100644 src/json_config.cpp create mode 100644 src/log.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test.cpp create mode 100644 tests/test.hpp create mode 100644 tests/test_cli.py create mode 100644 tests/test_export.py create mode 100755 tools/build.py create mode 100755 tools/conan/profiles/arm_64_gcc12_debug create mode 100755 tools/conan/profiles/arm_64_gcc12_release create mode 100644 tools/conan/profiles/raylib_drm_kms create mode 100755 tools/conan/profiles/x86_64_gcc12_debug create mode 100755 tools/conan/profiles/x86_64_gcc12_release create mode 100755 tools/configure.py create mode 100755 tools/docker/Dockerfile create mode 100644 tools/editor/clang-format create mode 100644 tools/editor/clangd.yaml create mode 100644 tools/editor/pyrightconfig.json create mode 100755 tools/lsp.py create mode 100644 tools/modulus_tool.py create mode 100755 tools/package.py create mode 100755 tools/run.py create mode 100755 tools/test.py diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..e384528 --- /dev/null +++ b/.clang-format @@ -0,0 +1 @@ +DisableFormat: true diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..7703ad6 --- /dev/null +++ b/.clangd @@ -0,0 +1,9 @@ +CompileFlags: + CompilationDatabase: build/build_x86_debug + Add: + - -Wall + - -Wextra + +Diagnostics: + ClangTidy: + Remove: ['*'] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5323e0a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.tmp +build +**/__pycache__ +package/CMakeCache.txt +package/CMakeFiles +package/metadata +package/.ninja_* +package/*.json +package/*.cmake +package/*.ninja +package/*.sh +package/*-smoke +package/modulus-smoke diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..83b6978 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,105 @@ +name: publish + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + actions: write + +env: + CONAN_HOME: build/build_common/conan_cache + MODULUS_DIRECT: "1" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: | + if [ -r /etc/os-release ]; then + . /etc/os-release + fi + if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + printf '%s\n' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME}-security" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/ubuntu-arm64.sources + elif [ -f /etc/apt/sources.list ]; then + sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list + sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list + printf '%s\n' \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \ + > /etc/apt/sources.list.d/ubuntu-arm64.list + fi + fi + dpkg --add-architecture arm64 + apt-get update + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + g++-12 \ + g++-aarch64-linux-gnu \ + gcc-12 \ + gcc-aarch64-linux-gnu \ + git \ + libdrm-dev \ + libdrm-dev:arm64 \ + libegl-dev \ + libegl-dev:arm64 \ + libgbm-dev \ + libgbm-dev:arm64 \ + libgles-dev \ + libgles-dev:arm64 \ + libglvnd-dev \ + libglvnd-dev:arm64 \ + ninja-build \ + pkg-config \ + python3 \ + python3-venv + python3 -m venv build/build_common/ci_python + build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 + echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH" + - name: Restore Conan cache + uses: actions/cache@v4 + with: + path: build/build_common/conan_cache + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', 'tools/conan/profiles/*') }} + restore-keys: conan-${{ runner.os }}- + - name: Configure native release + run: ./tools/configure.py --target x86_64_gcc12_release + - name: Build native release + run: ./tools/build.py --target x86_64_gcc12_release + - name: Test native release + run: ./tools/test.py --target x86_64_gcc12_release + - name: Create and upload packages + if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} + env: + CONAN_USER: ${{ secrets.CONAN_USER }} + CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }} + MODULUS_CONAN_UPLOAD: "1" + MODULUS_ALLOW_PACKAGE_REPLACE: "1" + run: ./tools/package.py --all --replace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a81655b --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo +*.pyd +.tmp/ +build/ +CMakeUserPresets.json + +package/CMakeCache.txt +package/CMakeFiles/ +package/metadata/ +package/.ninja_* +package/*.json +package/*.cmake +package/*.ninja +package/*.sh +package/*-smoke +package/Boost* +package/boost* +package/modulus-* +package/modulusTargets.cmake +package/cmakedeps_macros.cmake +package/conan_toolchain.cmake +package/conandeps_legacy.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4cfa69f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,71 @@ +cmake_minimum_required(VERSION 3.25) + +project(modulus VERSION 0.1.0 LANGUAGES CXX) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) +include(CTest) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(cmake/modulus_third_party.cmake) + +add_library(modulus + src/config.cpp + src/error.cpp + src/json_config.cpp + src/log.cpp +) +target_include_directories(modulus PUBLIC + $ + $ +) +target_compile_features(modulus PUBLIC cxx_std_23) +target_link_libraries(modulus PUBLIC + Boost::boost + Boost::json +) +add_library(modulus::modulus ALIAS modulus) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + +install(TARGETS modulus + EXPORT modulus-targets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT modulus-targets + NAMESPACE modulus:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus +) + +install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/modulus_third_party.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus +) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/modulus_config.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/modulus-config.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus +) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/modulus-config-version.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/modulus-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/modulus-config-version.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/modulus +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f93603 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Khodo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..067aa61 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Modulus + +Modulus is the shared C++23 foundation library and build tooling used by the khodo Product Vamp repositories. It provides small runtime utilities, Conan profiles, CMake package integration, editor templates, typed LSP compile-database setup, and bridge targets for the external libraries needed by Product Vamp. + +The public package intentionally keeps a small dependency surface: + +- Boost.JSON +- OpenSSL +- SQLite +- raylib +- miniaudio 0.11.22 + +## Build + +Use the checked-in tools directly inside CI or a compatible build environment: + +```sh +MODULUS_DIRECT=1 ./tools/configure.py --target x86_64_gcc12_release +./tools/build.py --target x86_64_gcc12_release +./tools/test.py --target x86_64_gcc12_release +``` + +Outside that environment, downstream repos use `python3 modulus.py ` to fetch the public `modulus/latest` package from the khodo Conan registry and export the tool wrappers into `build/build_tools/`. + +`build` automatically configures a target when CMake metadata is missing. Run `configure` explicitly when you want to force a reconfigure or refresh dependencies. + +Refresh clangd compile databases with: + +```sh +./tools/lsp.py +``` + +Product repos can add typed extra CMake compile databases under `tooling.lsp.compile_databases`; this is intended for cases like Product Vamp's Pico firmware tree. + +## Package + +Create Conan packages with: + +```sh +./tools/package.py --all +``` + +Publishing requires credentials only for the upload step: + +```sh +CONAN_USER= \ +CONAN_TOKEN= \ +MODULUS_CONAN_UPLOAD=1 \ +MODULUS_ALLOW_PACKAGE_REPLACE=1 \ +./tools/package.py --all --replace +``` + +The default public Conan remote is: + +```text +https://git.thyme-labs.com/api/packages/khodo/conan +``` + +Generic application artifacts default to: + +```text +https://git.thyme-labs.com/api/packages/khodo/generic +``` + +Configure/build/test do not require package credentials for public packages. + +## License + +Modulus is released under the MIT License. diff --git a/cmake/modulus_config.cmake.in b/cmake/modulus_config.cmake.in new file mode 100644 index 0000000..88066a3 --- /dev/null +++ b/cmake/modulus_config.cmake.in @@ -0,0 +1,6 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +include("${CMAKE_CURRENT_LIST_DIR}/modulus_third_party.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/modulus-targets.cmake") diff --git a/cmake/modulus_third_party.cmake b/cmake/modulus_third_party.cmake new file mode 100644 index 0000000..13314ca --- /dev/null +++ b/cmake/modulus_third_party.cmake @@ -0,0 +1,70 @@ +include(CMakeFindDependencyMacro) + +find_dependency(Boost CONFIG REQUIRED COMPONENTS json) +find_dependency(OpenSSL CONFIG REQUIRED) +find_dependency(SQLite3 CONFIG REQUIRED) +find_dependency(raylib CONFIG REQUIRED) +find_dependency(miniaudio CONFIG REQUIRED) + +function(modulus_define_bridge target) + if(NOT TARGET ${target}) + add_library(${target} INTERFACE IMPORTED) + endif() +endfunction() + +modulus_define_bridge(modulus::boost_json) +set_target_properties(modulus::boost_json PROPERTIES + INTERFACE_LINK_LIBRARIES "Boost::boost;Boost::json" +) + +modulus_define_bridge(modulus::openssl) +set_target_properties(modulus::openssl PROPERTIES + INTERFACE_LINK_LIBRARIES "OpenSSL::SSL;OpenSSL::Crypto" +) + +modulus_define_bridge(modulus::sqlite) +set_target_properties(modulus::sqlite PROPERTIES + INTERFACE_LINK_LIBRARIES "SQLite::SQLite3" +) + +set(_modulus_raylib_target "") +if(TARGET raylib) + set(_modulus_raylib_target raylib) +elseif(TARGET raylib::raylib) + set(_modulus_raylib_target raylib::raylib) +endif() + +if(NOT _modulus_raylib_target) + message(FATAL_ERROR "raylib package did not define a supported CMake target") +endif() + +set(_modulus_raylib_link_libraries ${_modulus_raylib_target}) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + list(APPEND _modulus_raylib_link_libraries EGL GLESv2 drm gbm atomic dl) +endif() + +modulus_define_bridge(modulus::raylib) +set_target_properties(modulus::raylib PROPERTIES + INTERFACE_LINK_LIBRARIES "${_modulus_raylib_link_libraries}" +) + +unset(_modulus_raylib_link_libraries) +unset(_modulus_raylib_target) + +set(_modulus_miniaudio_target "") +if(TARGET miniaudio::miniaudio) + set(_modulus_miniaudio_target miniaudio::miniaudio) +elseif(TARGET miniaudio) + set(_modulus_miniaudio_target miniaudio) +endif() + +if(NOT _modulus_miniaudio_target) + message(FATAL_ERROR "miniaudio package did not define a supported CMake target") +endif() + +modulus_define_bridge(modulus::miniaudio) +set_target_properties(modulus::miniaudio PROPERTIES + INTERFACE_LINK_LIBRARIES "${_modulus_miniaudio_target}" +) + +unset(_modulus_miniaudio_target) diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 0000000..b22c86c --- /dev/null +++ b/conanfile.py @@ -0,0 +1,171 @@ +import os +import pathlib + +import conan +import conan.tools.build +import conan.tools.cmake +import conan.tools.files + +import modulus.config + +config = modulus.config + +class modulus_recipe(conan.ConanFile): + name = "modulus" + version = "latest" + description = "Shared Conan/CMake tooling and C++23 utility framework" + license = "MIT" + package_type = "library" + settings = "os", "arch", "compiler", "build_type" + options = { + "shared": [ + True, + False, + ], + "fPIC": [ + True, + False, + ], + "with_tests": [ + True, + False, + ], + } + default_options = { + "shared": False, + "fPIC": True, + "with_tests": False, + "raylib/*:camera_system": False, + "raylib/*:custom_frame_control": True, + "raylib/*:customize_build": True, + "raylib/*:gestures_system": True, + "raylib/*:module_raudio": False, + "raylib/*:opengl_version": "ES-2.0", + "raylib/*:rprand_generator": False, + "glfw/*:with_wayland": False, + "glfw/*:with_x11": False, + } + exports = "modulus/*.py", "modulus.json" + exports_sources = ( + "CMakeLists.txt", + "cmake/*.cmake", + "cmake/*.in", + "include/*.hpp", + "include/modulus/*.hpp", + "src/*.cpp", + "tests/CMakeLists.txt", + "tests/*.cpp", + "tests/*.hpp", + "schemas/*.json", + "tools/*.py", + "tools/conan/profiles/*", + "tools/docker/*", + "tools/editor/*", + "README.md", + ) + + def config_options(self): + settings = self.settings + options = self.options + if settings.os == "Windows": + del options.fPIC + + def configure(self): + options = self.options + if options.shared: + del options.fPIC + + def requirements(self): + config = modulus.config.load_recipe_config(self) + self.requires( + modulus.config.boost_ref(config), + transitive_headers=True, + transitive_libs=True, + options=modulus.config.boost_requirement_options(config), + ) + self.requires( + "openssl/4.0.1", + transitive_headers=True, + transitive_libs=True, + ) + self.requires( + "sqlite3/3.53.2", + transitive_headers=True, + transitive_libs=True, + ) + self.requires( + "raylib/6.0", + transitive_headers=True, + transitive_libs=True, + ) + self.requires( + "miniaudio/0.11.22", + transitive_headers=True, + transitive_libs=True, + ) + + def generate(self): + options = self.options + deps = conan.tools.cmake.CMakeDeps(self) + deps.generate() + toolchain = conan.tools.cmake.CMakeToolchain(self) + if hasattr(toolchain, "user_presets_path"): + toolchain.user_presets_path = False + toolchain.variables["BUILD_SHARED_LIBS"] = bool(options.shared) + toolchain.variables["BUILD_TESTING"] = bool(options.with_tests) + toolchain.generate() + + def build(self): + options = self.options + cmake = conan.tools.cmake.CMake(self) + cmake.configure() + cmake.build() + if options.with_tests and conan.tools.build.can_run(self): + cmake.test(target="check") + + def package_id(self): + self.info.options.rm_safe("with_tests") + + def package(self): + cmake = conan.tools.cmake.CMake(self) + cmake.install() + package = pathlib.Path(self.package_folder) + conan.tools.files.copy(self, "*.py", src=pathlib.Path(self.recipe_folder) / "modulus", dst=package / "modulus", keep_path=True) + conan.tools.files.copy(self, "*", src=pathlib.Path(self.source_folder) / "tools", dst=package / "tools", keep_path=True) + + def package_info(self): + cpp_info = self.cpp_info + cpp_info.set_property("cmake_file_name", "modulus") + cpp_info.set_property("cmake_target_name", "modulus::all") + cpp_info.set_property( + "cmake_build_modules", + [os.path.join("lib", "cmake", "modulus", "modulus_third_party.cmake")], + ) + components = cpp_info.components + components["modulus"].set_property("cmake_target_name", "modulus::modulus") + components["modulus"].libs = ["modulus"] + components["modulus"].requires = ["boost::json"] + + components["boost_json"].set_property("cmake_target_name", "modulus::boost_json") + components["boost_json"].requires = ["boost::json"] + + components["openssl"].set_property("cmake_target_name", "modulus::openssl") + components["openssl"].requires = ["openssl::ssl", "openssl::crypto"] + + components["sqlite"].set_property("cmake_target_name", "modulus::sqlite") + components["sqlite"].requires = ["sqlite3::sqlite"] + + components["raylib"].set_property("cmake_target_name", "modulus::raylib") + components["raylib"].requires = ["raylib::raylib"] + if self.settings.os == "Linux": + components["raylib"].system_libs = [ + "EGL", + "GLESv2", + "drm", + "gbm", + "atomic", + "dl", + ] + + components["miniaudio"].set_property("cmake_target_name", "modulus::miniaudio") + components["miniaudio"].requires = ["miniaudio::miniaudio"] diff --git a/include/modulus.hpp b/include/modulus.hpp new file mode 100644 index 0000000..2695d80 --- /dev/null +++ b/include/modulus.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include diff --git a/include/modulus/config.hpp b/include/modulus/config.hpp new file mode 100644 index 0000000..9bc749c --- /dev/null +++ b/include/modulus/config.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include + +#include +#include + +namespace modulus { + +struct logging_config { + modulus::log_level level{modulus::log_level::INFO}; +}; + +struct config { + modulus::logging_config logging{}; + modulus::error_config errors{}; +}; + +auto parse_logging_config(boost::json::value const& value) -> std::expected; +auto parse_error_config(boost::json::value const& value) -> std::expected; +auto parse_config(boost::json::value const& value) -> std::expected; +auto load_config(std::string const& path) -> std::expected; +auto to_json(modulus::logging_config const& config) -> boost::json::value; +auto to_json(modulus::error_config const& config) -> boost::json::value; +auto to_json(modulus::config const& config) -> boost::json::value; +auto configure_logger(modulus::logger& logger, modulus::logging_config const& config) -> void; +auto configure(modulus::config const& config) -> void; + +} diff --git a/include/modulus/error.hpp b/include/modulus/error.hpp new file mode 100644 index 0000000..3fe945b --- /dev/null +++ b/include/modulus/error.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace modulus { + +struct error_config { + std::bool_t include_category{true}; + std::bool_t include_code{true}; + std::bool_t include_message{true}; + std::bool_t include_location{true}; +}; + +class error { +public: + error() = default; + error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())); + auto category() const noexcept -> std::string_view; + auto code() const noexcept -> std::int32_t; + auto message() const noexcept -> std::string_view; + auto location() const noexcept -> const modulus::source_location&; + auto describe(modulus::error_config const& config) const -> std::string; + auto describe() const -> std::string; +private: + std::string category_{"generic"}; + std::int32_t code_{}; + std::string message_{}; + modulus::source_location location_{modulus::source_location::unknown()}; +}; + +auto make_error(std::string category, std::int32_t code, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; +auto set_default_error_config(modulus::error_config config) -> void; +auto default_error_config() -> modulus::error_config; + +} diff --git a/include/modulus/json_config.hpp b/include/modulus/json_config.hpp new file mode 100644 index 0000000..1d16ded --- /dev/null +++ b/include/modulus/json_config.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace modulus::json_config { + +struct options { + std::string_view category{"modulus.config"}; + std::int32_t invalid_argument_code{1}; + std::int32_t not_found_code{2}; +}; + +template +struct enum_mapping { + std::string_view name{}; + ENUM value{}; +}; + +auto invalid(modulus::json_config::options config, std::string message) -> modulus::error; +auto not_found(modulus::json_config::options config, std::string message) -> modulus::error; +auto load_value(std::string const& path, modulus::json_config::options config = {}) -> std::expected; +auto require_object(boost::json::value const& value, std::string_view context, modulus::json_config::options config = {}) -> std::expected; +auto reject_unknown_fields(boost::json::object const& object, std::initializer_list allowed, std::string_view context, modulus::json_config::options config = {}) -> std::expected; +auto optional_bool_field(boost::json::object const& object, std::string_view name, std::bool_t fallback, modulus::json_config::options config = {}) -> std::expected; +auto optional_string_field(boost::json::object const& object, std::string_view name, std::string fallback, modulus::json_config::options config = {}) -> std::expected; +auto required_string_field(boost::json::object const& object, std::string_view name, std::string_view context, modulus::json_config::options config = {}) -> std::expected; +auto optional_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected; +auto positive_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config = {}) -> std::expected; +auto optional_int32_field(boost::json::object const& object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config = {}) -> std::expected; +auto positive_milliseconds_field(boost::json::object const& object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value = static_cast(std::numeric_limits::max()), modulus::json_config::options config = {}) -> std::expected; + +template +auto parse_enum(std::string_view value, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected; + +template +auto optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected; + +} + +template +auto modulus::json_config::parse_enum(std::string_view value, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config) -> std::expected { + for (auto mapping : mappings) { + if (value == mapping.name) { + return mapping.value; + } + } + return std::unexpected(modulus::json_config::invalid(config, std::string{message})); +} + +template +auto modulus::json_config::optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list> mappings, std::string_view message, modulus::json_config::options config) -> std::expected { + auto found = object.find(name); + if (found == object.end()) { + return fallback; + } + if (!found->value().is_string()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be a string")); + } + return modulus::json_config::parse_enum(std::string_view{found->value().as_string()}, mappings, message, config); +} diff --git a/include/modulus/log.hpp b/include/modulus/log.hpp new file mode 100644 index 0000000..9d949cc --- /dev/null +++ b/include/modulus/log.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace modulus { + +enum class log_level : std::int32_t { + TRACE, + DEBUG, + INFO, + WARN, + ERROR, + CRITICAL, + OFF, +}; + +auto to_string(modulus::log_level level) noexcept -> std::string_view; + +struct log_record { + modulus::log_level level{modulus::log_level::INFO}; + std::string message{}; + modulus::source_location location{modulus::source_location::unknown()}; + std::chrono::system_clock::time_point timestamp{std::chrono::system_clock::now()}; + std::thread::id thread_id{std::this_thread::get_id()}; +}; + +class logger { +public: + logger(); + auto set_level(modulus::log_level level) -> void; + auto level() const -> modulus::log_level; + auto enabled(modulus::log_level level) const -> std::bool_t; + auto set_sink(std::function sink) -> void; + auto write(modulus::log_record record) const -> void; + auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) const -> void; +private: + mutable std::mutex mutex_; + mutable std::mutex sink_mutex_; + modulus::log_level level_{modulus::log_level::INFO}; + std::function sink_{}; +}; + +auto default_logger() -> modulus::logger&; +auto set_default_log_level(modulus::log_level level) -> void; +auto is_log_enabled(modulus::log_level level) -> std::bool_t; +auto log(modulus::log_level level, std::string message, modulus::source_location location = modulus::source_location( __builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log_trace(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log_debug(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log_info(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log_warn(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log_error(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; +auto log_critical(std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; + +template +auto log_lazy(modulus::log_level level, MESSAGE&& message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> void; + +} + +template +auto modulus::log_lazy(modulus::log_level level, MESSAGE&& message, modulus::source_location location) -> void { + assert(static_cast(level) >= static_cast(modulus::log_level::TRACE)); + assert(static_cast(level) <= static_cast(modulus::log_level::OFF)); + assert(location.file_name() != nullptr); + assert(location.function_name() != nullptr); + if (modulus::is_log_enabled(level)) { + modulus::log(level, std::string(std::forward(message)()), location); + } +} diff --git a/include/modulus/result.hpp b/include/modulus/result.hpp new file mode 100644 index 0000000..a91ffdc --- /dev/null +++ b/include/modulus/result.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace modulus { + +template +auto success(VALUE&& value) -> std::expected, modulus::error>; + +template +auto fail(modulus::error value) -> std::expected; + +} + +template +auto modulus::success(VALUE&& value) -> std::expected, modulus::error> { + return std::forward(value); +} + +template +auto modulus::fail(modulus::error value) -> std::expected { + assert(!value.category().empty()); + return std::unexpected(std::move(value)); +} diff --git a/include/modulus/source_location.hpp b/include/modulus/source_location.hpp new file mode 100644 index 0000000..0cf25f0 --- /dev/null +++ b/include/modulus/source_location.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include + +namespace modulus { + +class source_location { +public: + constexpr source_location() noexcept = default; + constexpr source_location(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept; + static consteval auto current(std::char_t const* file = __builtin_FILE(), std::uint_least32_t line = __builtin_LINE(), std::char_t const* function = __builtin_FUNCTION()) noexcept -> modulus::source_location; + static constexpr auto unknown() noexcept -> modulus::source_location; + constexpr auto file_name() const noexcept -> std::char_t const*; + constexpr auto line() const noexcept -> std::uint_least32_t; + constexpr auto function_name() const noexcept -> std::char_t const*; +private: + std::char_t const* file_{""}; + std::uint_least32_t line_{}; + std::char_t const* function_{""}; +}; + +} + +constexpr modulus::source_location::source_location(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept : file_(file), line_(line), function_(function) { + assert(file != nullptr); + assert(function != nullptr); +} + +consteval auto modulus::source_location::current(std::char_t const* file, std::uint_least32_t line, std::char_t const* function) noexcept -> modulus::source_location { + assert(file != nullptr); + assert(function != nullptr); + return modulus::source_location(file, line, function); +} + +constexpr auto modulus::source_location::unknown() noexcept -> modulus::source_location { + return modulus::source_location(); +} + +constexpr auto modulus::source_location::file_name() const noexcept -> std::char_t const* { + assert(file_ != nullptr); + return file_; +} + +constexpr auto modulus::source_location::line() const noexcept -> std::uint_least32_t { + assert(file_ != nullptr); + return line_; +} + +constexpr auto modulus::source_location::function_name() const noexcept -> std::char_t const* { + assert(function_ != nullptr); + return function_; +} diff --git a/include/modulus/types.hpp b/include/modulus/types.hpp new file mode 100644 index 0000000..0738fd3 --- /dev/null +++ b/include/modulus/types.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +#if defined(__has_include) +# if __has_include() +# include +# endif +#endif + +namespace std { + +using bool_t = decltype(true); +using char_t = decltype('x'); +using uchar_t = std::uint8_t; + +#if !defined(__STDCPP_FLOAT32_T__) +using float32_t = decltype(0.0F); +#endif + +#if !defined(__STDCPP_FLOAT64_T__) +using float64_t = decltype(0.0); +#endif + +} diff --git a/modulus.json b/modulus.json new file mode 100644 index 0000000..4b56452 --- /dev/null +++ b/modulus.json @@ -0,0 +1,16 @@ +{ + "package": { + "name": "modulus" + }, + "boost": { + "components": [ + "json" + ] + }, + "tooling": { + "remote": { + "name": "khodo", + "url": "https://git.thyme-labs.com/api/packages/khodo/conan" + } + } +} diff --git a/modulus/__init__.py b/modulus/__init__.py new file mode 100644 index 0000000..b5757f8 --- /dev/null +++ b/modulus/__init__.py @@ -0,0 +1,2 @@ + +__all__ = [] diff --git a/modulus/cli.py b/modulus/cli.py new file mode 100644 index 0000000..4e9786e --- /dev/null +++ b/modulus/cli.py @@ -0,0 +1,1077 @@ +import argparse +import hashlib +import os +import pathlib +import shlex +import shutil +import subprocess +import sys +import tarfile +import urllib.parse + +import modulus.config + +build_root = pathlib.Path("build") +common_dir = build_root / "build_common" +tools_dir = build_root / "build_tools" + +class build_target: + def __init__(self, name, profile, build_dir, build_type, can_run, cmake_definitions): + self.name = name + self.profile = profile + self.build_dir = build_dir + self.build_type = build_type + self.can_run = can_run + self.cmake_definitions = cmake_definitions + +def main(command): + repo_root = find_repo_root(pathlib.Path.cwd()) + config = modulus.config.load_repo_config(repo_root) + context = build_context(repo_root, config) + if command == "configure": + return configure(context, sys.argv[1:]) + if command == "build": + return build(context, sys.argv[1:]) + if command == "test": + return test(context, sys.argv[1:]) + if command == "package": + return package(context, sys.argv[1:]) + if command == "run": + return product_run(context, sys.argv[1:]) + if command == "lsp": + return lsp(context, sys.argv[1:]) + raise SystemExit(f"unknown modulus command: {command}") + +def find_repo_root(start): + current = start.resolve() + for candidate in (current, *current.parents): + if (candidate / "modulus.json").is_file(): + return candidate + raise SystemExit("could not find modulus.json from current directory") + +class build_context: + def __init__(self, repo_root, config): + self.repo_root = repo_root + self.config = config + self.package = config["package"] + self.artifact = self.package["artifact"] + self.tooling = config["tooling"] + self.run_settings = self.tooling["run"] + self.lsp_settings = self.tooling.get("lsp", {"enabled": False, "compile_databases": []}) + self.build_root = repo_root / build_root + self.common_dir = repo_root / common_dir + self.tools_rel = tools_dir if (repo_root / tools_dir).exists() else pathlib.Path("tools") + self.tools_dir = repo_root / self.tools_rel + self.conan_cache_dir = self.common_dir / "conan_cache" + self.default_target_name = str(self.tooling["default_target"]) + self.targets = self._targets() + self.default_target = self.targets[self.default_target_name] + docker = self.tooling["docker"] + self.docker_image = str(docker["image"]) + self.dockerfile = self.tools_dir / "docker" / "Dockerfile" + self.docker_workdir = str(repo_root) + self.docker_marker = pathlib.Path(str(docker["marker"])) + self.upload_env = str(self.tooling["upload_env"]) + self.package_upload_env = str(self.artifact["upload_env"]) + + def _targets(self): + result = {} + targets = self.tooling["targets"] + for name, target in targets.items(): + result[name] = build_target( + name=name, + profile=self.repo_root / str(target["profile"]), + build_dir=self.repo_root / str(target["build_dir"]), + build_type=str(target.get("build_type", "Release")), + can_run=bool(target.get("can_run", True)), + cmake_definitions=dict(target.get("cmake_definitions", {})), + ) + return result + + def target(self, name): + try: + return self.targets[name] + except KeyError: + valid = ", ".join(sorted(self.targets)) + raise SystemExit(f"unknown target '{name}'. Valid targets: {valid}") from None + + def selected_targets(self, names, all_targets): + if all_targets: + return list(self.targets.values()) + if not names: + return [self.default_target] + return [self.target(name) for name in names] + +def in_docker(context): + return direct_mode() or context.docker_marker.exists() + +def env_flag(name): + return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} + +def direct_mode(): + return env_flag("MODULUS_DIRECT") + +def in_container(): + return pathlib.Path("/.dockerenv").exists() + +def current_container_name(): + return os.environ.get("HOSTNAME", "").strip() + +def docker_platform_args(): + platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() + if not platform: + return [] + return [ + "--platform", + platform, + ] + +def conan_remote(context): + return str(context.tooling["remote"]["name"]) + +def conan_remote_url(context): + return context.tooling["remote"]["url"] + +def conan_user(): + return os.environ.get("CONAN_USER") + +def conan_token(): + return os.environ.get("CONAN_TOKEN") + +def conan_credentials(): + return conan_user(), conan_token() + +def has_conan_credentials(): + user, token = conan_credentials() + if bool(user) != bool(token): + print("CONAN_USER and CONAN_TOKEN must either both be set or both be unset", file=sys.stderr) + return None + return bool(user) + +def require_conan_credentials(operation): + user, token = conan_credentials() + if bool(user) != bool(token): + print("CONAN_USER and CONAN_TOKEN must either both be set or both be unset", file=sys.stderr) + return None + if not user or not token: + print(f"CONAN_USER and CONAN_TOKEN are required to {operation}", file=sys.stderr) + return None + return user, token + +def run(context, command, cwd=None, env_overrides=None, env_unset=None, display_command=None,): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + env = os.environ.copy() + env["CONAN_HOME"] = str(context.conan_cache_dir) + for name in env_unset or (): + env.pop(name, None) + if env_overrides: + env.update(env_overrides) + return subprocess.run([str(part) for part in command], cwd=cwd or context.repo_root, check=False, env=env).returncode + +def run_capture(context, command, cwd=None, env_overrides=None, display_command=None): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + env = os.environ.copy() + env["CONAN_HOME"] = str(context.conan_cache_dir) + if env_overrides: + env.update(env_overrides) + completed = subprocess.run( + [str(part) for part in command], + cwd=cwd or context.repo_root, + check=False, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + if completed.stdout: + print(completed.stdout, end="") + if completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + return completed + +def configure_conan_remote(context, authenticate=False): + remote = conan_remote(context) + if has_conan_credentials() is None: + return 1 + status = run( + context, + [ + "conan", + "remote", + "add", + "-f", + remote, + conan_remote_url(context), + ], + ) + if status != 0: + return status + if not authenticate: + return 0 + credentials = require_conan_credentials("upload Conan packages") + if credentials is None: + return 1 + user, token = credentials + return run( + context, + [ + "conan", + "remote", + "login", + remote, + user, + "-p", + token, + ], + display_command=[ + "conan", + "remote", + "login", + remote, + user, + "-p", + "", + ], + ) + +def docker_passthrough_env(context): + return ( + "CI", + "CONAN_HOME", + "CONAN_TOKEN", + "CONAN_USER", + "MODULUS_DIRECT", + "MODULUS_ALLOW_PACKAGE_REPLACE", + "PACKAGE_REGISTRY_TOKEN", + "PACKAGE_REGISTRY_USER", + context.upload_env, + context.package_upload_env, + ) + +def docker_platform_arch(): + platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() + if not platform: + return None + parts = platform.split("/") + if len(parts) < 2: + return None + return { + "amd64": "amd64", + "arm64": "arm64", + "arm64v8": "arm64", + }.get(parts[1]) + +def docker_image_available(context): + command = [ + "docker", + "image", + "inspect", + context.docker_image, + ] + expected_arch = docker_platform_arch() + if expected_arch is not None: + command.extend(["--format", "{{.Architecture}}"]) + completed = subprocess.run( + command, + check=False, + stdout=subprocess.PIPE if expected_arch is not None else subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + if completed.returncode != 0: + return False + if expected_arch is None: + return True + return completed.stdout.strip() == expected_arch + +def build_image(context): + return run( + context, + [ + "docker", + "build", + *docker_platform_args(), + "--file", + context.dockerfile, + "--tag", + context.docker_image, + ".", + ], + ) + +def ensure_build_image(context): + if docker_image_available(context): + return 0 + return build_image(context) + +def docker_run(context, command, docker_args=None, env_overrides=None): + status = ensure_build_image(context) + if status != 0: + return status + resolved_env_overrides = dict(env_overrides or {}) + docker_command = [ + "docker", + "run", + "--rm", + "--interactive", + *docker_platform_args(), + "--user", + f"{os.getuid()}:{os.getgid()}", + "--env", + "HOME=/tmp", + ] + if docker_args: + docker_command.extend(docker_args) + for name in docker_passthrough_env(context): + if name in os.environ: + docker_command.extend(["--env", name]) + if resolved_env_overrides: + for name in resolved_env_overrides: + docker_command.extend(["--env", name]) + if in_container(): + container_name = current_container_name() + if not container_name: + raise SystemExit("HOSTNAME is required to share the CI job workspace with the build container") + docker_command.extend([ + "--volumes-from", + container_name, + ]) + else: + docker_command.extend([ + "--volume", + f"{context.repo_root}:{context.docker_workdir}", + ]) + docker_command.extend([ + "--workdir", + context.docker_workdir, + context.docker_image, + *command, + ]) + return run(context, docker_command, env_overrides=resolved_env_overrides) + +def docker_publish_args(context): + args = [] + for port in context.run_settings.get("ports", []): + args.extend(["--publish", port]) + return args + +def clear_cmake_cache(target): + cache = target.build_dir / "CMakeCache.txt" + cmake_files = target.build_dir / "CMakeFiles" + if cache.exists(): + cache.unlink() + if cmake_files.exists(): + shutil.rmtree(cmake_files) + +def clear_conan_deps(target): + conan_deps = target.build_dir / "conan" + if conan_deps.exists(): + shutil.rmtree(conan_deps) + +def cmake_cache_values(path): + result = {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return result + for line in lines: + if not line or line.startswith(("//", "#")) or "=" not in line: + continue + key, value = line.split("=", 1) + name = key.split(":", 1)[0] + result[name] = value + return result + +def cmake_bool_value(value): + text = str(value).upper() + if text.endswith("-NOTFOUND"): + return False + if text in {"1", "ON", "TRUE", "YES", "Y"}: + return True + if text in {"0", "OFF", "FALSE", "NO", "N", "IGNORE", "NOTFOUND", ""}: + return False + return None + +def cmake_cache_value_matches(configured, expected): + if isinstance(expected, bool): + return cmake_bool_value(configured) is expected + return configured == cmake_value(expected) + +def target_cmake_definitions_match(target): + definitions = target.cmake_definitions + if not definitions: + return True + values = cmake_cache_values(target.build_dir / "CMakeCache.txt") + for name, expected in definitions.items(): + configured = values.get(name) + if configured is None or not cmake_cache_value_matches(configured, expected): + return False + return True + +def target_is_configured(target): + return ( + (target.build_dir / "CMakeCache.txt").is_file() + and (target.build_dir / "build.ninja").is_file() + and target_cmake_definitions_match(target) + ) + +def package_name(context): + return context.package["name"] + +def dependency_build_args(context): + return ["--build=missing"] + +def configure_one(context, target, update, no_remote=False): + clear_conan_deps(target) + command = [ + "conan", + "install", + ".", + f"--output-folder={target.build_dir / 'conan'}", + f"--profile:host={target.profile}", + f"--profile:build={context.default_target.profile}", + *dependency_build_args(context), + "-o", + "&:with_tests=True" if target.can_run else "&:with_tests=False", + ] + if update: + command.append("--update") + if no_remote: + command.append("--no-remote") + status = run(context, command) + if status != 0: + return status + return run( + context, + [ + "cmake", + "-S", + ".", + "-B", + target.build_dir, + "-G", + "Ninja", + f"-DCMAKE_TOOLCHAIN_FILE={target.build_dir / 'conan' / 'conan_toolchain.cmake'}", + f"-DCMAKE_BUILD_TYPE={target.build_type}", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + f"-DBUILD_TESTING={'ON' if target.can_run else 'OFF'}", + f"-DCMAKE_PREFIX_PATH={target.build_dir / 'conan'}", + *cmake_definition_args(target.cmake_definitions), + ], + ) + +def configure(context, argv): + if not in_docker(context): + return docker_run( + context, + [ + f"./{context.tools_rel}/configure.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Configure {context.package['name']} for Conan/CMake targets.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to configure. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument("--all", action="store_true", help="Configure all supported targets.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes. Requires all dependencies to be available locally or buildable from local recipes.") + args = parser.parse_args(argv) + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if not args.no_remote: + status = configure_conan_remote(context) + if status != 0: + return status + update = args.update + for target in context.selected_targets(args.target, args.all): + target.build_dir.mkdir(parents=True, exist_ok=True) + clear_cmake_cache(target) + status = configure_one(context, target, update, no_remote=args.no_remote) + if status != 0: + return status + return 0 + +def build(context, argv): + if not in_docker(context): + return docker_run( + context, + [ + f"./{context.tools_rel}/build.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Build {context.package['name']} for configured targets.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to build. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument( + "--cmake-target", + action="append", + default=[], + help="CMake target to build. May be passed more than once.", + ) + parser.add_argument("--all", action="store_true", help="Build all supported targets.") + args = parser.parse_args(argv) + selected_targets = context.selected_targets(args.target, args.all) + if any(not target_is_configured(target) for target in selected_targets): + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + status = configure_conan_remote(context) + if status != 0: + return status + for target in selected_targets: + if not target_is_configured(target): + target.build_dir.mkdir(parents=True, exist_ok=True) + status = configure_one(context, target, update=False) + if status != 0: + return status + cmake_targets = list(args.cmake_target) + if not cmake_targets: + cmake_targets = [None] + for cmake_target in cmake_targets: + command = [ + "cmake", + "--build", + target.build_dir, + ] + if cmake_target is not None: + command.extend(["--target", cmake_target]) + command.extend(["--parallel", "2"]) + status = run(context, command) + if status != 0: + return status + return 0 + +def test(context, argv): + if not in_docker(context): + return docker_run( + context, + [ + f"./{context.tools_rel}/test.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Run {context.package['name']} tests for configured targets.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to test. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument("--all", action="store_true", help="Test all supported targets.") + args = parser.parse_args(argv) + test_name = context.config["tests"]["name"] + for target in context.selected_targets(args.target, args.all): + if not target.can_run: + print(f"Skipping tests for {target.name}: target binaries cannot run in this container.") + continue + status = run( + context, + [ + "ctest", + "--test-dir", + target.build_dir, + "-R", + f"^{test_name}$", + "--output-on-failure", + ], + ) + if status != 0: + return status + return 0 + +def lsp_compile_databases(context): + if not context.lsp_settings["enabled"]: + return [] + return list(context.lsp_settings["compile_databases"]) + +def lsp_database_map(context): + return { + str(database["name"]): database + for database in lsp_compile_databases(context) + } + +def clear_lsp_cmake_cache(build_dir): + cache = build_dir / "CMakeCache.txt" + cmake_files = build_dir / "CMakeFiles" + if cache.exists(): + cache.unlink() + if cmake_files.exists(): + shutil.rmtree(cmake_files) + +def configure_lsp_database(context, database): + build_dir = context.repo_root / str(database["build_dir"]) + source_dir = context.repo_root / str(database.get("source_dir", ".")) + build_dir.mkdir(parents=True, exist_ok=True) + clear_lsp_cmake_cache(build_dir) + return run( + context, + [ + "cmake", + "-S", + source_dir, + "-B", + build_dir, + "-G", + str(database.get("generator", "Ninja")), + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + *cmake_definition_args(database.get("cmake_definitions", {})), + ], + env_overrides=dict(database.get("env", {})), + env_unset=list(database.get("unset_env", [])), + ) + +def lsp(context, argv): + if not in_docker(context): + return docker_run( + context, + [ + f"./{context.tools_rel}/lsp.py", + *argv, + ], + ) + database_map = lsp_database_map(context) + parser = argparse.ArgumentParser(description=f"Refresh {context.package['name']} compile databases for clangd.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Configured build target compile database to refresh. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument( + "--database", + action="append", + default=[], + choices=sorted(database_map), + help="Extra LSP compile database to refresh. May be passed more than once. Defaults to all configured LSP databases.", + ) + parser.add_argument("--no-build-targets", action="store_true", help="Skip Modulus build-target compile databases.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes for Modulus build-target compile databases.") + args = parser.parse_args(argv) + + if not args.no_build_targets: + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if not args.no_remote: + status = configure_conan_remote(context) + if status != 0: + return status + for target in context.selected_targets(args.target, all_targets=False): + target.build_dir.mkdir(parents=True, exist_ok=True) + clear_cmake_cache(target) + status = configure_one(context, target, args.update, no_remote=args.no_remote) + if status != 0: + return status + + database_names = args.database or [str(database["name"]) for database in lsp_compile_databases(context)] + for name in database_names: + status = configure_lsp_database(context, database_map[name]) + if status != 0: + return status + return 0 + +def product_run(context, argv): + if not context.run_settings["enabled"]: + print(f"{package_name(context)} does not enable the Modulus run command", file=sys.stderr) + return 1 + if not in_docker(context): + return docker_run( + context, + [ + f"./{context.tools_rel}/run.py", + *argv, + ], + docker_args=docker_publish_args(context), + ) + parser = argparse.ArgumentParser(description=f"Run {context.package['name']} from a configured build target.") + parser.add_argument( + "--target", + default=str(context.run_settings["target"]), + choices=sorted(context.targets), + help="Configured target to run. Defaults to tooling.run.target.", + ) + parser.add_argument( + "args", + nargs=argparse.REMAINDER, + help="Arguments passed to the product executable. A leading -- separator is optional.", + ) + args = parser.parse_args(argv) + target = context.target(args.target) + if not target.can_run: + print(f"Target {target.name} is not runnable in this environment", file=sys.stderr) + return 1 + executable = pathlib.Path(str(context.run_settings["executable"])) + if not executable.is_absolute(): + executable = target.build_dir / executable + if not executable.is_file(): + print(f"Executable was not found: {executable}. Run python3 modulus.py build --target {target.name} first.", file=sys.stderr) + return 1 + command_args = list(context.run_settings["args"]) + extra_args = list(args.args) + if extra_args and extra_args[0] == "--": + extra_args = extra_args[1:] + command_args.extend(extra_args) + return run( + context, + [ + executable, + *command_args, + ], + env_overrides=dict(context.run_settings["env"]), + ) + +def create_package(context, target, update, no_remote=False): + command = [ + "conan", + "create", + ".", + f"--profile:host={target.profile}", + f"--profile:build={context.default_target.profile}", + f"--build={package_name(context)}/*", + *dependency_build_args(context), + "-o", + "&:with_tests=False", + ] + if update: + command.append("--update") + if no_remote: + command.append("--no-remote") + return run(context, command) + +def upload_all_packages(context, remote, force=False): + command = [ + "conan", + "upload", + f"{package_name(context)}/latest:*", + "-r", + remote, + "--confirm", + ] + if force: + command.append("--force") + return run(context, command) + +def allow_package_replace(): + return env_flag("MODULUS_ALLOW_PACKAGE_REPLACE") + +def upload_packages(context, replace=False): + if replace and not allow_package_replace(): + print("--replace requires MODULUS_ALLOW_PACKAGE_REPLACE=1", file=sys.stderr) + return 1 + remote = conan_remote(context) + status = configure_conan_remote(context, authenticate=True) + if status != 0: + return status + status = upload_all_packages(context, remote, force=replace) + if status != 0 and not replace: + print(f"upload failed for {package_name(context)}/latest. Use --replace with MODULUS_ALLOW_PACKAGE_REPLACE=1 to intentionally replace an existing version.", file=sys.stderr) + return status + +def should_upload(context): + return env_flag(context.upload_env) + +def artifact_packaging_enabled(context): + return bool(context.artifact["enabled"]) + +def should_upload_artifacts(context): + return env_flag(context.package_upload_env) + +def package_registry_credentials(): + user = os.environ.get("PACKAGE_REGISTRY_USER") + token = os.environ.get("PACKAGE_REGISTRY_TOKEN") + if bool(user) != bool(token): + print("PACKAGE_REGISTRY_USER and PACKAGE_REGISTRY_TOKEN must either both be set or both be unset", file=sys.stderr) + return None + if not user or not token: + print("PACKAGE_REGISTRY_USER and PACKAGE_REGISTRY_TOKEN are required to upload package artifacts", file=sys.stderr) + return None + return user, token + +def option_value(value): + if isinstance(value, bool): + return "True" if value else "False" + return str(value) + +def cmake_value(value): + if isinstance(value, bool): + return "ON" if value else "OFF" + return str(value) + +def cmake_definition_args(definitions): + result = [] + for name, value in definitions.items(): + result.append(f"-D{name}={cmake_value(value)}") + return result + +def package_artifact_conan_option_args(context): + result = [] + for name, value in context.artifact["conan_options"].items(): + result.extend(["-o", f"&:{name}={option_value(value)}"]) + return result + +def package_artifact_cmake_definition_args(context): + return cmake_definition_args(context.artifact["cmake_definitions"]) + +def package_artifact_targets(context, names, all_targets): + if all_targets or names: + return context.selected_targets(names, all_targets) + configured = context.artifact["targets"] + if configured: + return [context.target(name) for name in configured] + return [context.default_target] + +def git_version(context): + completed = run_capture( + context, + [ + "git", + "rev-parse", + "--short=12", + "HEAD", + ], + ) + if completed.returncode != 0: + return "dev" + version = completed.stdout.strip() + return version if version else "dev" + +def package_artifact_build_root(context, target): + return context.common_dir / "artifacts" / target.name + +def package_artifact_dist_dir(context): + return context.build_root / "packages" + +def package_artifact_install_prefix(context, stage_root): + install_prefix = pathlib.Path(str(context.artifact["install_prefix"])) + if str(install_prefix) == ".": + return stage_root + return stage_root / install_prefix + +def create_archive(source_dir, archive): + with tarfile.open(archive, "w:gz") as output: + for path in sorted(source_dir.rglob("*")): + output.add(path, arcname=path.relative_to(source_dir), recursive=False) + +def write_checksum(archive): + digest = hashlib.sha256() + with archive.open("rb") as input_file: + for chunk in iter(lambda: input_file.read(1024 * 1024), b""): + digest.update(chunk) + checksum = archive.with_name(archive.name + ".sha256") + checksum.write_text(f"{digest.hexdigest()} {archive.name}\n", encoding="utf-8") + return checksum + +def create_artifact_package(context, target, version, update, no_remote=False): + root = package_artifact_build_root(context, target) + build_dir = root / "build" + stage_root = root / "stage" + conan_dir = build_dir / "conan" + if root.exists(): + shutil.rmtree(root) + build_dir.mkdir(parents=True) + stage_root.mkdir(parents=True) + install_prefix = package_artifact_install_prefix(context, stage_root) + install_prefix.mkdir(parents=True, exist_ok=True) + conan_command = [ + "conan", + "install", + ".", + f"--output-folder={conan_dir}", + f"--profile:host={target.profile}", + f"--profile:build={context.default_target.profile}", + *dependency_build_args(context), + "-o", + "&:with_tests=False", + *package_artifact_conan_option_args(context), + ] + if update: + conan_command.append("--update") + if no_remote: + conan_command.append("--no-remote") + status = run(context, conan_command) + if status != 0: + return status, [] + status = run( + context, + [ + "cmake", + "-S", + ".", + "-B", + build_dir, + "-G", + "Ninja", + f"-DCMAKE_TOOLCHAIN_FILE={conan_dir / 'conan_toolchain.cmake'}", + f"-DCMAKE_BUILD_TYPE={target.build_type}", + "-DBUILD_TESTING=OFF", + f"-DCMAKE_PREFIX_PATH={conan_dir}", + f"-DCMAKE_INSTALL_PREFIX={install_prefix}", + *package_artifact_cmake_definition_args(context), + ], + ) + if status != 0: + return status, [] + status = run( + context, + [ + "cmake", + "--build", + build_dir, + "--parallel", + "2", + ], + ) + if status != 0: + return status, [] + status = run( + context, + [ + "cmake", + "--install", + build_dir, + ], + ) + if status != 0: + return status, [] + dist_dir = package_artifact_dist_dir(context) + dist_dir.mkdir(parents=True, exist_ok=True) + archive = dist_dir / f"{package_name(context)}-{version}-{target.name}.tar.gz" + if archive.exists(): + archive.unlink() + create_archive(stage_root, archive) + checksum = write_checksum(archive) + print(f"Created {archive}") + print(f"Created {checksum}") + return 0, [archive, checksum] + +def generic_package_upload_url(context, version, path): + base = str(context.artifact["registry_url"]).rstrip("/") + package = urllib.parse.quote(package_name(context), safe="") + version = urllib.parse.quote(version, safe="") + filename = urllib.parse.quote(path.name, safe="") + return f"{base}/{package}/{version}/{filename}" + +def upload_generic_artifact(context, version, path): + credentials = package_registry_credentials() + if credentials is None: + return 1 + user, token = credentials + url = generic_package_upload_url(context, version, path) + return run( + context, + [ + "curl", + "--fail", + "--silent", + "--show-error", + "--user", + f"{user}:{token}", + "--upload-file", + path, + url, + ], + display_command=[ + "curl", + "--fail", + "--silent", + "--show-error", + "--user", + f"{user}:", + "--upload-file", + path, + url, + ], + ) + +def package_artifacts(context, argv): + if not in_docker(context): + return docker_run( + context, + [ + f"./{context.tools_rel}/package.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Create {context.package['name']} application tarballs.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to package. May be passed more than once. Defaults to package.artifact.targets.", + ) + parser.add_argument("--all", action="store_true", help="Package all supported targets.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes or upload package artifacts.") + parser.add_argument("--version", default=None, help="Artifact version. Defaults to the current git commit.") + args = parser.parse_args(argv) + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if should_upload_artifacts(context) and not args.no_remote and package_registry_credentials() is None: + return 1 + if not args.no_remote: + status = configure_conan_remote(context) + if status != 0: + return status + version = args.version or git_version(context) + artifacts = [] + for target in package_artifact_targets(context, args.target, args.all): + status, created = create_artifact_package(context, target, version, args.update, no_remote=args.no_remote) + if status != 0: + return status + artifacts.extend(created) + if should_upload_artifacts(context) and not args.no_remote: + for artifact in artifacts: + status = upload_generic_artifact(context, version, artifact) + if status != 0: + return status + return 0 + +def package(context, argv): + if artifact_packaging_enabled(context): + return package_artifacts(context, argv) + if not in_docker(context): + return docker_run( + context, + [ + f"./{context.tools_rel}/package.py", + *argv, + ], + ) + parser = argparse.ArgumentParser(description=f"Create {context.package['name']} Conan packages.") + parser.add_argument( + "--target", + action="append", + default=[], + choices=sorted(context.targets), + help="Target to package. May be passed more than once. Defaults to the default target.", + ) + parser.add_argument("--all", action="store_true", help="Package all supported targets.") + parser.add_argument("--update", action="store_true", help="Check Conan remotes for newer dependency revisions.") + parser.add_argument("--no-remote", action="store_true", help="Do not query Conan remotes. Requires all dependencies to be available locally or buildable from local recipes.") + parser.add_argument("--replace", action="store_true", help="Delete the remote package version before upload. Requires MODULUS_ALLOW_PACKAGE_REPLACE=1.") + args = parser.parse_args(argv) + context.conan_cache_dir.mkdir(parents=True, exist_ok=True) + if not args.no_remote: + status = configure_conan_remote(context) + if status != 0: + return status + update = args.update + for target in context.selected_targets(args.target, args.all): + status = create_package(context, target, update, no_remote=args.no_remote) + if status != 0: + return status + if should_upload(context) and not args.no_remote: + return upload_packages(context, replace=args.replace) + return 0 diff --git a/modulus/config.py b/modulus/config.py new file mode 100644 index 0000000..81377ab --- /dev/null +++ b/modulus/config.py @@ -0,0 +1,639 @@ +import copy +import json +import os +import re +import pathlib + +class config_error(RuntimeError): + pass + +boost_default_options = { + "boost/*:without_atomic": True, + "boost/*:without_charconv": True, + "boost/*:without_chrono": True, + "boost/*:without_cobalt": True, + "boost/*:without_container": True, + "boost/*:without_context": True, + "boost/*:without_contract": True, + "boost/*:without_coroutine": True, + "boost/*:without_date_time": True, + "boost/*:without_exception": True, + "boost/*:without_fiber": True, + "boost/*:without_filesystem": True, + "boost/*:without_graph": True, + "boost/*:without_graph_parallel": True, + "boost/*:without_iostreams": True, + "boost/*:without_json": True, + "boost/*:without_locale": True, + "boost/*:without_log": True, + "boost/*:without_math": True, + "boost/*:without_mpi": True, + "boost/*:without_nowide": True, + "boost/*:without_process": True, + "boost/*:without_program_options": True, + "boost/*:without_python": True, + "boost/*:without_random": True, + "boost/*:without_regex": True, + "boost/*:without_serialization": True, + "boost/*:without_stacktrace": True, + "boost/*:without_test": True, + "boost/*:without_thread": True, + "boost/*:without_timer": True, + "boost/*:without_type_erasure": True, + "boost/*:without_url": True, + "boost/*:without_wave": True, +} + +boost_component_dependencies = { + "json": ["container"], +} + +top_level_keys = { + "package", + "boost", + "tests", + "tooling", +} +package_keys = { + "name", + "artifact", +} +artifact_keys = { + "enabled", + "targets", + "install_prefix", + "cmake_definitions", + "conan_options", + "registry_url", + "upload_env", +} +boost_keys = { + "ref", + "components", + "default_options", +} +test_keys = { + "enabled", + "name", +} +tooling_keys = { + "default_target", + "targets", + "docker", + "upload_env", + "remote", + "run", + "lsp", +} +run_keys = { + "enabled", + "target", + "executable", + "env", + "args", + "ports", +} +lsp_keys = { + "enabled", + "compile_databases", +} +lsp_compile_database_keys = { + "name", + "source_dir", + "build_dir", + "generator", + "cmake_definitions", + "env", + "unset_env", +} +docker_keys = { + "image", + "marker", +} +remote_keys = { + "name", + "url", +} +target_keys = { + "profile", + "build_dir", + "can_run", + "build_type", + "cmake_definitions", +} + +conan_name_re = re.compile(r"^[a-z0-9_][a-z0-9_+.-]*$") +conan_ref_re = re.compile(r"^[a-z0-9_][a-z0-9_+.-]*/[A-Za-z0-9_+.-]+(?:@[A-Za-z0-9_+.-]+/[A-Za-z0-9_+.-]+)?$") +cmake_name_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_+.-]*$") +path_re = re.compile(r"^[A-Za-z0-9_./+*?\[\]-]+$") +option_re = re.compile(r"^[A-Za-z0-9_+./*:-]+$") +docker_publish_re = re.compile(r"^[A-Za-z0-9_.:-]+(?:/(?:tcp|udp|sctp))?$") + +def read_json(path): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise config_error(f"missing Modulus config: {path}") from error + except json.JSONDecodeError as error: + raise config_error(f"invalid JSON in {path}: {error}") from error + if not isinstance(data, dict): + raise config_error(f"{path} must contain a JSON object") + return data + +def config_path(recipe): + configured = getattr(recipe, "modulus_config", "modulus.json") + path = pathlib.Path(configured) + if path.is_absolute(): + return path + recipe_folder = pathlib.Path(getattr(recipe, "recipe_folder", os.getcwd())) + return recipe_folder / path + +def load_recipe_config(recipe): + path = config_path(recipe) + return with_profile_overrides(path.parent, normalize(read_json(path))) + +def load_repo_config(repo_root): + repo_root = pathlib.Path(repo_root) + return with_profile_overrides(repo_root, normalize(read_json(repo_root / "modulus.json"))) + +def require_mapping(data, key): + value = data.get(key) + if not isinstance(value, dict): + raise config_error(f"modulus.json field '{key}' must be an object") + return value + +def normalize(data): + data = copy.deepcopy(data) + validate_raw_config(data) + package = require_mapping(data, "package") + name = package.get("name") + if not isinstance(name, str) or not name: + raise config_error("modulus.json package.name must be a non-empty string") + package.setdefault( + "artifact", + default_artifact_config(name), + ) + artifact = package["artifact"] + artifact.setdefault("enabled", False) + artifact.setdefault("targets", []) + artifact.setdefault("install_prefix", "") + artifact.setdefault("cmake_definitions", {}) + artifact.setdefault("conan_options", {}) + artifact.setdefault("registry_url", "https://git.thyme-labs.com/api/packages/khodo/generic") + artifact.setdefault("upload_env", f"{environment_prefix(name)}_PACKAGE_UPLOAD") + boost = data.setdefault( + "boost", + {}, + ) + if not isinstance(boost, dict): + raise config_error("modulus.json boost must be an object") + boost.setdefault("ref", "boost/1.91.0") + boost.setdefault( + "components", + [], + ) + boost_options = dict(boost_default_options) + boost_options.update(boost.get("default_options", {})) + for component in boost.get("components", []): + boost_options[f"boost/*:without_{component}"] = False + for dependency in boost_component_dependencies.get(component, []): + boost_options[f"boost/*:without_{dependency}"] = False + boost["default_options"] = boost_options + tests = data.setdefault( + "tests", + {}, + ) + if not isinstance(tests, dict): + raise config_error("modulus.json tests must be an object") + tests.setdefault("enabled", True) + tests.setdefault("name", name) + tooling = data.setdefault( + "tooling", + {}, + ) + if not isinstance(tooling, dict): + raise config_error("modulus.json tooling must be an object") + tooling.setdefault("default_target", "x86_64_gcc12_debug") + tooling.setdefault("targets", default_targets()) + tooling.setdefault( + "docker", + {}, + ) + tooling["docker"].setdefault("image", "modulus-build:dev") + tooling["docker"].setdefault("marker", "/opt/modulus-build-image") + tooling.setdefault("upload_env", f"{environment_prefix(name)}_CONAN_UPLOAD") + tooling.setdefault( + "remote", + {}, + ) + tooling["remote"].setdefault("name", "khodo") + tooling["remote"].setdefault("url", "https://git.thyme-labs.com/api/packages/khodo/conan") + tooling.setdefault( + "run", + default_run_config(name, tooling["default_target"]), + ) + run = tooling["run"] + run.setdefault("enabled", False) + run.setdefault("target", tooling["default_target"]) + run.setdefault("executable", name) + run.setdefault("env", {}) + run.setdefault("args", []) + run.setdefault("ports", []) + tooling.setdefault( + "lsp", + {}, + ) + lsp = tooling["lsp"] + lsp.setdefault("enabled", False) + lsp.setdefault("compile_databases", []) + for database in lsp["compile_databases"]: + database.setdefault("source_dir", ".") + database.setdefault("generator", "Ninja") + database.setdefault("cmake_definitions", {}) + database.setdefault("env", {}) + database.setdefault("unset_env", []) + for target in tooling["targets"].values(): + target.setdefault("cmake_definitions", {}) + validate_config_references(data) + return data + +def validate_raw_config(data): + validate_keys(data, top_level_keys, "modulus.json") + package = require_mapping(data, "package") + validate_keys(package, package_keys, "package") + validate_package(package) + if "boost" in data: + boost = require_mapping(data, "boost") + validate_keys(boost, boost_keys, "boost") + validate_boost(boost) + if "tests" in data: + tests = require_mapping(data, "tests") + validate_keys(tests, test_keys, "tests") + validate_tests(tests) + if "tooling" in data: + tooling = require_mapping(data, "tooling") + validate_keys(tooling, tooling_keys, "tooling") + validate_tooling(tooling) + +def validate_keys(data, allowed, field): + unknown = sorted(set(data) - allowed) + if unknown: + raise config_error(f"modulus.json field '{field}' has unknown keys: {', '.join(unknown)}") + +def validate_package(package): + name = package.get("name") + if not isinstance(name, str) or not conan_name_re.match(name): + raise config_error("modulus.json package.name must be a Conan package name") + artifact = package.get("artifact") + if artifact is not None: + if not isinstance(artifact, dict): + raise config_error("modulus.json package.artifact must be an object") + validate_keys(artifact, artifact_keys, "package.artifact") + validate_artifact(artifact) + +def validate_artifact(artifact): + validate_bool(artifact.get("enabled"), "package.artifact.enabled", optional=True) + validate_component_list( + artifact.get( + "targets", + [], + ), + "package.artifact.targets", + ) + validate_package_relative_path(artifact.get("install_prefix"), "package.artifact.install_prefix", optional=True) + validate_options( + artifact.get( + "cmake_definitions", + {}, + ), + "package.artifact.cmake_definitions", + ) + validate_options( + artifact.get( + "conan_options", + {}, + ), + "package.artifact.conan_options", + ) + validate_string(artifact.get("registry_url"), "package.artifact.registry_url", optional=True) + validate_env_name(artifact.get("upload_env"), "package.artifact.upload_env", optional=True) + +def validate_boost(boost): + validate_conan_ref(boost.get("ref"), "boost.ref", optional=True) + validate_component_list( + boost.get( + "components", + [], + ), + "boost.components", + ) + validate_options( + boost.get( + "default_options", + {}, + ), + "boost.default_options", + ) + +def validate_tests(tests): + validate_bool(tests.get("enabled"), "tests.enabled", optional=True) + validate_cmake_name(tests.get("name"), "tests.name", optional=True) + +def validate_tooling(tooling): + validate_cmake_name(tooling.get("default_target"), "tooling.default_target", optional=True) + validate_env_name(tooling.get("upload_env"), "tooling.upload_env", optional=True) + run = tooling.get("run") + if run is not None: + if not isinstance(run, dict): + raise config_error("modulus.json tooling.run must be an object") + validate_keys(run, run_keys, "tooling.run") + validate_run(run) + lsp = tooling.get("lsp") + if lsp is not None: + if not isinstance(lsp, dict): + raise config_error("modulus.json tooling.lsp must be an object") + validate_keys(lsp, lsp_keys, "tooling.lsp") + validate_lsp(lsp) + docker = tooling.get("docker") + if docker is not None: + if not isinstance(docker, dict): + raise config_error("modulus.json tooling.docker must be an object") + validate_keys(docker, docker_keys, "tooling.docker") + validate_string(docker.get("image"), "tooling.docker.image", optional=True) + validate_absolute_path(docker.get("marker"), "tooling.docker.marker", optional=True) + remote = tooling.get("remote") + if remote is not None: + if not isinstance(remote, dict): + raise config_error("modulus.json tooling.remote must be an object") + validate_keys(remote, remote_keys, "tooling.remote") + for key in ("name", "url"): + validate_string(remote.get(key), f"tooling.remote.{key}", optional=True) + targets = tooling.get("targets") + if targets is not None: + if not isinstance(targets, dict): + raise config_error("modulus.json tooling.targets must be an object") + for name, target in targets.items(): + validate_cmake_name(name, f"tooling.targets.{name}") + if not isinstance(target, dict): + raise config_error(f"modulus.json tooling.targets.{name} must be an object") + validate_keys(target, target_keys, f"tooling.targets.{name}") + validate_path(target.get("profile"), f"tooling.targets.{name}.profile") + validate_path(target.get("build_dir"), f"tooling.targets.{name}.build_dir") + validate_bool(target.get("can_run"), f"tooling.targets.{name}.can_run", optional=True) + build_type = target.get("build_type") + if build_type is not None and build_type not in {"Debug", "Release", "RelWithDebInfo", "MinSizeRel"}: + raise config_error(f"modulus.json tooling.targets.{name}.build_type must be a CMake build type") + validate_options( + target.get( + "cmake_definitions", + {}, + ), + f"tooling.targets.{name}.cmake_definitions", + ) + +def validate_run(run): + validate_bool(run.get("enabled"), "tooling.run.enabled", optional=True) + validate_cmake_name(run.get("target"), "tooling.run.target", optional=True) + validate_path(run.get("executable"), "tooling.run.executable", optional=True) + validate_string_map( + run.get( + "env", + {}, + ), + "tooling.run.env", + env_keys=True, + ) + validate_string_list( + run.get( + "args", + [], + ), + "tooling.run.args", + ) + validate_docker_publish_list( + run.get( + "ports", + [], + ), + "tooling.run.ports", + ) + +def validate_lsp(lsp): + validate_bool(lsp.get("enabled"), "tooling.lsp.enabled", optional=True) + databases = lsp.get("compile_databases", []) + if not isinstance(databases, list): + raise config_error("modulus.json tooling.lsp.compile_databases must be a list") + for index, database in enumerate(databases): + field = f"tooling.lsp.compile_databases[{index}]" + if not isinstance(database, dict): + raise config_error(f"modulus.json {field} must be an object") + validate_keys(database, lsp_compile_database_keys, field) + validate_cmake_name(database.get("name"), f"{field}.name") + validate_path(database.get("source_dir"), f"{field}.source_dir", optional=True) + validate_path(database.get("build_dir"), f"{field}.build_dir") + validate_string(database.get("generator"), f"{field}.generator", optional=True) + validate_options( + database.get( + "cmake_definitions", + {}, + ), + f"{field}.cmake_definitions", + ) + validate_string_map( + database.get( + "env", + {}, + ), + f"{field}.env", + env_keys=True, + ) + validate_string_list( + database.get( + "unset_env", + [], + ), + f"{field}.unset_env", + ) + for env_name in database.get("unset_env", []): + validate_env_name(env_name, f"{field}.unset_env") + +def validate_docker_publish_list(value, field): + validate_string_list(value, field) + for index, item in enumerate(value): + if not docker_publish_re.match(item): + raise config_error(f"modulus.json {field}[{index}] must be a Docker publish mapping") + +def validate_string(value, field, optional=False): + if value is None and optional: + return + if not isinstance(value, str) or not value: + raise config_error(f"modulus.json {field} must be a non-empty string") + if any(char in value for char in "\n\r\0"): + raise config_error(f"modulus.json {field} must not contain control characters") + +def validate_bool(value, field, optional=False): + if value is None and optional: + return + if not isinstance(value, bool): + raise config_error(f"modulus.json {field} must be a boolean") + +def validate_conan_ref(value, field, optional=False): + validate_string(value, field, optional=optional) + if value is not None and not conan_ref_re.match(value): + raise config_error(f"modulus.json {field} must be a Conan reference like name/version") + +def validate_cmake_name(value, field, optional=False): + validate_string(value, field, optional=optional) + if value is not None and not cmake_name_re.match(value): + raise config_error(f"modulus.json {field} must be a valid CMake identifier") + +def validate_env_name(value, field, optional=False): + validate_string(value, field, optional=optional) + if value is not None and not re.match(r"^[A-Z_][A-Z0-9_]*$", value): + raise config_error(f"modulus.json {field} must be an environment variable name") + +def validate_path(value, field, optional=False): + validate_string(value, field, optional=optional) + if value is None: + return + path = pathlib.Path(value) + if path.is_absolute() or ".." in path.parts or not path_re.match(value): + raise config_error(f"modulus.json {field} must be a safe relative path") + +def validate_package_relative_path(value, field, optional=False): + if value == "": + return + validate_path(value, field, optional=optional) + +def validate_absolute_path(value, field, optional=False): + validate_string(value, field, optional=optional) + if value is not None and not str(value).startswith("/"): + raise config_error(f"modulus.json {field} must be an absolute path") + +def validate_string_list(value, field, path=False): + if not isinstance(value, list): + raise config_error(f"modulus.json {field} must be a list") + for index, item in enumerate(value): + if path: + validate_path(item, f"{field}[{index}]") + else: + validate_string(item, f"{field}[{index}]") + +def validate_string_map(value, field, env_keys=False): + if not isinstance(value, dict): + raise config_error(f"modulus.json {field} must be an object") + for key, item in value.items(): + if env_keys: + validate_env_name(key, f"{field}.{key}") + else: + validate_string(key, f"{field}.{key}") + validate_string(item, f"{field}.{key}") + +def validate_component_list(value, field): + validate_string_list(value, field) + for index, item in enumerate(value): + if not cmake_name_re.match(item): + raise config_error(f"modulus.json {field}[{index}] must be a component name") + +def validate_options(value, field): + if not isinstance(value, dict): + raise config_error(f"modulus.json {field} must be an object") + for key, option in value.items(): + if not isinstance(key, str) or not option_re.match(key): + raise config_error(f"modulus.json {field} has an invalid option name") + if not isinstance(option, (str, int, bool)): + raise config_error(f"modulus.json {field}.{key} must be a string, integer, or boolean") + +def default_targets(): + return { + "x86_64_gcc12_debug": target_config("x86_64_gcc12_debug", "Debug", "build/build_x86_debug", True), + "x86_64_gcc12_release": target_config("x86_64_gcc12_release", "Release", "build/build_x86_release", True), + "arm_64_gcc12_debug": target_config("arm_64_gcc12_debug", "Debug", "build/build_arm_debug", False), + "arm_64_gcc12_release": target_config("arm_64_gcc12_release", "Release", "build/build_arm_release", False), + } + +def target_config(name, build_type, build_dir, can_run): + return { + "profile": f"build/build_tools/conan/profiles/{name}", + "build_dir": build_dir, + "can_run": can_run, + "build_type": build_type, + "cmake_definitions": {}, + } + +def default_artifact_config(name): + return { + "enabled": False, + "targets": [], + "install_prefix": "", + "cmake_definitions": {}, + "conan_options": {}, + "registry_url": "https://git.thyme-labs.com/api/packages/khodo/generic", + "upload_env": f"{environment_prefix(name)}_PACKAGE_UPLOAD", + } + +def default_run_config(name, default_target): + return { + "enabled": False, + "target": default_target, + "executable": name, + "env": {}, + "args": [], + "ports": [], + } + +def default_lsp_config(): + return { + "enabled": False, + "compile_databases": [], + } + +def environment_prefix(name): + return re.sub(r"[^A-Z0-9]+", "_", name.upper()).strip("_") + +def validate_config_references(data): + tooling = data["tooling"] + targets = tooling["targets"] + run = tooling["run"] + run_target = run.get("target") + if run_target not in targets: + raise config_error(f"modulus.json tooling.run.target must reference a configured target: {run_target}") + artifact = data["package"]["artifact"] + for target in artifact.get("targets", []): + if target not in targets: + raise config_error(f"modulus.json package.artifact.targets references an unknown target: {target}") + +def with_profile_overrides(repo_root, config): + for name, target in config["tooling"]["targets"].items(): + profile = local_profile(repo_root, name) + if profile is not None: + target["profile"] = str(profile) + return config + +def local_profile(repo_root, target_name): + for path in ( + pathlib.Path("conan") / "profiles" / target_name, + pathlib.Path("tools") / "conan" / "profiles" / target_name, + ): + if (repo_root / path).is_file(): + return path + return None + +def boost_ref(config): + return config["boost"]["ref"] + +def boost_requirement_options(config): + options = {} + for key, value in config["boost"]["default_options"].items(): + option = requirement_option_name(key) + if option is not None: + options[option] = value + return options + +def requirement_option_name(key): + if ":" not in key: + return key + pattern, option = key.split(":", 1) + if pattern in {"boost/*", "boost"}: + return option + return None diff --git a/modulus/export.py b/modulus/export.py new file mode 100644 index 0000000..ac7c1b7 --- /dev/null +++ b/modulus/export.py @@ -0,0 +1,88 @@ +import pathlib +import shutil + +import modulus.config + +build_dir = pathlib.Path("build") +common_dir = build_dir / "build_common" +tools_dir = build_dir / "build_tools" +runtime_dir = common_dir / "modulus" + +runtime_payload = ("modulus",) + +runtime_ignore_patterns = ( + ".git", + ".tmp", + "__pycache__", + "*.pyc", + "*.pyo", + "*.pyd", + ".ninja_*", + "CMakeCache.txt", + "CMakeFiles", + "CMakePresets.json", + "build.ninja", + "cmake_install.cmake", + "conan_toolchain.cmake", + "conanbuild*.sh", + "conanbuildenv-*.sh", + "conandeps_legacy.cmake", + "conanrun*.sh", + "conanrunenv-*.sh", + "deactivate_conan*.sh", + "metadata", +) + +editor_files = { + "clangd.yaml": ".clangd", + "clang-format": ".clang-format", + "pyrightconfig.json": "pyrightconfig.json", +} + +def sync_project(repo_root, package_root): + repo_root = pathlib.Path(repo_root).resolve() + package_root = pathlib.Path(package_root).resolve() + modulus.config.load_repo_config(repo_root) + sync_runtime(repo_root, package_root) + sync_tools(repo_root, package_root) + sync_editor_files(repo_root, package_root) + +def sync_runtime(repo_root, package_root): + dst = repo_root / runtime_dir + replace_directory(dst) + ignore = shutil.ignore_patterns(*runtime_ignore_patterns) + for name in runtime_payload: + src = package_root / name + if src.is_dir(): + shutil.copytree(src, dst / name, ignore=ignore) + elif src.is_file(): + shutil.copy2(src, dst / name) + +def sync_tools(repo_root, package_root): + src = package_root / "tools" + dst = repo_root / tools_dir + if not src.is_dir(): + raise RuntimeError(f"Modulus package is missing tools directory: {src}") + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.pyd")) + chmod_python_tools(dst) + +def sync_editor_files(repo_root, package_root): + src = package_root / "tools" / "editor" + if not src.is_dir(): + raise RuntimeError(f"Modulus package is missing editor templates: {src}") + for source_name, destination_name in editor_files.items(): + source = src / source_name + if not source.is_file(): + raise RuntimeError(f"Modulus package is missing editor template: {source}") + shutil.copy2(source, repo_root / destination_name) + +def replace_directory(path): + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True) + +def chmod_python_tools(root): + for path in root.rglob("*.py"): + path.chmod(path.stat().st_mode | 0o111) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..17499c9 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "include": [ + "modulus.py", + "package/conanfile.py", + "build/build_tools" + ], + "extraPaths": [ + "build/build_common/modulus", + "." + ], + "typeCheckingMode": "off", + "reportMissingImports": false, + "reportMissingModuleSource": false +} diff --git a/schemas/modulus.schema.json b/schemas/modulus.schema.json new file mode 100644 index 0000000..771c125 --- /dev/null +++ b/schemas/modulus.schema.json @@ -0,0 +1,311 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.thyme-labs.com/khodo/modulus/schemas/modulus.schema.json", + "type": "object", + "required": [ + "package" + ], + "additionalProperties": false, + "properties": { + "package": { + "$ref": "#/$defs/package" + }, + "boost": { + "$ref": "#/$defs/boost" + }, + "tests": { + "$ref": "#/$defs/tests" + }, + "tooling": { + "$ref": "#/$defs/tooling" + } + }, + "$defs": { + "package_name": { + "type": "string", + "pattern": "^[a-z0-9_][a-z0-9_+.-]*$" + }, + "conan_ref": { + "type": "string", + "pattern": "^[a-z0-9_][a-z0-9_+.-]*/[A-Za-z0-9_+.-]+(?:@[A-Za-z0-9_+.-]+/[A-Za-z0-9_+.-]+)?$" + }, + "cmake_name": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_+.-]*$" + }, + "relative_path": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9_./+*?\\[\\]-]+$" + }, + "option_value": { + "type": [ + "boolean", + "integer", + "string" + ] + }, + "options": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Za-z0-9_+./*:-]+$" + }, + "additionalProperties": { + "$ref": "#/$defs/option_value" + } + }, + "package": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/package_name" + }, + "artifact": { + "$ref": "#/$defs/artifact" + } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/$defs/cmake_name" + } + }, + "install_prefix": { + "type": "string", + "pattern": "^(|(?!(?:/|.*(?:^|/)\\.\\.(?:/|$)))[A-Za-z0-9_./+*?\\[\\]-]+)$" + }, + "cmake_definitions": { + "$ref": "#/$defs/options" + }, + "conan_options": { + "$ref": "#/$defs/options" + }, + "registry_url": { + "type": "string" + }, + "upload_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + } + } + }, + "boost": { + "type": "object", + "additionalProperties": false, + "properties": { + "ref": { + "$ref": "#/$defs/conan_ref" + }, + "components": { + "type": "array", + "items": { + "$ref": "#/$defs/cmake_name" + } + }, + "default_options": { + "$ref": "#/$defs/options" + } + } + }, + "tests": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "$ref": "#/$defs/cmake_name" + } + } + }, + "tooling": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_target": { + "$ref": "#/$defs/cmake_name" + }, + "upload_env": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "docker": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "type": "string" + }, + "marker": { + "type": "string", + "pattern": "^/" + } + } + }, + "remote": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "run": { + "$ref": "#/$defs/run" + }, + "lsp": { + "$ref": "#/$defs/lsp" + }, + "targets": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/cmake_name" + }, + "additionalProperties": { + "$ref": "#/$defs/target" + } + } + } + }, + "lsp": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "compile_databases": { + "type": "array", + "items": { + "$ref": "#/$defs/lsp_compile_database" + } + } + } + }, + "lsp_compile_database": { + "type": "object", + "required": [ + "name", + "build_dir" + ], + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/cmake_name" + }, + "source_dir": { + "$ref": "#/$defs/relative_path" + }, + "build_dir": { + "$ref": "#/$defs/relative_path" + }, + "generator": { + "type": "string" + }, + "cmake_definitions": { + "$ref": "#/$defs/options" + }, + "env": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + } + }, + "unset_env": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + } + } + } + }, + "run": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "target": { + "$ref": "#/$defs/cmake_name" + }, + "executable": { + "$ref": "#/$defs/relative_path" + }, + "env": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": "string" + } + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "ports": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9_.:-]+(?:/(?:tcp|udp|sctp))?$" + } + } + } + }, + "target": { + "type": "object", + "required": [ + "profile", + "build_dir" + ], + "additionalProperties": false, + "properties": { + "profile": { + "$ref": "#/$defs/relative_path" + }, + "build_dir": { + "$ref": "#/$defs/relative_path" + }, + "can_run": { + "type": "boolean" + }, + "build_type": { + "type": "string", + "enum": [ + "Debug", + "Release", + "RelWithDebInfo", + "MinSizeRel" + ] + }, + "cmake_definitions": { + "$ref": "#/$defs/options" + } + } + } + } +} diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..61208dd --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,234 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +auto config_error(std::string message) -> modulus::error { + assert(!message.empty()); + return modulus::json_config::invalid({}, std::move(message)); +} + +auto parse_log_level(std::string_view value) -> std::expected { + assert(value.data() != nullptr); + if (value == "trace") { + return modulus::success(modulus::log_level::TRACE); + } + if (value == "debug") { + return modulus::success(modulus::log_level::DEBUG); + } + if (value == "info") { + return modulus::success(modulus::log_level::INFO); + } + if (value == "warn") { + return modulus::success(modulus::log_level::WARN); + } + if (value == "error") { + return modulus::success(modulus::log_level::ERROR); + } + if (value == "critical") { + return modulus::success(modulus::log_level::CRITICAL); + } + if (value == "off") { + return modulus::success(modulus::log_level::OFF); + } + return modulus::fail(config_error("logging level must be trace, debug, info, warn, error, critical, or off")); +} + +auto log_level_name(modulus::log_level level) -> std::string_view { + assert(static_cast(level) >= static_cast(modulus::log_level::TRACE)); + assert(static_cast(level) <= static_cast(modulus::log_level::OFF)); + switch (level) { + case modulus::log_level::TRACE: + return "trace"; + case modulus::log_level::DEBUG: + return "debug"; + case modulus::log_level::INFO: + return "info"; + case modulus::log_level::WARN: + return "warn"; + case modulus::log_level::ERROR: + return "error"; + case modulus::log_level::CRITICAL: + return "critical"; + case modulus::log_level::OFF: + return "off"; + } + return "info"; +} + +} + +auto modulus::parse_logging_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "logging config"); + if (!object) { + return modulus::fail(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, {"level"}, "logging config"); + if (!unknown) { + return modulus::fail(unknown.error()); + } + auto config = modulus::logging_config{}; + auto found = (*object)->find("level"); + if (found == (*object)->end()) { + return modulus::success(config); + } + if (!found->value().is_string()) { + return modulus::fail(config_error("logging level must be a string")); + } + auto level = parse_log_level(std::string_view{found->value().as_string()}); + if (!level) { + return modulus::fail(level.error()); + } + config.level = *level; + return modulus::success(config); +} + +auto modulus::parse_error_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "error config"); + if (!object) { + return modulus::fail(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "include_category", + "include_code", + "include_message", + "include_location", + }, "error config"); + if (!unknown) { + return modulus::fail(unknown.error()); + } + auto config = modulus::error_config{}; + auto include_category = modulus::json_config::optional_bool_field(**object, "include_category", config.include_category); + if (!include_category) { + return modulus::fail(include_category.error()); + } + auto include_code = modulus::json_config::optional_bool_field(**object, "include_code", config.include_code); + if (!include_code) { + return modulus::fail(include_code.error()); + } + auto include_message = modulus::json_config::optional_bool_field(**object, "include_message", config.include_message); + if (!include_message) { + return modulus::fail(include_message.error()); + } + auto include_location = modulus::json_config::optional_bool_field(**object, "include_location", config.include_location); + if (!include_location) { + return modulus::fail(include_location.error()); + } + config.include_category = *include_category; + config.include_code = *include_code; + config.include_message = *include_message; + config.include_location = *include_location; + return modulus::success(config); +} + +auto modulus::parse_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "config root"); + if (!object) { + return modulus::fail(object.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "logging", + "errors", + }, "config root"); + if (!unknown) { + return modulus::fail(unknown.error()); + } + auto config = modulus::config{}; + if (auto found = (*object)->find("logging"); found != (*object)->end()) { + auto logging = modulus::parse_logging_config(found->value()); + if (!logging) { + return modulus::fail(logging.error()); + } + config.logging = *logging; + } + if (auto found = (*object)->find("errors"); found != (*object)->end()) { + auto errors = modulus::parse_error_config(found->value()); + if (!errors) { + return modulus::fail(errors.error()); + } + config.errors = *errors; + } + return modulus::success(config); +} + +auto modulus::load_config(std::string const& path) -> std::expected { + auto value = modulus::json_config::load_value(path, { + .category = "modulus.config", + .invalid_argument_code = 1, + .not_found_code = 1, + }); + if (!value) { + return modulus::fail(value.error()); + } + return modulus::parse_config(*value); +} + +auto modulus::to_json(modulus::logging_config const& config) -> boost::json::value { + return boost::json::object{ + { + "level", + std::string{log_level_name(config.level)}, + }, + }; +} + +auto modulus::to_json(modulus::error_config const& config) -> boost::json::value { + return boost::json::object{ + { + "include_category", + config.include_category, + }, + { + "include_code", + config.include_code, + }, + { + "include_message", + config.include_message, + }, + { + "include_location", + config.include_location, + }, + }; +} + +auto modulus::to_json(modulus::config const& config) -> boost::json::value { + return boost::json::object{ + { + "logging", + modulus::to_json(config.logging), + }, + { + "errors", + modulus::to_json(config.errors), + }, + }; +} + +auto modulus::configure_logger(modulus::logger& logger, modulus::logging_config const& config) -> void { + logger.set_level(config.level); + modulus::log_debug("modulus logger configured"); +} + +auto modulus::configure(modulus::config const& config) -> void { + modulus::configure_logger(modulus::default_logger(), config.logging); + modulus::set_default_error_config(config.errors); + modulus::log_debug("modulus configuration applied"); +} diff --git a/src/error.cpp b/src/error.cpp new file mode 100644 index 0000000..02eb24d --- /dev/null +++ b/src/error.cpp @@ -0,0 +1,95 @@ +#include + +#include +#include +#include +#include +#include + +namespace { + +auto config_mutex() -> std::mutex& { + static auto mutex = std::mutex{}; + return mutex; +} + +auto config_storage() -> modulus::error_config& { + static auto config = modulus::error_config{}; + return config; +} + +} + +modulus::error::error(std::string category, std::int32_t code, std::string message, modulus::source_location location) : category_(std::move(category)), code_(code), message_(std::move(message)), location_(location) { + assert(!category_.empty()); + assert(location_.file_name() != nullptr); + assert(location_.function_name() != nullptr); +} + +auto modulus::error::category() const noexcept -> std::string_view { + assert(!category_.empty()); + return category_; +} + +auto modulus::error::code() const noexcept -> std::int32_t { + assert(!category_.empty()); + return code_; +} + +auto modulus::error::message() const noexcept -> std::string_view { + assert(message_.data() != nullptr); + return message_; +} + +auto modulus::error::location() const noexcept -> const modulus::source_location& { + assert(location_.file_name() != nullptr); + return location_; +} + +auto modulus::error::describe(modulus::error_config const& config) const -> std::string { + assert(!category_.empty()); + assert(location_.file_name() != nullptr); + auto out = std::ostringstream{}; + if (config.include_category) { + out << category_; + } + if (config.include_code) { + if (config.include_category) { + out << ':'; + } + out << code_; + } + if (config.include_message) { + if (config.include_category || config.include_code) { + out << ": "; + } + out << message_; + } + if (config.include_location) { + if (config.include_category || config.include_code || config.include_message) { + out << ' '; + } + out << '(' << location_.file_name() << ':' << location_.line() << ')'; + } + return out.str(); +} + +auto modulus::error::describe() const -> std::string { + return describe(modulus::default_error_config()); +} + +auto modulus::make_error(std::string category, std::int32_t code, std::string message, modulus::source_location location) -> modulus::error { + assert(!category.empty()); + assert(location.file_name() != nullptr); + return modulus::error(std::move(category), code, std::move(message), location); +} + +auto modulus::set_default_error_config(modulus::error_config config) -> void { + auto lock = std::lock_guard{config_mutex()}; + config_storage() = config; +} + +auto modulus::default_error_config() -> modulus::error_config { + auto lock = std::lock_guard{config_mutex()}; + return config_storage(); +} diff --git a/src/json_config.cpp b/src/json_config.cpp new file mode 100644 index 0000000..8d52779 --- /dev/null +++ b/src/json_config.cpp @@ -0,0 +1,191 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +namespace { + +auto make_config_error(modulus::json_config::options config, std::int32_t code, std::string message) -> modulus::error { + assert(!config.category.empty()); + assert(!message.empty()); + return modulus::make_error(std::string{config.category}, code, std::move(message), modulus::source_location::unknown()); +} + +auto key_allowed(std::string_view key, std::initializer_list allowed) -> std::bool_t { + assert(key.data() != nullptr); + for (auto candidate : allowed) { + if (key == candidate) { + return true; + } + } + return false; +} + +auto integer_value(boost::json::value const& value, std::string_view name, modulus::json_config::options config) -> std::expected { + if (!value.is_number() || value.is_double()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be an integer")); + } + if (value.is_uint64()) { + if (value.as_uint64() > static_cast(std::numeric_limits::max())) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " is out of range")); + } + return static_cast(value.as_uint64()); + } + return value.as_int64(); +} + +} + +auto modulus::json_config::invalid(modulus::json_config::options config, std::string message) -> modulus::error { + return make_config_error(config, config.invalid_argument_code, std::move(message)); +} + +auto modulus::json_config::not_found(modulus::json_config::options config, std::string message) -> modulus::error { + return make_config_error(config, config.not_found_code, std::move(message)); +} + +auto modulus::json_config::load_value(std::string const& path, modulus::json_config::options config) -> std::expected { + if (path.empty()) { + return std::unexpected(modulus::json_config::invalid(config, "config path must not be empty")); + } + auto input = std::ifstream{path}; + if (!input) { + return std::unexpected(modulus::json_config::not_found(config, "config file not found")); + } + auto buffer = std::ostringstream{}; + buffer << input.rdbuf(); + auto parse_error = boost::system::error_code{}; + auto value = boost::json::parse(buffer.str(), parse_error); + if (parse_error) { + return std::unexpected(modulus::json_config::invalid(config, parse_error.message())); + } + return value; +} + +auto modulus::json_config::require_object(boost::json::value const& value, std::string_view context, modulus::json_config::options config) -> std::expected { + assert(context.data() != nullptr); + if (!value.is_object()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{context} + " must be an object")); + } + return &value.as_object(); +} + +auto modulus::json_config::reject_unknown_fields(boost::json::object const& object, std::initializer_list allowed, std::string_view context, modulus::json_config::options config) -> std::expected { + assert(context.data() != nullptr); + for (auto const& field : object) { + auto key = std::string_view{ + field.key().data(), + field.key().size(), + }; + if (!key_allowed(key, allowed)) { + return std::unexpected(modulus::json_config::invalid(config, std::string{context} + " has unknown key: " + std::string{key})); + } + } + return {}; +} + +auto modulus::json_config::optional_bool_field(boost::json::object const& object, std::string_view name, std::bool_t fallback, modulus::json_config::options config) -> std::expected { + assert(name.data() != nullptr); + auto found = object.find(name); + if (found == object.end()) { + return fallback; + } + if (!found->value().is_bool()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be a boolean")); + } + return found->value().as_bool(); +} + +auto modulus::json_config::optional_string_field(boost::json::object const& object, std::string_view name, std::string fallback, modulus::json_config::options config) -> std::expected { + assert(name.data() != nullptr); + auto found = object.find(name); + if (found == object.end()) { + return std::move(fallback); + } + if (!found->value().is_string()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be a string")); + } + return std::string{found->value().as_string()}; +} + +auto modulus::json_config::required_string_field(boost::json::object const& object, std::string_view name, std::string_view context, modulus::json_config::options config) -> std::expected { + assert(name.data() != nullptr); + assert(context.data() != nullptr); + auto found = object.find(name); + if (found == object.end() || !found->value().is_string()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{context} + " " + std::string{name} + " is required")); + } + auto value = std::string{found->value().as_string()}; + if (value.empty()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{context} + " " + std::string{name} + " must not be empty")); + } + return value; +} + +auto modulus::json_config::optional_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { + assert(name.data() != nullptr); + auto found = object.find(name); + if (found == object.end()) { + return fallback; + } + if (!found->value().is_number() || found->value().is_double()) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be an integer")); + } + if (found->value().is_int64() && found->value().as_int64() < 0) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must not be negative")); + } + auto number = found->value().is_uint64() ? found->value().as_uint64() : static_cast(found->value().as_int64()); + if (number > max_value) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " is out of range")); + } + return number; +} + +auto modulus::json_config::positive_uint64_field(boost::json::object const& object, std::string_view name, std::uint64_t fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { + auto parsed = modulus::json_config::optional_uint64_field(object, name, fallback, max_value, config); + if (!parsed) { + return parsed; + } + if (*parsed == 0) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " must be positive")); + } + return parsed; +} + +auto modulus::json_config::optional_int32_field(boost::json::object const& object, std::string_view name, std::int32_t fallback, std::int32_t min_value, std::int32_t max_value, modulus::json_config::options config) -> std::expected { + assert(name.data() != nullptr); + auto found = object.find(name); + if (found == object.end()) { + return fallback; + } + auto number = integer_value(found->value(), name, config); + if (!number) { + return std::unexpected(number.error()); + } + if (*number < min_value || *number > max_value) { + return std::unexpected(modulus::json_config::invalid(config, std::string{name} + " is out of range")); + } + return static_cast(*number); +} + +auto modulus::json_config::positive_milliseconds_field(boost::json::object const& object, std::string_view name, std::chrono::milliseconds fallback, std::uint64_t max_value, modulus::json_config::options config) -> std::expected { + auto parsed = modulus::json_config::positive_uint64_field(object, name, static_cast(fallback.count()), max_value, config); + if (!parsed) { + return std::unexpected(parsed.error()); + } + return std::chrono::milliseconds{static_cast(*parsed)}; +} diff --git a/src/log.cpp b/src/log.cpp new file mode 100644 index 0000000..b2b6518 --- /dev/null +++ b/src/log.cpp @@ -0,0 +1,149 @@ +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +auto is_valid_log_level(modulus::log_level level) noexcept -> std::bool_t { + return static_cast(level) >= static_cast(modulus::log_level::TRACE) && static_cast(level) <= static_cast(modulus::log_level::OFF); +} + +auto should_log(modulus::log_level record_level, modulus::log_level configured_level) noexcept -> std::bool_t { + assert(is_valid_log_level(record_level)); + assert(is_valid_log_level(configured_level)); + return configured_level != modulus::log_level::OFF && static_cast(record_level) >= static_cast(configured_level); +} + +auto format_log_record(std::ostream& stream, const modulus::log_record& record) -> void { + assert(is_valid_log_level(record.level)); + assert(record.location.file_name() != nullptr); + stream << '[' << modulus::to_string(record.level) << "] " << record.message << " (" << record.location.file_name() << ':' << record.location.line() << ")\n"; +} + +auto stderr_sink(const modulus::log_record& record) -> void { + assert(is_valid_log_level(record.level)); + assert(record.location.file_name() != nullptr); + static auto mutex = std::mutex{}; + auto lock = std::lock_guard{mutex}; + format_log_record(std::cerr, record); +} + +} + +auto modulus::to_string(modulus::log_level level) noexcept -> std::string_view { + assert(is_valid_log_level(level)); + switch (level) { + case modulus::log_level::TRACE: return "trace"; + case modulus::log_level::DEBUG: return "debug"; + case modulus::log_level::INFO: return "info"; + case modulus::log_level::WARN: return "warn"; + case modulus::log_level::ERROR: return "error"; + case modulus::log_level::CRITICAL: return "critical"; + case modulus::log_level::OFF: return "off"; + } + return "unknown"; +} + +modulus::logger::logger() : sink_(stderr_sink) {} + +auto modulus::logger::set_level(modulus::log_level level) -> void { + assert(is_valid_log_level(level)); + auto lock = std::lock_guard{mutex_}; + level_ = level; +} + +auto modulus::logger::level() const -> modulus::log_level { + auto lock = std::lock_guard{mutex_}; + assert(is_valid_log_level(level_)); + return level_; +} + +auto modulus::logger::enabled(modulus::log_level level) const -> std::bool_t { + assert(is_valid_log_level(level)); + auto lock = std::lock_guard{mutex_}; + return should_log(level, level_) && static_cast(sink_); +} + +auto modulus::logger::set_sink(std::function sink) -> void { + auto lock = std::lock_guard{mutex_}; + sink_ = std::move(sink); +} + +auto modulus::logger::write(modulus::log_record record) const -> void { + assert(is_valid_log_level(record.level)); + assert(record.location.file_name() != nullptr); + assert(record.location.function_name() != nullptr); + auto sink = std::function{}; + { + auto lock = std::lock_guard{mutex_}; + if (!should_log(record.level, level_) || !sink_) { + return; + } + sink = sink_; + } + assert(static_cast(sink)); + auto sink_lock = std::lock_guard(sink_mutex_); + sink(record); +} + +auto modulus::logger::log(modulus::log_level level, std::string message, modulus::source_location location) const -> void { + assert(is_valid_log_level(level)); + assert(location.file_name() != nullptr); + assert(location.function_name() != nullptr); + write(modulus::log_record{ + .level = level, + .message = std::move(message), + .location = location, + }); +} + +auto modulus::default_logger() -> modulus::logger& { + static auto instance = modulus::logger{}; + return instance; +} + +auto modulus::set_default_log_level(modulus::log_level level) -> void { + assert(is_valid_log_level(level)); + modulus::default_logger().set_level(level); +} + +auto modulus::is_log_enabled(modulus::log_level level) -> std::bool_t { + assert(is_valid_log_level(level)); + return modulus::default_logger().enabled(level); +} + +auto modulus::log(modulus::log_level level, std::string message, modulus::source_location location) -> void { + assert(is_valid_log_level(level)); + assert(location.file_name() != nullptr); + assert(location.function_name() != nullptr); + modulus::default_logger().log(level, std::move(message), location); +} + +auto modulus::log_trace(std::string message, modulus::source_location location) -> void { + modulus::log(modulus::log_level::TRACE, std::move(message), location); +} + +auto modulus::log_debug(std::string message, modulus::source_location location) -> void { + modulus::log(modulus::log_level::DEBUG, std::move(message), location); +} + +auto modulus::log_info(std::string message, modulus::source_location location) -> void { + modulus::log(modulus::log_level::INFO, std::move(message), location); +} + +auto modulus::log_warn(std::string message, modulus::source_location location) -> void { + modulus::log(modulus::log_level::WARN, std::move(message), location); +} + +auto modulus::log_error(std::string message, modulus::source_location location) -> void { + modulus::log(modulus::log_level::ERROR, std::move(message), location); +} + +auto modulus::log_critical(std::string message, modulus::source_location location) -> void { + modulus::log(modulus::log_level::CRITICAL, std::move(message), location); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..23a0ace --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,26 @@ +add_executable(modulus-test + test.cpp +) +target_include_directories(modulus-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(modulus-test PRIVATE + modulus::modulus +) +target_compile_features(modulus-test PRIVATE cxx_std_23) +target_compile_definitions(modulus-test PRIVATE + $<$:MODULUS_EXPECT_DEBUG_ASSERTS> + $<$:MODULUS_EXPECT_NDEBUG> + $<$:MODULUS_EXPECT_NDEBUG> + $<$:MODULUS_EXPECT_NDEBUG> +) +add_test(NAME modulus COMMAND modulus-test) +set_tests_properties(modulus PROPERTIES TIMEOUT 20) +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir ${CMAKE_BINARY_DIR} + -R ^modulus$ + --output-on-failure + --timeout 20 + USES_TERMINAL +) diff --git a/tests/test.cpp b/tests/test.cpp new file mode 100644 index 0000000..923b76b --- /dev/null +++ b/tests/test.cpp @@ -0,0 +1,271 @@ +#include "test.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { + +struct stderr_capture { + std::ostringstream stream{}; + std::streambuf* previous{std::cerr.rdbuf(stream.rdbuf())}; + ~stderr_capture(); +}; + +auto parse_positive(std::int32_t value) -> std::expected { + if (value <= std::int32_t{0}) { + return modulus::fail(modulus::make_error("test", std::int32_t{7}, "value must be positive")); + } + return modulus::success(value); +} + +stderr_capture::~stderr_capture() { + std::cerr.rdbuf(previous); +} + +} + +BOOST_AUTO_TEST_CASE(modulus_result_carries_success_and_failure_error) { + auto ok = parse_positive(std::int32_t{42}); + BOOST_REQUIRE(ok.has_value()); + BOOST_TEST(*ok == std::int32_t{42}); + auto failed = parse_positive(std::int32_t{0}); + BOOST_REQUIRE(!failed.has_value()); + BOOST_TEST(std::string{failed.error().category()} == "test"); + BOOST_TEST(failed.error().code() == std::int32_t{7}); + BOOST_TEST(failed.error().describe().find("value must be positive") != std::string::npos); + BOOST_TEST(std::string{failed.error().location().file_name()}.find("test.cpp") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(modulus_asserts_follow_build_type) { +#if defined(MODULUS_EXPECT_NDEBUG) && !defined(NDEBUG) + BOOST_FAIL("release-style builds must define NDEBUG"); +#endif +#if defined(MODULUS_EXPECT_DEBUG_ASSERTS) && defined(NDEBUG) + BOOST_FAIL("debug builds must not define NDEBUG"); +#endif + BOOST_TEST(true); +} + +BOOST_AUTO_TEST_CASE(modulus_types_and_source_locations_are_available) { + auto value32 = std::float32_t{1}; + auto value64 = std::float64_t{2}; + BOOST_TEST(static_cast(value32) < value64); + auto location = modulus::source_location::current(); + BOOST_TEST(location.file_name() != nullptr); + BOOST_TEST(std::string{location.file_name()}.find("test.cpp") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(modulus_logger_filters_and_reports_caller_location) { + auto records = std::vector{}; + auto logger = modulus::logger{}; + logger.set_level(modulus::log_level::DEBUG); + logger.set_sink([&](const modulus::log_record& record) { + records.push_back(record); + }); + logger.log(modulus::log_level::TRACE, "hidden"); + logger.log(modulus::log_level::DEBUG, "visible"); + BOOST_REQUIRE(records.size() == std::size_t{1}); + BOOST_TEST(records.front().message == "visible"); + BOOST_TEST(std::string{records.front().location.file_name()}.find("test.cpp") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(modulus_logger_serializes_custom_sink_writes) { + auto records = std::vector{}; + auto logger = modulus::logger{}; + logger.set_level(modulus::log_level::INFO); + logger.set_sink([&](const modulus::log_record& record) { + records.push_back(record); + }); + auto threads = std::vector{}; + for (auto thread = std::int32_t{}; thread < std::int32_t{4}; ++thread) { + threads.emplace_back([thread, &logger]() -> void { + for (auto message = std::int32_t{}; message < std::int32_t{50}; ++message) { + logger.log(modulus::log_level::INFO, "custom-log-" + std::to_string(thread) + "-" + std::to_string(message)); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + BOOST_TEST(records.size() == std::size_t{200}); + auto messages = std::set{}; + for (const auto& record : records) { + messages.insert(record.message); + } + BOOST_TEST(messages.size() == std::size_t{200}); +} + +BOOST_AUTO_TEST_CASE(modulus_logger_tolerates_concurrent_configuration_changes) { + auto records = std::vector{}; + auto logger = modulus::logger{}; + logger.set_level(modulus::log_level::INFO); + logger.set_sink([&](const modulus::log_record& record) { + records.push_back(record); + }); + auto sink = [&](const modulus::log_record& record) -> void { + records.push_back(record); + }; + auto mutator = std::thread{[&]() -> void { + for (auto iteration = std::int32_t{}; iteration < std::int32_t{50}; ++iteration) { + logger.set_level(modulus::log_level::DEBUG); + logger.set_sink(sink); + logger.set_level(modulus::log_level::INFO); + } + }}; + auto threads = std::vector{}; + for (auto thread = std::int32_t{}; thread < std::int32_t{4}; ++thread) { + threads.emplace_back([thread, &logger]() -> void { + for (auto message = std::int32_t{}; message < std::int32_t{50}; ++message) { + logger.log(modulus::log_level::INFO, "mutation-log-" + std::to_string(thread) + "-" + std::to_string(message)); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + mutator.join(); + BOOST_TEST(records.size() == std::size_t{200}); +} + +BOOST_AUTO_TEST_CASE(modulus_lazy_logging_skips_disabled_messages) { + modulus::set_default_log_level(modulus::log_level::ERROR); + auto side_effects = std::int32_t{}; + modulus::log_lazy(modulus::log_level::DEBUG, [&]() -> std::string { + return std::string{"hidden "} + std::to_string(++side_effects); + }); + BOOST_TEST(side_effects == std::int32_t{0}); +} + +BOOST_AUTO_TEST_CASE(modulus_default_logger_serializes_stderr_lines) { + auto capture = stderr_capture{}; + modulus::set_default_log_level(modulus::log_level::INFO); + auto threads = std::vector{}; + for (auto thread = std::int32_t{}; thread < std::int32_t{4}; ++thread) { + threads.emplace_back([thread]() -> void { + for (auto message = std::int32_t{}; message < std::int32_t{20}; ++message) { + modulus::log_info("thread-log-" + std::to_string(thread) + "-" + std::to_string(message)); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + auto captured = std::istringstream{capture.stream.str()}; + auto messages = std::set{}; + auto line = std::string{}; + while (std::getline(captured, line)) { + BOOST_TEST(line.starts_with("[info] thread-log-")); + auto end = line.find(" ("); + BOOST_REQUIRE(end != std::string::npos); + messages.insert(line.substr(7, end - 7)); + } + BOOST_TEST(messages.size() == std::size_t{80}); +} + +BOOST_AUTO_TEST_CASE(modulus_free_logging_reports_caller_location) { + auto capture = stderr_capture{}; + modulus::set_default_log_level(modulus::log_level::INFO); + modulus::log_info("free log source"); + modulus::log_lazy(modulus::log_level::INFO, []() -> std::string { + return "lazy log source"; + }); + auto output = capture.stream.str(); + BOOST_TEST(output.find("free log source") != std::string::npos); + BOOST_TEST(output.find("lazy log source") != std::string::npos); + BOOST_TEST(output.find("test.cpp") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(modulus_error_describe_honors_formatting_config) { + auto error = modulus::make_error("test", std::int32_t{7}, "value must be positive"); + auto message_only = modulus::error_config{ + .include_category = false, + .include_code = false, + .include_message = true, + .include_location = false, + }; + BOOST_TEST(error.describe(message_only) == "value must be positive"); + modulus::set_default_error_config(message_only); + BOOST_TEST(error.describe() == "value must be positive"); + modulus::set_default_error_config(modulus::error_config{}); +} + +BOOST_AUTO_TEST_CASE(modulus_config_parses_defaults_and_logging_level) { + auto defaults = modulus::parse_config(boost::json::parse("{}")); + BOOST_REQUIRE(defaults.has_value()); + BOOST_TEST(static_cast(defaults->logging.level) == static_cast(modulus::log_level::INFO)); + auto parsed = modulus::parse_config(boost::json::parse(R"json({"logging":{"level":"debug"}})json")); + BOOST_REQUIRE(parsed.has_value()); + BOOST_TEST(static_cast(parsed->logging.level) == static_cast(modulus::log_level::DEBUG)); + auto round_trip = modulus::parse_config(modulus::to_json(*parsed)); + BOOST_REQUIRE(round_trip.has_value()); + BOOST_TEST(static_cast(round_trip->logging.level) == static_cast(modulus::log_level::DEBUG)); +} + +BOOST_AUTO_TEST_CASE(modulus_config_rejects_invalid_logging_values) { + auto bad_level = modulus::parse_config(boost::json::parse(R"json({"logging":{"level":"loud"}})json")); + BOOST_REQUIRE(!bad_level.has_value()); + BOOST_TEST(std::string{bad_level.error().category()} == "modulus.config"); + auto bad_shape = modulus::parse_config(boost::json::parse(R"json({"logging":[]})json")); + BOOST_REQUIRE(!bad_shape.has_value()); + BOOST_TEST(bad_shape.error().describe().find("logging config must be an object") != std::string::npos); + auto unknown_root = modulus::parse_config(boost::json::parse(R"json({"loging":{"level":"debug"}})json")); + BOOST_REQUIRE(!unknown_root.has_value()); + BOOST_TEST(unknown_root.error().describe().find("config root has unknown key: loging") != std::string::npos); + auto unknown_logging = modulus::parse_config(boost::json::parse(R"json({"logging":{"leve":"debug"}})json")); + BOOST_REQUIRE(!unknown_logging.has_value()); + BOOST_TEST(unknown_logging.error().describe().find("logging config has unknown key: leve") != std::string::npos); + auto unknown_errors = modulus::parse_config(boost::json::parse(R"json({"errors":{"include_messages":false}})json")); + BOOST_REQUIRE(!unknown_errors.has_value()); + BOOST_TEST(unknown_errors.error().describe().find("error config has unknown key: include_messages") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(modulus_config_parses_error_formatting) { + auto parsed = modulus::parse_config(boost::json::parse(R"json({"errors":{"include_category":false,"include_code":false,"include_location":false}})json")); + BOOST_REQUIRE(parsed.has_value()); + BOOST_TEST(!parsed->errors.include_category); + BOOST_TEST(!parsed->errors.include_code); + BOOST_TEST(parsed->errors.include_message); + BOOST_TEST(!parsed->errors.include_location); +} + +BOOST_AUTO_TEST_CASE(modulus_configure_applies_default_logger_and_error_config) { + auto parsed = modulus::parse_config(boost::json::parse(R"json({"logging":{"level":"off"},"errors":{"include_category":false,"include_code":false,"include_location":false}})json")); + BOOST_REQUIRE(parsed.has_value()); + modulus::configure(*parsed); + BOOST_TEST(!modulus::is_log_enabled(modulus::log_level::CRITICAL)); + auto error = modulus::make_error("test", std::int32_t{1}, "configured"); + BOOST_TEST(error.describe() == "configured"); + modulus::set_default_log_level(modulus::log_level::INFO); + modulus::set_default_error_config(modulus::error_config{}); +} + +BOOST_AUTO_TEST_CASE(modulus_load_config_handles_files) { + auto path = std::filesystem::temp_directory_path() / "modulus-config-test.json"; + auto output = std::ofstream{path}; + output << R"json({"logging":{"level":"warn"}})json"; + output.close(); + auto loaded = modulus::load_config(path.string()); + BOOST_REQUIRE(loaded.has_value()); + BOOST_TEST(static_cast(loaded->logging.level) == static_cast(modulus::log_level::WARN)); + auto missing = modulus::load_config((std::filesystem::temp_directory_path() / "modulus-config-missing.json").string()); + BOOST_REQUIRE(!missing.has_value()); + BOOST_TEST(missing.error().describe().find("config file not found") != std::string::npos); + auto invalid_path = std::filesystem::temp_directory_path() / "modulus-config-invalid.json"; + auto invalid_output = std::ofstream{invalid_path}; + invalid_output << "{"; + invalid_output.close(); + auto invalid = modulus::load_config(invalid_path.string()); + BOOST_REQUIRE(!invalid.has_value()); +} diff --git a/tests/test.hpp b/tests/test.hpp new file mode 100644 index 0000000..c55eab8 --- /dev/null +++ b/tests/test.hpp @@ -0,0 +1,4 @@ +#pragma once + +#define BOOST_TEST_MODULE modulus +#include diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..28d1754 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,961 @@ +import os +import pathlib +import subprocess +import sys +import tarfile +import tempfile +import unittest +import unittest.mock + +import modulus.cli +import modulus.config + +class upload_packages_test(unittest.TestCase): + def context(self, repo_root=None, package_name="sample"): + config = { + "package": { + "name": package_name, + "artifact": { + "enabled": False, + "targets": [], + "install_prefix": "", + "cmake_definitions": {}, + "conan_options": {}, + "registry_url": "https://example.test/api/packages/acme/generic", + "upload_env": "SAMPLE_PACKAGE_UPLOAD", + }, + }, + "tooling": { + "default_target": "native", + "targets": { + "native": { + "profile": "build/build_tools/conan/profiles/native", + "build_dir": "build/build_native", + "can_run": True, + "build_type": "Release", + }, + }, + "docker": { + "image": "sample-build:dev", + "marker": "/opt/sample-build-image", + }, + "upload_env": "SAMPLE_CONAN_UPLOAD", + "remote": { + "name": "origin", + "url": "https://example.test/conan", + }, + "run": { + "enabled": False, + "target": "native", + "executable": "sample", + "env": {}, + "args": [], + "ports": [], + }, + }, + "tests": { + "name": "sample", + }, + } + return modulus.cli.build_context(repo_root or pathlib.Path(os.getcwd()), config) + + def test_cli_import_does_not_import_conan_recipe_module(self): + repo = pathlib.Path(__file__).resolve().parents[1] + command = [ + sys.executable, + "-c", + "import sys; import modulus.cli; raise SystemExit(1 if 'modulus.recipe' in sys.modules else 0)", + ] + completed = subprocess.run(command, cwd=repo, check=False) + self.assertEqual(completed.returncode, 0) + + @unittest.mock.patch.object(modulus.cli, "upload_all_packages", return_value=37) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + def test_failed_upload_does_not_force_by_default(self, configure, upload): + context = self.context() + with unittest.mock.patch.dict(os.environ, {}, clear=True): + status = modulus.cli.upload_packages(context) + self.assertEqual(status, 37) + configure.assert_called_once_with(context, authenticate=True) + upload.assert_called_once_with(context, "origin", force=False) + + @unittest.mock.patch.object(modulus.cli, "upload_all_packages") + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote") + def test_replace_requires_guard_env(self, configure, upload): + with unittest.mock.patch.dict(os.environ, {}, clear=True): + status = modulus.cli.upload_packages(self.context(), replace=True) + self.assertEqual(status, 1) + configure.assert_not_called() + upload.assert_not_called() + + @unittest.mock.patch.object(modulus.cli, "upload_all_packages", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + def test_replace_forces_upload_when_guarded(self, configure, upload): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "MODULUS_ALLOW_PACKAGE_REPLACE": "1", + }, + clear=True, + ): + status = modulus.cli.upload_packages(context, replace=True) + self.assertEqual(status, 0) + configure.assert_called_once_with(context, authenticate=True) + upload.assert_called_once_with(context, "origin", force=True) + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_upload_defaults_to_latest_reference(self, run): + with unittest.mock.patch.dict(os.environ, {}, clear=True): + status = modulus.cli.upload_all_packages(self.context(), "origin") + self.assertEqual(status, 0) + self.assertIn("sample/latest:*", run.call_args.args[1]) + self.assertNotIn("--force", run.call_args.args[1]) + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_replace_upload_uses_conan_force(self, run): + status = modulus.cli.upload_all_packages(self.context(), "origin", force=True) + self.assertEqual(status, 0) + self.assertIn("--force", run.call_args.args[1]) + + def test_checked_in_ci_overwrites_latest_on_master(self): + workflow = (pathlib.Path(__file__).resolve().parents[1] / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8") + self.assertIn("gitea.ref == 'refs/heads/master'", workflow) + self.assertIn("workflow_dispatch", workflow) + self.assertIn("MODULUS_ALLOW_PACKAGE_REPLACE", workflow) + self.assertIn("--replace", workflow) + self.assertNotIn("--seed-dependencies", workflow) + self.assertNotIn("--no-remote", workflow) + self.assertNotIn("CONAN_PACKAGE_API_URL", workflow) + self.assertNotIn("VERSION_TAG", workflow) + self.assertIn("CONAN_HOME: build/build_common/conan_cache", workflow) + self.assertNotIn("./tools/bench.py", workflow) + self.assertNotIn("--examples", workflow) + self.assertNotIn("--volume \"${PWD}:${PWD}\"", workflow) + self.assertNotIn("CONAN_PUBLIC_REMOTE_URL", workflow) + self.assertNotIn("docs.py", workflow) + + def test_removed_public_commands_are_rejected(self): + with tempfile.TemporaryDirectory() as repo_dir: + repo = pathlib.Path(repo_dir) + (repo / "modulus.json").write_text( + '{"package":{"name":"sample"}}', + encoding="utf-8", + ) + previous_cwd = pathlib.Path.cwd() + os.chdir(repo) + try: + for command in ("image", "docs", "shell", "clangd", "docker_support"): + with self.assertRaisesRegex(SystemExit, "unknown modulus command"): + modulus.cli.main(command) + finally: + os.chdir(previous_cwd) + + def test_docker_passthrough_includes_replace_env(self): + passthrough = modulus.cli.docker_passthrough_env(self.context()) + self.assertIn("MODULUS_ALLOW_PACKAGE_REPLACE", passthrough) + self.assertIn("PACKAGE_REGISTRY_USER", passthrough) + self.assertIn("PACKAGE_REGISTRY_TOKEN", passthrough) + self.assertIn("SAMPLE_PACKAGE_UPLOAD", passthrough) + self.assertNotIn("CONAN_PACKAGE_VERSION", passthrough) + self.assertNotIn("MODULUS_EXPORT_RUNTIME", passthrough) + self.assertNotIn("GITHUB_TOKEN", passthrough) + self.assertNotIn("GITEA_TOKEN", passthrough) + + def test_run_ports_default_to_empty_list(self): + config = modulus.config.normalize({ + "package": { + "name": "sample", + }, + }) + self.assertEqual(config["tooling"]["run"]["ports"], []) + + def test_run_accepts_docker_publish_mapping(self): + config = modulus.config.normalize({ + "package": { + "name": "sample", + }, + "tooling": { + "run": { + "ports": ["127.0.0.1:8000:8000/tcp"], + }, + }, + }) + self.assertEqual(config["tooling"]["run"]["ports"], ["127.0.0.1:8000:8000/tcp"]) + + def test_run_rejects_invalid_docker_publish_mapping(self): + with self.assertRaisesRegex(modulus.config.config_error, "tooling.run.ports\\[0\\]"): + modulus.config.normalize({ + "package": { + "name": "sample", + }, + "tooling": { + "run": { + "ports": ["127.0.0.1:8000:8000/tcp --privileged"], + }, + }, + }) + + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_run_is_disabled_by_default(self, docker): + status = modulus.cli.product_run(self.context(), []) + self.assertEqual(status, 1) + docker.assert_not_called() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_run_executes_configured_product_executable(self, docker, run): + with tempfile.TemporaryDirectory() as repo_dir: + repo = pathlib.Path(repo_dir) + context = self.context(repo_root=repo) + context.run_settings.update({ + "enabled": True, + "target": "native", + "executable": "sample-app", + "env": { + "SAMPLE_WEB_ROOT": "build/web", + }, + "args": ["--serve"], + }) + context.default_target.build_dir.mkdir(parents=True) + executable = context.default_target.build_dir / "sample-app" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + status = modulus.cli.product_run( + context, + [ + "--", + "--port", + "8000", + ], + ) + self.assertEqual(status, 0) + self.assertEqual( + run.call_args.args[1], + [ + executable, + "--serve", + "--port", + "8000", + ], + ) + self.assertEqual(run.call_args.kwargs["env_overrides"], {"SAMPLE_WEB_ROOT": "build/web"}) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "docker_run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=False) + def test_run_publishes_configured_ports_when_wrapped_by_docker(self, docker, docker_run): + context = self.context() + context.run_settings.update({ + "enabled": True, + "ports": ["127.0.0.1:8000:8000/tcp"], + }) + status = modulus.cli.product_run(context, ["--target", "native"]) + self.assertEqual(status, 0) + docker_run.assert_called_once_with( + context, + [ + f"./{context.tools_rel}/run.py", + "--target", + "native", + ], + docker_args=[ + "--publish", + "127.0.0.1:8000:8000/tcp", + ], + ) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_build_image_honors_docker_platform_env(self, run): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "MODULUS_DOCKER_PLATFORM": "linux/arm64", + }, + clear=True, + ): + status = modulus.cli.build_image(context) + self.assertEqual(status, 0) + self.assertEqual( + run.call_args.args[1], + [ + "docker", + "build", + "--platform", + "linux/arm64", + "--file", + context.dockerfile, + "--tag", + "sample-build:dev", + ".", + ], + ) + + @unittest.mock.patch.object(modulus.cli, "ensure_build_image", return_value=0) + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_container", return_value=False) + def test_docker_run_honors_docker_platform_env(self, in_container, run, ensure_image): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "MODULUS_DOCKER_PLATFORM": "linux/arm64", + }, + clear=True, + ): + status = modulus.cli.docker_run(context, ["true"]) + self.assertEqual(status, 0) + self.assertIn("--platform", run.call_args.args[1]) + self.assertIn("linux/arm64", run.call_args.args[1]) + ensure_image.assert_called_once_with(context) + in_container.assert_called_once() + + def test_conan_remote_url_uses_config_by_default(self): + with unittest.mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(modulus.cli.conan_remote_url(self.context()), "https://example.test/conan") + + def test_conan_remote_url_uses_config_in_ci(self): + with unittest.mock.patch.dict( + os.environ, + { + "CI": "true", + }, + clear=True, + ): + self.assertEqual(modulus.cli.conan_remote_url(self.context()), "https://example.test/conan") + + def test_conan_credentials_only_uses_conan_environment(self): + with unittest.mock.patch.dict( + os.environ, + { + "CONAN_USER": "dev", + "CONAN_TOKEN": "secret", + "GITEA_TOKEN": "ignored", + }, + clear=True, + ): + user, token = modulus.cli.conan_credentials() + self.assertEqual(user, "dev") + self.assertEqual(token, "secret") + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_remote_skips_login_without_conan_credentials(self, run): + with unittest.mock.patch.dict(os.environ, {}, clear=True): + status = modulus.cli.configure_conan_remote(self.context()) + self.assertEqual(status, 0) + self.assertEqual(len(run.call_args_list), 1) + self.assertEqual( + run.call_args_list[0].args[1], + [ + "conan", + "remote", + "add", + "-f", + "origin", + "https://example.test/conan", + ], + ) + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_remote_rejects_partial_conan_credentials(self, run): + with unittest.mock.patch.dict(os.environ, {"CONAN_USER": "dev"}, clear=True): + status = modulus.cli.configure_conan_remote(self.context()) + self.assertEqual(status, 1) + run.assert_not_called() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_remote_skips_login_until_authentication_is_requested(self, run): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "CONAN_USER": "dev", + "CONAN_TOKEN": "secret", + }, + clear=True, + ): + status = modulus.cli.configure_conan_remote(context) + self.assertEqual(status, 0) + self.assertEqual(len(run.call_args_list), 1) + self.assertEqual( + run.call_args_list[0].args[1], + [ + "conan", + "remote", + "add", + "-f", + "origin", + "https://example.test/conan", + ], + ) + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_remote_logs_in_with_hidden_token_when_authenticating(self, run): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "CONAN_USER": "dev", + "CONAN_TOKEN": "secret", + }, + clear=True, + ): + status = modulus.cli.configure_conan_remote(context, authenticate=True) + self.assertEqual(status, 0) + self.assertEqual( + run.call_args_list[0].args[1], + [ + "conan", + "remote", + "add", + "-f", + "origin", + "https://example.test/conan", + ], + ) + self.assertEqual( + run.call_args_list[1].args[1], + [ + "conan", + "remote", + "login", + "origin", + "dev", + "-p", + "secret", + ], + ) + self.assertEqual( + run.call_args_list[1].kwargs["display_command"], + [ + "conan", + "remote", + "login", + "origin", + "dev", + "-p", + "", + ], + ) + + def multi_target_context(self): + config = { + "package": { + "name": "sample", + "artifact": { + "enabled": False, + "targets": [], + "install_prefix": "", + "cmake_definitions": {}, + "conan_options": {}, + "registry_url": "https://example.test/api/packages/acme/generic", + "upload_env": "SAMPLE_PACKAGE_UPLOAD", + }, + }, + "tooling": { + "default_target": "debug", + "targets": { + "debug": { + "profile": "build/build_tools/conan/profiles/debug", + "build_dir": "build/build_debug", + "can_run": True, + "build_type": "Debug", + }, + "release": { + "profile": "build/build_tools/conan/profiles/release", + "build_dir": "build/build_release", + "can_run": True, + "build_type": "Release", + }, + }, + "docker": { + "image": "sample-build:dev", + "marker": "/opt/sample-build-image", + }, + "upload_env": "SAMPLE_CONAN_UPLOAD", + "remote": { + "name": "origin", + "url": "https://example.test/conan", + }, + "run": { + "enabled": False, + "target": "debug", + "executable": "sample", + "env": {}, + "args": [], + }, + }, + "tests": { + "name": "sample", + }, + } + return modulus.cli.build_context(pathlib.Path(os.getcwd()), config) + + def test_selected_targets_default_to_default_target_only(self): + context = self.multi_target_context() + selected = context.selected_targets( + [], + False, + ) + self.assertEqual([target.name for target in selected], ["debug"]) + + def test_selected_targets_all_returns_every_target(self): + context = self.multi_target_context() + selected = context.selected_targets( + [], + True, + ) + self.assertEqual([target.name for target in selected], ["debug", "release"]) + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_build_can_target_one_cmake_target(self, docker, run): + context = self.context() + context.default_target.build_dir.mkdir(parents=True, exist_ok=True) + (context.default_target.build_dir / "CMakeCache.txt").write_text("", encoding="utf-8") + (context.default_target.build_dir / "build.ninja").write_text("", encoding="utf-8") + status = modulus.cli.build( + context, + [ + "--target", + "native", + "--cmake-target", + "sample-example", + ], + ) + self.assertEqual(status, 0) + self.assertEqual( + run.call_args.args[1], + [ + "cmake", + "--build", + context.default_target.build_dir, + "--target", + "sample-example", + "--parallel", + "2", + ], + ) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_build_auto_configures_missing_target(self, docker, configure_remote, configure_one, run): + with tempfile.TemporaryDirectory() as repo_dir: + context = self.context(repo_root=pathlib.Path(repo_dir)) + status = modulus.cli.build(context, []) + self.assertEqual(status, 0) + configure_remote.assert_called_once_with(context) + configure_one.assert_called_once_with(context, context.default_target, update=False) + self.assertEqual( + run.call_args.args[1], + [ + "cmake", + "--build", + context.default_target.build_dir, + "--parallel", + "2", + ], + ) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_build_skips_configure_when_target_is_configured(self, docker, configure_remote, configure_one, run): + with tempfile.TemporaryDirectory() as repo_dir: + context = self.context(repo_root=pathlib.Path(repo_dir)) + context.default_target.build_dir.mkdir(parents=True) + (context.default_target.build_dir / "CMakeCache.txt").write_text("", encoding="utf-8") + (context.default_target.build_dir / "build.ninja").write_text("", encoding="utf-8") + status = modulus.cli.build(context, []) + self.assertEqual(status, 0) + configure_remote.assert_not_called() + configure_one.assert_not_called() + self.assertEqual( + run.call_args.args[1], + [ + "cmake", + "--build", + context.default_target.build_dir, + "--parallel", + "2", + ], + ) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_build_reconfigures_when_target_cmake_definition_differs(self, docker, configure_remote, configure_one, run): + with tempfile.TemporaryDirectory() as repo_dir: + context = self.context(repo_root=pathlib.Path(repo_dir)) + context.default_target.cmake_definitions = { + "SAMPLE_WITH_WEB": True, + } + context.default_target.build_dir.mkdir(parents=True) + (context.default_target.build_dir / "CMakeCache.txt").write_text("SAMPLE_WITH_WEB:BOOL=OFF\n", encoding="utf-8") + (context.default_target.build_dir / "build.ninja").write_text("", encoding="utf-8") + status = modulus.cli.build(context, []) + self.assertEqual(status, 0) + configure_remote.assert_called_once_with(context) + configure_one.assert_called_once_with(context, context.default_target, update=False) + self.assertEqual( + run.call_args.args[1], + [ + "cmake", + "--build", + context.default_target.build_dir, + "--parallel", + "2", + ], + ) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=37) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_build_stops_when_auto_configure_fails(self, docker, configure_remote, configure_one, run): + with tempfile.TemporaryDirectory() as repo_dir: + context = self.context(repo_root=pathlib.Path(repo_dir)) + status = modulus.cli.build(context, []) + self.assertEqual(status, 37) + configure_remote.assert_called_once_with(context) + configure_one.assert_called_once_with(context, context.default_target, update=False) + run.assert_not_called() + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_configure_no_remote_skips_remote_setup(self, docker, configure_remote, configure_one): + with tempfile.TemporaryDirectory() as repo_dir: + context = self.context(repo_root=pathlib.Path(repo_dir)) + status = modulus.cli.configure( + context, + [ + "--target", + "native", + "--no-remote", + ], + ) + self.assertEqual(status, 0) + configure_remote.assert_not_called() + configure_one.assert_called_once_with(context, context.default_target, False, no_remote=True) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0) + @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_package_no_remote_skips_remote_setup_and_upload(self, docker, configure_remote, create_package, upload): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "SAMPLE_CONAN_UPLOAD": "1", + }, + clear=True, + ): + status = modulus.cli.package( + context, + [ + "--target", + "native", + "--no-remote", + ], + ) + self.assertEqual(status, 0) + configure_remote.assert_not_called() + create_package.assert_called_once_with(context, context.default_target, False, no_remote=True) + upload.assert_not_called() + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=0) + @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_package_configures_remote_once_before_upload(self, docker, configure_remote, create_package, upload): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "CONAN_USER": "dev", + "CONAN_TOKEN": "secret", + "SAMPLE_CONAN_UPLOAD": "1", + }, + clear=True, + ): + status = modulus.cli.package( + context, + [ + "--target", + "native", + ], + ) + self.assertEqual(status, 0) + configure_remote.assert_called_once_with(context) + create_package.assert_called_once_with(context, context.default_target, False, no_remote=False) + upload.assert_called_once_with(context, replace=False) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "upload_packages", return_value=1) + @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_package_defers_conan_upload_credentials_until_after_create(self, docker, configure_remote, create_package, upload): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "SAMPLE_CONAN_UPLOAD": "1", + }, + clear=True, + ): + status = modulus.cli.package( + context, + [ + "--target", + "native", + ], + ) + self.assertEqual(status, 1) + configure_remote.assert_called_once_with(context) + create_package.assert_called_once_with(context, context.default_target, False, no_remote=False) + upload.assert_called_once_with(context, replace=False) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "create_package", return_value=0) + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_package_rejects_partial_conan_credentials_before_create(self, docker, run, create_package): + context = self.context() + with unittest.mock.patch.dict( + os.environ, + { + "CONAN_USER": "dev", + "SAMPLE_CONAN_UPLOAD": "1", + }, + clear=True, + ): + status = modulus.cli.package( + context, + [ + "--target", + "native", + ], + ) + self.assertEqual(status, 1) + create_package.assert_not_called() + run.assert_not_called() + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_one_passes_no_remote_to_conan_install(self, run, clear): + context = self.context() + status = modulus.cli.configure_one(context, context.default_target, update=False, no_remote=True) + self.assertEqual(status, 0) + conan_command = run.call_args_list[0].args[1] + self.assertIn("--no-remote", conan_command) + self.assertIn("--build=missing", conan_command) + self.assertNotIn("-s", conan_command) + self.assertNotIn("-c", conan_command) + self.assertNotIn("glfw/*:with_x11=False", conan_command) + self.assertIn(f"-DCMAKE_TOOLCHAIN_FILE={context.default_target.build_dir / 'conan' / 'conan_toolchain.cmake'}", run.call_args_list[1].args[1]) + self.assertIn("-DCMAKE_BUILD_TYPE=Release", run.call_args_list[1].args[1]) + clear.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_one_passes_target_cmake_definitions(self, run, clear): + context = self.context() + context.default_target.cmake_definitions = { + "SAMPLE_WITH_WEB": True, + "SAMPLE_MODE": "dev", + "SAMPLE_LIMIT": 2, + } + status = modulus.cli.configure_one(context, context.default_target, update=False) + self.assertEqual(status, 0) + cmake_command = run.call_args_list[1].args[1] + self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command) + self.assertIn("-DSAMPLE_MODE=dev", cmake_command) + self.assertIn("-DSAMPLE_LIMIT=2", cmake_command) + clear.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "configure_lsp_database", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_one", return_value=0) + @unittest.mock.patch.object(modulus.cli, "configure_conan_remote", return_value=0) + @unittest.mock.patch.object(modulus.cli, "in_docker", return_value=True) + def test_lsp_configures_default_target_and_extra_databases(self, docker, configure_remote, configure_one, configure_lsp): + context = self.context() + database = { + "name": "pico", + "source_dir": ".", + "build_dir": "build/build_pico_debug", + "generator": "Ninja", + "cmake_definitions": { + "VAMP_PICO_FIRMWARE_PROJECT": True, + }, + "env": {}, + "unset_env": ["CC", "CXX"], + } + context.lsp_settings = { + "enabled": True, + "compile_databases": [database], + } + status = modulus.cli.lsp(context, []) + self.assertEqual(status, 0) + configure_remote.assert_called_once_with(context) + configure_one.assert_called_once_with(context, context.default_target, False, no_remote=False) + configure_lsp.assert_called_once_with(context, database) + docker.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_lsp_database_passes_cmake_definitions_and_unsets_env(self, run): + with tempfile.TemporaryDirectory() as repo_dir: + repo = pathlib.Path(repo_dir) + context = self.context(repo_root=repo) + status = modulus.cli.configure_lsp_database( + context, + { + "name": "pico", + "source_dir": ".", + "build_dir": "build/build_pico_debug", + "generator": "Ninja", + "cmake_definitions": { + "VAMP_PICO_FIRMWARE_PROJECT": True, + }, + "env": { + "PICO_SDK_PATH": "/opt/pico-sdk", + }, + "unset_env": ["CC", "CXX"], + }, + ) + self.assertEqual(status, 0) + command = run.call_args.args[1] + self.assertIn("-DVAMP_PICO_FIRMWARE_PROJECT=ON", command) + self.assertEqual(run.call_args.kwargs["env_overrides"], {"PICO_SDK_PATH": "/opt/pico-sdk"}) + self.assertEqual(run.call_args.kwargs["env_unset"], ["CC", "CXX"]) + + @unittest.mock.patch.object(modulus.cli, "clear_conan_deps") + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_configure_one_allows_public_repos_to_build_missing_dependencies(self, run, clear): + context = self.context() + status = modulus.cli.configure_one(context, context.default_target, update=False) + self.assertEqual(status, 0) + conan_command = run.call_args_list[0].args[1] + self.assertIn("--build=missing", conan_command) + clear.assert_called_once() + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_create_package_passes_no_remote_to_conan_create(self, run): + with tempfile.TemporaryDirectory() as repo_dir: + context = self.context(repo_root=pathlib.Path(repo_dir)) + status = modulus.cli.create_package(context, context.default_target, update=False, no_remote=True) + self.assertEqual(status, 0) + self.assertIn("&:with_tests=False", run.call_args.args[1]) + self.assertIn("--build=sample/*", run.call_args.args[1]) + self.assertIn("--build=missing", run.call_args.args[1]) + self.assertIn("--no-remote", run.call_args.args[1]) + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_create_package_allows_public_repos_to_build_missing_dependencies(self, run): + with tempfile.TemporaryDirectory() as repo_dir: + context = self.context(repo_root=pathlib.Path(repo_dir)) + status = modulus.cli.create_package(context, context.default_target, update=False) + self.assertEqual(status, 0) + self.assertIn("--build=sample/*", run.call_args.args[1]) + self.assertIn("--build=missing", run.call_args.args[1]) + + def test_create_artifact_package_installs_archives_and_checksums_tree(self): + with tempfile.TemporaryDirectory() as repo_dir: + repo = pathlib.Path(repo_dir) + context = self.context(repo_root=repo) + context.package["artifact"].update({ + "enabled": True, + "targets": ["native"], + "install_prefix": "usr", + "cmake_definitions": { + "SAMPLE_WITH_WEB": True, + }, + "conan_options": { + "with_appliance_adapters": True, + }, + }) + + def fake_run(run_context, command, **kwargs): + if command[:2] == ["cmake", "--install"]: + install_root = modulus.cli.package_artifact_build_root(context, context.default_target) / "stage" / "usr" + (install_root / "bin").mkdir(parents=True) + (install_root / "bin" / "sample").write_text("binary\n", encoding="utf-8") + return 0 + + with unittest.mock.patch.object(modulus.cli, "run", side_effect=fake_run) as run: + status, artifacts = modulus.cli.create_artifact_package( + context, + context.default_target, + "abc123", + update=False, + no_remote=True, + ) + self.assertEqual(status, 0) + archive = repo / "build" / "packages" / "sample-abc123-native.tar.gz" + checksum = archive.with_name(archive.name + ".sha256") + self.assertEqual(artifacts, [archive, checksum]) + self.assertTrue(archive.is_file()) + self.assertTrue(checksum.is_file()) + with tarfile.open(archive, "r:gz") as input_archive: + self.assertIn("usr/bin/sample", input_archive.getnames()) + self.assertIn(archive.name, checksum.read_text(encoding="utf-8")) + conan_command = run.call_args_list[0].args[1] + self.assertEqual(conan_command[0:3], ["conan", "install", "."]) + self.assertIn("&:with_appliance_adapters=True", conan_command) + self.assertIn("&:with_tests=False", conan_command) + self.assertIn("--no-remote", conan_command) + self.assertIn("--build=missing", conan_command) + cmake_command = run.call_args_list[1].args[1] + self.assertIn("-DSAMPLE_WITH_WEB=ON", cmake_command) + self.assertIn(f"-DCMAKE_INSTALL_PREFIX={repo / 'build' / 'build_common' / 'artifacts' / 'native' / 'stage' / 'usr'}", cmake_command) + + @unittest.mock.patch.object(modulus.cli, "run", return_value=0) + def test_generic_artifact_upload_uses_package_registry_credentials(self, run): + context = self.context() + archive = pathlib.Path("build/packages/sample-abc123-native.tar.gz") + with unittest.mock.patch.dict( + os.environ, + { + "PACKAGE_REGISTRY_USER": "dev", + "PACKAGE_REGISTRY_TOKEN": "secret", + }, + clear=True, + ): + status = modulus.cli.upload_generic_artifact(context, "abc123", archive) + self.assertEqual(status, 0) + self.assertEqual( + run.call_args.args[1], + [ + "curl", + "--fail", + "--silent", + "--show-error", + "--user", + "dev:secret", + "--upload-file", + archive, + "https://example.test/api/packages/acme/generic/sample/abc123/sample-abc123-native.tar.gz", + ], + ) + self.assertEqual( + run.call_args.kwargs["display_command"][5], + "dev:", + ) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..e901096 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,352 @@ +import json +import pathlib +import tempfile +import unittest + +import modulus.config +import modulus.export + +class modulus_export_test(unittest.TestCase): + def config(self): + return { + "package": { + "name": "sample", + }, + "boost": { + "ref": "boost/1.91.0", + "components": ["json"], + "default_options": { + "boost/*:without_charconv": False, + }, + }, + "tests": { + "name": "sample", + }, + } + + def write_config(self, root, config): + (root / "modulus.json").write_text(json.dumps(config), encoding="utf-8") + + def write_package(self, package): + for directory in ( + "modulus", + "tools/docker", + "tools/editor", + "include/modulus", + "tests", + "lib/cmake/modulus", + "share/modulus", + "bin", + ): + (package / directory).mkdir(parents=True) + (package / "conanfile.py").write_text("", encoding="utf-8") + (package / "modulus.json").write_text("{}", encoding="utf-8") + (package / "modulus" / "cli.py").write_text("", encoding="utf-8") + (package / "modulus" / "config.py").write_text("", encoding="utf-8") + (package / "modulus" / "export.py").write_text("", encoding="utf-8") + (package / "tools" / "configure.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + (package / "tools" / "lsp.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + (package / "tools" / "run.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + (package / "tools" / "docker" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + (package / "tools" / "editor" / "clangd.yaml").write_text("CompileFlags: {}\n", encoding="utf-8") + (package / "tools" / "editor" / "clang-format").write_text("DisableFormat: true\n", encoding="utf-8") + (package / "tools" / "editor" / "pyrightconfig.json").write_text("{}\n", encoding="utf-8") + (package / "include" / "modulus" / "types.hpp").write_text("", encoding="utf-8") + (package / "tests" / "test.cpp").write_text("", encoding="utf-8") + (package / "lib" / "cmake" / "modulus" / "modulus-config.cmake").write_text("", encoding="utf-8") + (package / "share" / "modulus" / "duplicate.py").write_text("", encoding="utf-8") + (package / "bin" / "modulus-tool").write_text("", encoding="utf-8") + + def test_boost_requirement_options_enable_configured_components(self): + config = modulus.config.normalize(self.config()) + boost_options = modulus.config.boost_requirement_options(config) + self.assertEqual(modulus.config.boost_ref(config), "boost/1.91.0") + self.assertIs(boost_options["without_json"], False) + self.assertIs(boost_options["without_container"], False) + self.assertIs(boost_options["without_charconv"], False) + self.assertIs(boost_options["without_atomic"], True) + + def test_default_targets_include_architecture_and_build_type_matrix(self): + config = modulus.config.normalize(self.config()) + targets = config["tooling"]["targets"] + self.assertEqual( + sorted(targets), + [ + "arm_64_gcc12_debug", + "arm_64_gcc12_release", + "x86_64_gcc12_debug", + "x86_64_gcc12_release", + ], + ) + self.assertEqual(config["tooling"]["default_target"], "x86_64_gcc12_debug") + self.assertEqual(targets["x86_64_gcc12_debug"]["build_type"], "Debug") + self.assertFalse(targets["arm_64_gcc12_release"]["can_run"]) + self.assertEqual(targets["x86_64_gcc12_release"]["profile"], "build/build_tools/conan/profiles/x86_64_gcc12_release") + self.assertFalse(config["package"]["artifact"]["enabled"]) + self.assertFalse(config["tooling"]["run"]["enabled"]) + self.assertFalse(config["tooling"]["lsp"]["enabled"]) + self.assertEqual(config["tooling"]["lsp"]["compile_databases"], []) + + def test_product_artifact_and_run_config_are_validated(self): + config = self.config() + config["package"]["artifact"] = { + "enabled": True, + "targets": ["x86_64_gcc12_release"], + "install_prefix": "usr", + "cmake_definitions": { + "SAMPLE_WITH_WEB": True, + }, + "conan_options": { + "with_appliance_adapters": True, + }, + "upload_env": "SAMPLE_PACKAGE_UPLOAD", + } + config["tooling"] = { + "run": { + "enabled": True, + "target": "x86_64_gcc12_debug", + "executable": "sample", + "env": { + "SAMPLE_WEB_ROOT": "build/web", + }, + }, + } + normalized = modulus.config.normalize(config) + self.assertEqual(normalized["package"]["artifact"]["install_prefix"], "usr") + self.assertEqual(normalized["package"]["artifact"]["registry_url"], "https://git.thyme-labs.com/api/packages/khodo/generic") + self.assertEqual(normalized["tooling"]["run"]["args"], []) + + config["package"]["artifact"]["targets"] = ["missing"] + with self.assertRaisesRegex(modulus.config.config_error, "unknown target"): + modulus.config.normalize(config) + + def test_target_cmake_definitions_are_validated_and_defaulted(self): + config = self.config() + config["tooling"] = { + "default_target": "native", + "targets": { + "native": { + "profile": "build/build_tools/conan/profiles/native", + "build_dir": "build/build_native", + "can_run": True, + "build_type": "Debug", + "cmake_definitions": { + "SAMPLE_WITH_WEB": True, + "SAMPLE_MODE": "dev", + }, + }, + "release": { + "profile": "build/build_tools/conan/profiles/release", + "build_dir": "build/build_release", + "can_run": True, + "build_type": "Release", + }, + }, + } + normalized = modulus.config.normalize(config) + self.assertEqual( + normalized["tooling"]["targets"]["native"]["cmake_definitions"], + { + "SAMPLE_WITH_WEB": True, + "SAMPLE_MODE": "dev", + }, + ) + self.assertEqual(normalized["tooling"]["targets"]["release"]["cmake_definitions"], {}) + + config["tooling"]["targets"]["native"]["cmake_definitions"] = { + "SAMPLE_BAD": [ + "invalid", + ], + } + with self.assertRaisesRegex(modulus.config.config_error, "tooling.targets.native.cmake_definitions"): + modulus.config.normalize(config) + + def test_lsp_compile_databases_are_validated_and_defaulted(self): + config = self.config() + config["tooling"] = { + "lsp": { + "enabled": True, + "compile_databases": [ + { + "name": "pico", + "build_dir": "build/build_pico_debug", + "cmake_definitions": { + "VAMP_PICO_FIRMWARE_PROJECT": True, + }, + "unset_env": [ + "CC", + "CXX", + ], + }, + ], + }, + } + normalized = modulus.config.normalize(config) + database = normalized["tooling"]["lsp"]["compile_databases"][0] + self.assertEqual(database["source_dir"], ".") + self.assertEqual(database["generator"], "Ninja") + self.assertEqual(database["env"], {}) + self.assertEqual(database["unset_env"], ["CC", "CXX"]) + + config["tooling"]["lsp"]["compile_databases"][0]["unset_env"] = ["bad-name"] + with self.assertRaisesRegex(modulus.config.config_error, "tooling.lsp.compile_databases\\[0\\].unset_env"): + modulus.config.normalize(config) + + def test_checked_in_profile_overrides_exported_default_profile(self): + with tempfile.TemporaryDirectory() as repo_dir: + repo = pathlib.Path(repo_dir) + self.write_config(repo, self.config()) + profile = repo / "conan" / "profiles" / "x86_64_gcc12_release" + profile.parent.mkdir(parents=True) + profile.write_text("[settings]\n", encoding="utf-8") + config = modulus.config.load_repo_config(repo) + self.assertEqual(config["tooling"]["targets"]["x86_64_gcc12_release"]["profile"], "conan/profiles/x86_64_gcc12_release") + + def test_docker_defaults_use_shared_modulus_marker(self): + config = modulus.config.normalize(self.config()) + self.assertEqual(config["tooling"]["docker"]["image"], "modulus-build:dev") + self.assertEqual(config["tooling"]["docker"]["marker"], "/opt/modulus-build-image") + self.assertNotIn("apt_architectures", config["tooling"]["docker"]) + self.assertNotIn("apt_packages", config["tooling"]["docker"]) + + def test_export_project_copies_runtime_and_exported_tools(self): + with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: + repo = pathlib.Path(repo_dir) + package = pathlib.Path(package_dir) + self.write_config(repo, self.config()) + self.write_package(package) + modulus.export.sync_project(repo, package) + self.assertFalse((repo / "modulus.py").exists()) + self.assertFalse((repo / "build" / "build_common" / "manifest.json").exists()) + self.assertTrue((repo / "build" / "build_tools" / "configure.py").is_file()) + self.assertTrue((repo / "build" / "build_tools" / "lsp.py").is_file()) + self.assertTrue((repo / "build" / "build_tools" / "run.py").is_file()) + self.assertTrue((repo / "build" / "build_tools" / "docker" / "Dockerfile").is_file()) + self.assertFalse((repo / "build" / "build_tools" / "docs").exists()) + self.assertFalse((repo / "build" / "build_tools" / "lsp").exists()) + for source_name, destination_name in modulus.export.editor_files.items(): + self.assertEqual((repo / destination_name).read_text(encoding="utf-8"), (package / "tools" / "editor" / source_name).read_text(encoding="utf-8")) + runtime = repo / "build" / "build_common" / "modulus" + self.assertTrue((runtime / "modulus" / "cli.py").is_file()) + self.assertTrue((runtime / "modulus" / "config.py").is_file()) + self.assertTrue((runtime / "modulus" / "export.py").is_file()) + self.assertFalse((runtime / "CMakeLists.txt").exists()) + self.assertFalse((runtime / "README.md").exists()) + self.assertFalse((runtime / "conanfile.py").exists()) + self.assertFalse((runtime / "cmake").exists()) + self.assertFalse((runtime / "include").exists()) + self.assertFalse((runtime / "schemas").exists()) + self.assertFalse((runtime / "src").exists()) + self.assertFalse((runtime / "tools").exists()) + self.assertFalse((runtime / "tests").exists()) + self.assertFalse((runtime / "package").exists()) + self.assertFalse((runtime / "lib").exists()) + self.assertFalse((runtime / "share").exists()) + self.assertFalse((runtime / "bin").exists()) + + def test_root_editor_files_match_export_templates(self): + root = pathlib.Path(__file__).resolve().parents[1] + for source_name, destination_name in modulus.export.editor_files.items(): + self.assertEqual((root / destination_name).read_text(encoding="utf-8"), (root / "tools" / "editor" / source_name).read_text(encoding="utf-8")) + + def test_clangd_template_uses_default_target_compile_database_and_disables_clang_tidy(self): + root = pathlib.Path(__file__).resolve().parents[1] + config = modulus.config.normalize(self.config()) + default_target = config["tooling"]["targets"][config["tooling"]["default_target"]] + template = (root / "tools" / "editor" / "clangd.yaml").read_text(encoding="utf-8") + self.assertIn(f"CompilationDatabase: {default_target['build_dir']}", template) + self.assertIn("Diagnostics:", template) + self.assertIn("ClangTidy:", template) + self.assertIn("Remove: ['*']", template) + self.assertNotIn("bugprone-*", template) + self.assertNotIn("performance-*", template) + self.assertNotIn("readability-*", template) + + def test_export_requires_packaged_tools(self): + with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as package_dir: + repo = pathlib.Path(repo_dir) + package = pathlib.Path(package_dir) + self.write_config(repo, self.config()) + (package / "modulus").mkdir() + (package / "modulus" / "cli.py").write_text("", encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "missing tools directory"): + modulus.export.sync_project(repo, package) + + def test_normalize_rejects_invalid_cmake_values(self): + config = self.config() + config["package"]["name"] = "BadName" + with self.assertRaisesRegex(modulus.config.config_error, "package.name"): + modulus.config.normalize(config) + config = self.config() + config["docs"] = {} + with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: docs"): + modulus.config.normalize(config) + + def test_unknown_top_level_config_keys_are_rejected(self): + config = self.config() + config["old_config"] = {} + with self.assertRaisesRegex(modulus.config.config_error, "unknown keys: old_config"): + modulus.config.normalize(config) + + def test_removed_setup_generation_fields_are_rejected(self): + config = self.config() + config["schema_version"] = 1 + with self.assertRaisesRegex(modulus.config.config_error, "schema_version"): + modulus.config.normalize(config) + config = self.config() + config["package"]["cmake_target"] = "sample::sample" + with self.assertRaisesRegex(modulus.config.config_error, "cmake_target"): + modulus.config.normalize(config) + config = self.config() + config["exports"] = ["include/*.hpp"] + with self.assertRaisesRegex(modulus.config.config_error, "exports"): + modulus.config.normalize(config) + config = self.config() + config["dependencies"] = [ + { + "ref": "openssl/4.0.1", + }, + ] + with self.assertRaisesRegex(modulus.config.config_error, "dependencies"): + modulus.config.normalize(config) + config = self.config() + config["examples"] = [ + { + "target": "sample-example", + }, + ] + with self.assertRaisesRegex(modulus.config.config_error, "examples"): + modulus.config.normalize(config) + config = self.config() + config["tooling"] = { + "docker": { + "apt_packages": ["libdrm-dev"], + }, + } + with self.assertRaisesRegex(modulus.config.config_error, "apt_packages"): + modulus.config.normalize(config) + config = self.config() + config["tooling"] = { + "targets": { + "native": { + "profile": "build/profile", + "build_dir": "build/native", + "options": { + "glfw/*:with_x11": False, + }, + }, + }, + } + with self.assertRaisesRegex(modulus.config.config_error, "options"): + modulus.config.normalize(config) + config = self.config() + config["tooling"] = { + "remote": { + "update_by_default": True, + }, + } + with self.assertRaisesRegex(modulus.config.config_error, "update_by_default"): + modulus.config.normalize(config) + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build.py b/tools/build.py new file mode 100755 index 0000000..97f44d6 --- /dev/null +++ b/tools/build.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import modulus_tool + +if __name__ == "__main__": + modulus_tool.run("build") diff --git a/tools/conan/profiles/arm_64_gcc12_debug b/tools/conan/profiles/arm_64_gcc12_debug new file mode 100755 index 0000000..a1e466d --- /dev/null +++ b/tools/conan/profiles/arm_64_gcc12_debug @@ -0,0 +1,14 @@ +include(raylib_drm_kms) + +[settings] +os=Linux +arch=armv8 +compiler=gcc +compiler.version=12 +compiler.libcxx=libstdc++11 +compiler.cppstd=23 +build_type=Debug + +[conf] +tools.cmake.cmaketoolchain:generator=Ninja +tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc", "cpp": "aarch64-linux-gnu-g++"} diff --git a/tools/conan/profiles/arm_64_gcc12_release b/tools/conan/profiles/arm_64_gcc12_release new file mode 100755 index 0000000..c414913 --- /dev/null +++ b/tools/conan/profiles/arm_64_gcc12_release @@ -0,0 +1,14 @@ +include(raylib_drm_kms) + +[settings] +os=Linux +arch=armv8 +compiler=gcc +compiler.version=12 +compiler.libcxx=libstdc++11 +compiler.cppstd=23 +build_type=Release + +[conf] +tools.cmake.cmaketoolchain:generator=Ninja +tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc", "cpp": "aarch64-linux-gnu-g++"} diff --git a/tools/conan/profiles/raylib_drm_kms b/tools/conan/profiles/raylib_drm_kms new file mode 100644 index 0000000..a0686e7 --- /dev/null +++ b/tools/conan/profiles/raylib_drm_kms @@ -0,0 +1,4 @@ +[conf] +raylib/*:tools.info.package_id:confs=["tools.cmake.cmaketoolchain:enabled_blocks", "tools.cmake.cmaketoolchain:extra_variables"] +raylib/*:tools.cmake.cmaketoolchain:enabled_blocks=["user_toolchain", "generic_system", "compilers", "fpic", "arch_flags", "rpath_link_flags", "libcxx", "cppstd", "parallel", "extra_flags", "cmake_flags_init", "extra_variables", "try_compile", "find_paths", "pkg_config", "rpath", "shared", "output_dirs"] +raylib/*:tools.cmake.cmaketoolchain:extra_variables={"PLATFORM": {"value": "DRM", "cache": True, "type": "STRING", "force": True}, "USE_EXTERNAL_GLFW": {"value": "OFF", "cache": True, "type": "BOOL", "force": True}, "OPENGL_VERSION": {"value": "ES 2.0", "cache": True, "type": "STRING", "force": True}, "BUILD_EXAMPLES": {"value": "OFF", "cache": True, "type": "BOOL", "force": True}, "WITH_PIC": {"value": "ON", "cache": True, "type": "BOOL", "force": True}, "CUSTOMIZE_BUILD": {"value": "ON", "cache": True, "type": "BOOL", "force": True}, "SUPPORT_MODULE_RAUDIO": {"value": "OFF", "cache": True, "type": "BOOL", "force": True}, "SUPPORT_CUSTOM_FRAME_CONTROL": {"value": "ON", "cache": True, "type": "BOOL", "force": True}, "SUPPORT_CAMERA_SYSTEM": {"value": "OFF", "cache": True, "type": "BOOL", "force": True}, "SUPPORT_GESTURES_SYSTEM": {"value": "ON", "cache": True, "type": "BOOL", "force": True}, "SUPPORT_RPRAND_GENERATOR": {"value": "OFF", "cache": True, "type": "BOOL", "force": True}} diff --git a/tools/conan/profiles/x86_64_gcc12_debug b/tools/conan/profiles/x86_64_gcc12_debug new file mode 100755 index 0000000..9f24f46 --- /dev/null +++ b/tools/conan/profiles/x86_64_gcc12_debug @@ -0,0 +1,14 @@ +include(raylib_drm_kms) + +[settings] +os=Linux +arch=x86_64 +compiler=gcc +compiler.version=12 +compiler.libcxx=libstdc++11 +compiler.cppstd=23 +build_type=Debug + +[conf] +tools.cmake.cmaketoolchain:generator=Ninja +tools.build:compiler_executables={"c": "gcc-12", "cpp": "g++-12"} diff --git a/tools/conan/profiles/x86_64_gcc12_release b/tools/conan/profiles/x86_64_gcc12_release new file mode 100755 index 0000000..bd3ae42 --- /dev/null +++ b/tools/conan/profiles/x86_64_gcc12_release @@ -0,0 +1,14 @@ +include(raylib_drm_kms) + +[settings] +os=Linux +arch=x86_64 +compiler=gcc +compiler.version=12 +compiler.libcxx=libstdc++11 +compiler.cppstd=23 +build_type=Release + +[conf] +tools.cmake.cmaketoolchain:generator=Ninja +tools.build:compiler_executables={"c": "gcc-12", "cpp": "g++-12"} diff --git a/tools/configure.py b/tools/configure.py new file mode 100755 index 0000000..a639166 --- /dev/null +++ b/tools/configure.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import modulus_tool + +if __name__ == "__main__": + modulus_tool.run("configure") diff --git a/tools/docker/Dockerfile b/tools/docker/Dockerfile new file mode 100755 index 0000000..f0fc403 --- /dev/null +++ b/tools/docker/Dockerfile @@ -0,0 +1,48 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN dpkg --add-architecture arm64 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + clangd \ + cmake \ + curl \ + g++-12 \ + g++-aarch64-linux-gnu \ + gcc-12 \ + gcc-aarch64-linux-gnu \ + git \ + libdrm-dev \ + libdrm-dev:arm64 \ + libegl-dev \ + libegl-dev:arm64 \ + libgbm-dev \ + libgbm-dev:arm64 \ + libgles-dev \ + libgles-dev:arm64 \ + libglvnd-dev \ + libglvnd-dev:arm64 \ + ninja-build \ + pkg-config \ + python3 \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=/usr/bin/gcc-12 +ENV CXX=/usr/bin/g++-12 + +RUN python3 -m venv /opt/modulus-python + +ENV PATH=/opt/modulus-python/bin:${PATH} + +RUN python -m pip install --no-cache-dir \ + conan==2.26.2 + +WORKDIR /workspace + +RUN mkdir -p /opt && touch /opt/modulus-build-image diff --git a/tools/editor/clang-format b/tools/editor/clang-format new file mode 100644 index 0000000..e384528 --- /dev/null +++ b/tools/editor/clang-format @@ -0,0 +1 @@ +DisableFormat: true diff --git a/tools/editor/clangd.yaml b/tools/editor/clangd.yaml new file mode 100644 index 0000000..7703ad6 --- /dev/null +++ b/tools/editor/clangd.yaml @@ -0,0 +1,9 @@ +CompileFlags: + CompilationDatabase: build/build_x86_debug + Add: + - -Wall + - -Wextra + +Diagnostics: + ClangTidy: + Remove: ['*'] diff --git a/tools/editor/pyrightconfig.json b/tools/editor/pyrightconfig.json new file mode 100644 index 0000000..17499c9 --- /dev/null +++ b/tools/editor/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "include": [ + "modulus.py", + "package/conanfile.py", + "build/build_tools" + ], + "extraPaths": [ + "build/build_common/modulus", + "." + ], + "typeCheckingMode": "off", + "reportMissingImports": false, + "reportMissingModuleSource": false +} diff --git a/tools/lsp.py b/tools/lsp.py new file mode 100755 index 0000000..5c858b6 --- /dev/null +++ b/tools/lsp.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import modulus_tool + +if __name__ == "__main__": + modulus_tool.run("lsp") diff --git a/tools/modulus_tool.py b/tools/modulus_tool.py new file mode 100644 index 0000000..e0d9266 --- /dev/null +++ b/tools/modulus_tool.py @@ -0,0 +1,20 @@ +import pathlib +import sys + +def repo_root(): + current = pathlib.Path(__file__).resolve() + for candidate in (current.parent, *current.parents): + if (candidate / "modulus.json").is_file(): + return candidate + raise SystemExit("could not find modulus.json from Modulus tool wrapper") + +def run(command): + root = repo_root() + runtime_root = root / "build" / "build_common" / "modulus" + source_root = root / "modulus" / "cli.py" + if source_root.is_file(): + sys.path.insert(0, str(root)) + elif runtime_root.exists(): + sys.path.insert(0, str(runtime_root)) + import modulus.cli + raise SystemExit(modulus.cli.main(command)) diff --git a/tools/package.py b/tools/package.py new file mode 100755 index 0000000..0359fd9 --- /dev/null +++ b/tools/package.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import modulus_tool + +if __name__ == "__main__": + modulus_tool.run("package") diff --git a/tools/run.py b/tools/run.py new file mode 100755 index 0000000..7042ab6 --- /dev/null +++ b/tools/run.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import modulus_tool + +if __name__ == "__main__": + modulus_tool.run("run") diff --git a/tools/test.py b/tools/test.py new file mode 100755 index 0000000..9ccfeff --- /dev/null +++ b/tools/test.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import modulus_tool + +if __name__ == "__main__": + modulus_tool.run("test")