From 77106e61c950b2686d35ecb04de978e8baaf9254 Mon Sep 17 00:00:00 2001 From: PeterC Date: Tue, 11 Aug 2026 13:52:38 +0200 Subject: [PATCH 1/2] [MAJOR] Harden template ownership and development workflows - Keep wrapper maintenance explicit and assemble Python packages entirely in build-owned trees. - Namespace nested feature options, deliver portable TensorRT discovery, and stage authoritative version and source-package metadata. - Preserve host ownership for Docker and Podman while adding VS Code and MATLAB container support plus isolated MATLAB library tooling. - Cover cleanup, packaging, containers, nested options, TensorRT, ROS facades, and late CPack builds with focused regressions and documentation. --- AGENTS.md | 3 + CMakeLists.txt | 194 +++----- README.md | 88 +++- build_lib.sh | 112 ++--- cmake/FindTensorRT.cmake | 173 +++++++ cmake/HandleMatlabWrapper.cmake | 7 + cmake/HandlePythonWrapper.cmake | 105 ++-- cmake/HandleWrapper.cmake | 92 +--- cmake/RefreshCPackSourceIgnores.cmake.in | 130 +++++ cmake/StagePackageVersion.cmake.in | 12 + doc/build_script_doc.md | 26 +- doc/ros2_overlay.md | 9 +- doc/template_usage.md | 11 + doc/versioning.md | 30 +- doc/wrappers.md | 34 +- python/pyproject.toml.in | 2 +- ros2/template_project/CMakeLists.txt | 4 +- run_in_container.sh | 328 +++++++++++-- scripts/use_system_matlab_libraries.sh | 460 ++++++++++++++++++ src/CMakeLists.txt | 22 +- src/cmake/template_projectConfig.cmake.in | 8 + tests/CMakeLists.txt | 52 ++ ...fyTemplateProjectBuildLibCleanSafety.cmake | 39 ++ ...TemplateProjectNestedOptionIsolation.cmake | 187 +++++++ ...VerifyTemplateProjectPythonPackaging.cmake | 85 +++- .../VerifyTemplateProjectReleaseTagSync.cmake | 27 + .../VerifyTemplateProjectRos2Overlay.cmake | 39 ++ .../VerifyTemplateProjectTensorRTModule.cmake | 132 +++++ tests/scripts/test_run_in_container.sh | 158 ++++++ .../test_use_system_matlab_libraries.sh | 240 +++++++++ tests/scripts/test_wrapper_maintenance.sh | 186 +++++++ 31 files changed, 2592 insertions(+), 403 deletions(-) create mode 100644 cmake/FindTensorRT.cmake create mode 100644 cmake/RefreshCPackSourceIgnores.cmake.in create mode 100644 cmake/StagePackageVersion.cmake.in create mode 100755 scripts/use_system_matlab_libraries.sh create mode 100644 tests/cmake/VerifyTemplateProjectNestedOptionIsolation.cmake create mode 100644 tests/cmake/VerifyTemplateProjectTensorRTModule.cmake create mode 100644 tests/scripts/test_run_in_container.sh create mode 100755 tests/scripts/test_use_system_matlab_libraries.sh create mode 100644 tests/scripts/test_wrapper_maintenance.sh diff --git a/AGENTS.md b/AGENTS.md index 79d47be..55febbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,9 @@ tests part of the derived-project contract. included in a wheel. - Keep CMake Python install destinations relative to `CMAKE_INSTALL_PREFIX`; pip owns installation into an active environment. +- Wrapper checkout updates, submodule initialization, and submodule creation + are explicit maintenance operations. Ordinary configure and build commands + must not move the wrapper checkout or change the parent repository gitlinks. For MATLAB: Use classes a lot also in MATLAB, with a python style, but do it only when it makes sense. Functions in MATLAB are often more efficient. Evaluate whether it makes sense to have stateful implementation. Use "self" instead of "obj". All variables names must specify the datatype of the variable since MATLAB does not (hungarian notation). The following list applies: d for double, f for float, b for bool, str for struct and not for strings, char for strings and chars, ui8 for uint8, i8 for int8. All the other integers are similar to the latter. Specify "obj" as prefix if an object, cell if a cell, table if a table; "bus_" if a Simulink bus. The names are always in Pascal case including the prefix, for instance ui8MyVariable. Never nest functions definitions within other functions, always do them separate or at most in the same file (after the main function implementation). Add them as local in the same function file only when not re-used elsewhere, otherwise prefer a single implementation. Function names and static methods of classes starts with Capital letter. Local functions names ends with underscore meaning "private". Names of variables must be explicative and tell what the variable does. Short names are not allowed unless "very local in scope". Use underscore for those variables and preferably Tmp within the name. For codes that are intended to be algorithms of some kind (e.g. not plots or things to run on the host PC), make them always MATLAB codegen safe (especially if codegen directive is used). In that case names should be limited to 31 chars. Add the same template of doc to functions as below and always specify arguments-end block for input and output: %% SIGNATURE diff --git a/CMakeLists.txt b/CMakeLists.txt index 032b09a..231d101 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,14 +19,63 @@ set(PROJECT_MAINTAINER_NAME "Pietro Califano" CACHE STRING "Project maintainer n set(PROJECT_MAINTAINER_EMAIL "petercalifano.gs@gmail.com" CACHE STRING "Project maintainer email") set(PROJECT_LICENSE "MIT" CACHE STRING "Project SPDX license identifier") +# Determine ownership before project() so pre-language feature selection cannot +# consume unrelated generic cache options from an add_subdirectory() parent. +set(BUILD_AS_MAIN_PROJECT OFF) +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(BUILD_AS_MAIN_PROJECT ON) +endif() + # Set install default directory if not specified if(NOT DEFINED CMAKE_INSTALL_PREFIX) set(CMAKE_INSTALL_PREFIX ${CMAKE_SOURCE_DIR}/install CACHE PATH "Install path" FORCE) endif() -option(PROJECT_METADATA_ONLY "Configure only project identity and version metadata" OFF) -option(ENABLE_OPTIX "Enable OptiX" OFF) -option(ENABLE_CUDA "Enable CUDA" OFF) +# Project-qualified options are canonical in nested builds. Preserve the +# historical generic spellings only as one-config top-level input aliases. +set(METADATA_ONLY_OPTION_NAME "${project_name}_METADATA_ONLY") +set(ENABLE_OPTIX_OPTION_NAME "${project_name}_ENABLE_OPTIX") +set(ENABLE_CUDA_OPTION_NAME "${project_name}_ENABLE_CUDA") + +function(_template_project_migrate_top_level_bool_option_alias legacy_option + canonical_option option_help) + if(NOT BUILD_AS_MAIN_PROJECT) + return() + endif() + + get_property(_legacy_alias_is_cached + CACHE "${legacy_option}" PROPERTY TYPE SET) + if(NOT _legacy_alias_is_cached) + return() + endif() + + # Consume legacy command-line/cache input on every configure so an ON-to-OFF + # transition updates the canonical value instead of remaining stuck. + set(_legacy_alias_value "${${legacy_option}}") + set("${canonical_option}" "${_legacy_alias_value}" + CACHE BOOL "${option_help}" FORCE) + unset("${legacy_option}" CACHE) +endfunction() + +_template_project_migrate_top_level_bool_option_alias( + PROJECT_METADATA_ONLY "${METADATA_ONLY_OPTION_NAME}" + "Configure only project identity and version metadata") +_template_project_migrate_top_level_bool_option_alias( + ENABLE_OPTIX "${ENABLE_OPTIX_OPTION_NAME}" "Enable OptiX") +_template_project_migrate_top_level_bool_option_alias( + ENABLE_CUDA "${ENABLE_CUDA_OPTION_NAME}" "Enable CUDA") + +option(${METADATA_ONLY_OPTION_NAME} + "Configure only project identity and version metadata" + OFF) +option(${ENABLE_OPTIX_OPTION_NAME} "Enable OptiX" OFF) +option(${ENABLE_CUDA_OPTION_NAME} "Enable CUDA" OFF) + +# Retained modules consume the historical local variable names. Normal +# directory-scope assignments isolate them from a parent's cache entries. +set(PROJECT_METADATA_ONLY "${${METADATA_ONLY_OPTION_NAME}}") +set(ENABLE_OPTIX "${${ENABLE_OPTIX_OPTION_NAME}}") +set(ENABLE_CUDA "${${ENABLE_CUDA_OPTION_NAME}}") if(PROJECT_METADATA_ONLY) set(languages NONE) @@ -85,12 +134,6 @@ endif() write_build_VERSION_file() install(FILES "${PROJECT_BINARY_DIR}/VERSION" DESTINATION ".") -# Define variable for build as subproject (compare source dir) -set(BUILD_AS_MAIN_PROJECT ON) -if(NOT CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) - set(BUILD_AS_MAIN_PROJECT OFF) -endif() - # Define variable for namespacing (required to prevent global target clashes!) set(LIB_NAMESPACE "${PROJECT_NAME}") if (DEFINED LIB_NAMESPACE_OVERRIDE) @@ -545,8 +588,25 @@ if(DEFINED FULL_VERSION AND NOT "${FULL_VERSION}" STREQUAL "") endif() set(CPACK_GENERATOR "TGZ") set(CPACK_SOURCE_GENERATOR "TGZ") + +# VERSION is generated in the build tree so configured package metadata remains +# authoritative even when the ignored source-tree fallback is stale. Stage that +# exact file through CPack's private install prefix for binary and source TGZs. +set(_cpack_stage_version_script + "${PROJECT_BINARY_DIR}/StagePackageVersion.cmake") +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/StagePackageVersion.cmake.in" + "${_cpack_stage_version_script}" + @ONLY) +if(CMAKE_VERSION VERSION_LESS "3.16") + set(CPACK_INSTALL_SCRIPT "${_cpack_stage_version_script}") +else() + list(APPEND CPACK_INSTALL_SCRIPTS "${_cpack_stage_version_script}") +endif() + # Preserve source-ignore regexes verbatim and anchor known generated outputs -# beneath this checkout rather than matching adjacent directories. +# beneath this checkout rather than matching adjacent directories. The ignored +# source VERSION must not overwrite the generated file staged above. set(CPACK_VERBATIM_VARIABLES YES) set(_cpack_source_root_regex "${CMAKE_CURRENT_SOURCE_DIR}") string( @@ -554,113 +614,21 @@ string( _cpack_source_root_regex "${_cpack_source_root_regex}") set(CPACK_SOURCE_IGNORE_FILES "^${_cpack_source_root_regex}/(.*/)?\\.git(/|$)" + "^${_cpack_source_root_regex}/VERSION$" "^${_cpack_source_root_regex}/install/" "^${_cpack_source_root_regex}/ros2/(build|install|log)/" "^${_cpack_source_root_regex}/(.*/)?\\.pytest_cache/" "^${_cpack_source_root_regex}/(.*/)?__pycache__/" "^${_cpack_source_root_regex}/.*\\.py[cod]$") -# Exclude the active nested binary tree and other nested CMake builds whose -# caches prove that this exact checkout owns them. Directory names alone are -# insufficient because paths such as tools/build_helpers may be real sources. -get_filename_component( - _cpack_source_root_real - "${CMAKE_CURRENT_SOURCE_DIR}" - REALPATH) -get_filename_component( - _cpack_binary_root_real - "${CMAKE_BINARY_DIR}" - REALPATH) -file( - RELATIVE_PATH - _cpack_binary_relative_to_source - "${_cpack_source_root_real}" - "${_cpack_binary_root_real}") -set(_cpack_owned_build_directories) -if(NOT IS_ABSOLUTE "${_cpack_binary_relative_to_source}" - AND NOT "${_cpack_binary_relative_to_source}" MATCHES "^\\.\\.(/|$)" - AND NOT "${_cpack_binary_relative_to_source}" STREQUAL "") - list(APPEND - _cpack_owned_build_directories - "${CMAKE_BINARY_DIR}") -endif() - -file( - GLOB_RECURSE - _cpack_cache_candidates - LIST_DIRECTORIES FALSE - "${CMAKE_CURRENT_SOURCE_DIR}/*/CMakeCache.txt") -foreach(_cpack_cache_candidate IN LISTS _cpack_cache_candidates) - file( - RELATIVE_PATH - _cpack_cache_relative_to_source - "${CMAKE_CURRENT_SOURCE_DIR}" - "${_cpack_cache_candidate}") - if("${_cpack_cache_relative_to_source}" MATCHES "^install/" - OR "${_cpack_cache_relative_to_source}" - MATCHES "^ros2/(build|install|log)/") - continue() - endif() - - set(_cpack_cache_is_within_owned_build FALSE) - foreach(_cpack_known_build IN LISTS _cpack_owned_build_directories) - file( - RELATIVE_PATH - _cpack_cache_relative_to_build - "${_cpack_known_build}" - "${_cpack_cache_candidate}") - if(NOT IS_ABSOLUTE "${_cpack_cache_relative_to_build}" - AND NOT "${_cpack_cache_relative_to_build}" MATCHES "^\\.\\.(/|$)") - set(_cpack_cache_is_within_owned_build TRUE) - break() - endif() - endforeach() - if(_cpack_cache_is_within_owned_build - OR NOT EXISTS "${_cpack_cache_candidate}") - continue() - endif() - - file( - STRINGS - "${_cpack_cache_candidate}" - _cpack_cache_home_entries - REGEX "^CMAKE_HOME_DIRECTORY:INTERNAL=" - LIMIT_COUNT 1) - if(NOT _cpack_cache_home_entries) - continue() - endif() - - list(GET _cpack_cache_home_entries 0 _cpack_cache_home_entry) - string(REGEX MATCH - "^CMAKE_HOME_DIRECTORY:INTERNAL=(.*)$" - _cpack_cache_home_match - "${_cpack_cache_home_entry}") - set(_cpack_cache_home "${CMAKE_MATCH_1}") - get_filename_component( - _cpack_cache_home_real - "${_cpack_cache_home}" - REALPATH) - if(NOT "${_cpack_cache_home_real}" STREQUAL "${_cpack_source_root_real}") - continue() - endif() - - get_filename_component( - _cpack_owned_build_directory - "${_cpack_cache_candidate}" - DIRECTORY) - list(APPEND - _cpack_owned_build_directories - "${_cpack_owned_build_directory}") -endforeach() - -list(REMOVE_DUPLICATES _cpack_owned_build_directories) -foreach(_cpack_owned_build_directory IN LISTS _cpack_owned_build_directories) - string( - REGEX REPLACE "([][+.*^$()|?\\\\])" "\\\\\\1" - _cpack_owned_build_regex "${_cpack_owned_build_directory}") - list( - APPEND - CPACK_SOURCE_IGNORE_FILES - "^${_cpack_owned_build_regex}(/|$)") -endforeach() +# Refresh checkout-owned build exclusions when CPack runs. A generated build +# can appear after this configure, while path names alone remain insufficient +# evidence because build-prefixed directories may be legitimate sources. +set(_cpack_refresh_source_ignores_script + "${PROJECT_BINARY_DIR}/RefreshCPackSourceIgnores.cmake") +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/RefreshCPackSourceIgnores.cmake.in" + "${_cpack_refresh_source_ignores_script}" + @ONLY) +set(CPACK_PROJECT_CONFIG_FILE "${_cpack_refresh_source_ignores_script}") include(CPack) diff --git a/README.md b/README.md index 1faf6ca..d27471f 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ All options are passed via `build_lib.sh` flags or directly as `-D=` t ### `build_lib.sh` reference ``` --B, --buildpath Build directory (default: ./build) +-B, --buildpath Build directory (default: /build) -t, --type debug | release | relwithdebinfo | minsizerel -j, --jobs Parallel jobs (default: nproc or 4) -r, --rebuild-only Skip CMake configure; rebuild sources only @@ -177,9 +177,11 @@ All options are passed via `build_lib.sh` flags or directly as `-D=` t --ctest-extra-args Simple whitespace-split arguments appended to CTest --gtwrap-root Path to local wrap checkout root - --no-wrap-update Disable auto-update of local wrap checkout to latest master + --wrap-update Explicitly update a local wrap checkout to latest master + --no-wrap-update Keep the local wrap checkout unchanged (default) + --wrap-submodule-init Explicitly initialize a declared wrap submodule fallback --no-wrap-submodule-init - Disable wrap submodule initialization fallback + Do not initialize a wrap submodule (default) --toolchain CMake toolchain file -h, --help Show full help ``` @@ -188,14 +190,17 @@ See [`doc/build_script_doc.md`](doc/build_script_doc.md) for a detailed option r `--clean` accepts only conventional in-repository `build`, `build*`, or `out/*` paths. An existing directory must contain a CMake cache owned by this -checkout. The option is ignored with `--rebuild-only`. +checkout. Relative paths remain anchored to the checkout containing the script, +including when it is invoked from another working directory. The option is +ignored with `--rebuild-only`. ### CMake feature flags | Option | Default | Description | |---|---|---| -| `ENABLE_CUDA` | OFF | CUDA GPU acceleration | -| `ENABLE_OPTIX` | OFF | NVIDIA OptiX (enables CUDA automatically) | +| `template_project_ENABLE_CUDA` | OFF | CUDA GPU acceleration | +| `template_project_ENABLE_OPTIX` | OFF | NVIDIA OptiX (enables CUDA automatically) | +| `template_project_METADATA_ONLY` | OFF | Configure project identity/version without compiler languages | | `ENABLE_TBB` | OFF | Intel oneTBB support (`find_package(TBB)`) | | `ENABLE_OPENGL` | OFF | OpenGL support | | `ENABLE_TESTS` | ON | Register and run CTest tests | @@ -226,6 +231,14 @@ checkout. The option is ignored with `--rebuild-only`. | `NO_OPTIMIZATION` | OFF | Force profiler-friendly `-O0 -g3`, frame pointers, and assertions regardless of build type | | `WARNINGS_ARE_ERRORS` | OFF | Treat all warnings as errors (`-Werror`) | +Replace the `template_project` prefix during tailoring. The historical +`ENABLE_CUDA`, `ENABLE_OPTIX`, and `PROJECT_METADATA_ONLY` options remain +top-level compatibility aliases; nested consumers must use the project-qualified +forms so parent cache options cannot change the library configuration. A legacy +alias supplied to a top-level configure wins for that invocation, is copied to +the canonical option, and is then removed from the cache so later reconfigures +cannot retain two conflicting sources of truth. + ### Build type compiler flags | Build type | Flags | Notes | @@ -322,10 +335,14 @@ When `-p` and/or `-m` is used, wrapper resolution now follows this order: 3. If still unresolved and `GTWRAP_INIT_SUBMODULE_IF_MISSING=ON`, initialize a declared `wrap` or `lib/wrap` git submodule and use that checkout. -Existing local wrap roots are updated to latest `origin/master` by default. This -includes detached/tag states by switching/creating local `master` from -`origin/master`. Pass `--no-wrap-update` to disable that update step, or -`--no-wrap-submodule-init` to disable the submodule fallback entirely. +Wrapper checkout maintenance is disabled by default. Pass `--wrap-update` to +explicitly advance a resolved local checkout to `origin/master`, or +`--wrap-submodule-init` to initialize a declared submodule after local and +installed discovery fail. Direct CMake callers must grant checkout maintenance +with `GTWRAP_MAINTENANCE_UPDATE=ON` as well as requesting +`GTWRAP_SYNC_TO_MASTER=ON`. Submodule initialization applies only to a `wrap` +or `lib/wrap` entry already declared in `.gitmodules`; adding a new submodule is +a separate Git maintenance operation. ### Prerequisites @@ -371,7 +388,7 @@ Wrapper generators produce different C++ files by design: ### Python package install workflow Python package metadata is owned by `python/pyproject.toml.in` and configured -into `python/pyproject.toml` when Python wrapping is requested. +into `/python/pyproject.toml` when Python wrapping is requested. The optional `setup.py.in` augments installation behavior without duplicating package name/version metadata. @@ -388,18 +405,18 @@ The checked-in `python//__init__.py` is the public package entrypoint: - `HAS_WRAPPER` is `False` when the pure-Python package imports without the wrapper. - `WRAPPER_IMPORT_ERROR` stores the wrapper import exception when fallback is active. -When Python wrapping is requested, the source package becomes the public install -entrypoint. CMake updates it with: +When Python wrapping is requested, CMake assembles a disposable package root +without updating the source checkout: -- generated `python/pyproject.toml` -- generated `python/setup.py` -- build-time `python//_wrapper_build.py` linking the latest +- generated `/python/pyproject.toml` +- generated `/python/setup.py` +- build-time `/python//_wrapper_build.py` linking the latest successfully staged wrapper configuration -Install from the source Python package directory: +Install from the configured build package directory: ```bash -cd python +cd build/python python -m pip install . ``` @@ -559,6 +576,41 @@ The image in `.devcontainer/Dockerfile` can be built and used outside the DevCon docker build --build-arg INSTALL_CUDA=on --build-arg CUDA_VERSION=12.9 -t my-dev .devcontainer ``` +Command mode runs with the host numeric UID and GID and uses `/tmp` as its +writable home. Files created through the `/workspace` bind mount therefore +remain owned by the host user instead of root. Rootless Podman additionally +uses its `keep-id` user namespace. + +### Attach VS Code to a launcher-managed container + +Use `--vscode` to start a stable container before selecting +`Dev Containers: Attach to Running Container...`: + +```bash +./run_in_container.sh --vscode --engine podman +``` + +Attachment mode mounts the repository under `/workspaces/`, +preserves bind-mount ownership, and forwards a live SSH-agent socket when one +is available. The launcher prints the `workspaceFolder` and `remoteUser` +values for the first attachment. This mode builds the Dockerfile directly, so +features declared only in `devcontainer.json` are not applied; use the normal +Dev Containers create/reopen workflow when those features are required. +Docker attachment mode also requires the image's `vscode` UID and GID to match +the host user; the launcher rejects a mismatch rather than creating files with +ambiguous ownership. + +Expose a host MATLAB installation to wrapper configuration with: + +```bash +./run_in_container.sh --vscode --engine podman \ + --matlab-root /usr/local/MATLAB/R2024b +``` + +The installation is mounted read-only at the same absolute path and exported +as `MATLAB_ROOT_DIR`. Because mounts are fixed at container creation, stop and +recreate an existing attachment container before changing the MATLAB root. + --- ## Documentation diff --git a/build_lib.sh b/build_lib.sh index 01e4173..0c9f2f4 100755 --- a/build_lib.sh +++ b/build_lib.sh @@ -8,6 +8,8 @@ set -Eeuo pipefail IFS=$'\n\t' # Narrows word splitting to newlines and tabs (safe with spaces) # --- Defaults --- +script_path="$(realpath -- "${BASH_SOURCE[0]}")" +project_root="$(dirname "${script_path}")" buildpath="build" jobs="${JOBS:-$(command -v nproc >/dev/null 2>&1 && nproc || echo 4)}" @@ -26,8 +28,8 @@ clean_first=false profiling=false toolchain_file="" gtwrap_root="" -wrap_update=true -wrap_submodule_init=true +wrap_update=false +wrap_submodule_init=false wrap_branch="master" python_test_conda_env="" python_test_conda_prefix="" @@ -36,7 +38,7 @@ ctest_extra_args="" cmake_defines=() detect_project_name() { - local _cmakelists="CMakeLists.txt" + local _cmakelists="${project_root}/CMakeLists.txt" local _name="" if [[ -f "$_cmakelists" ]]; then _name="$(sed -nE 's/^[[:space:]]*set[[:space:]]*[(][[:space:]]*project_name[[:space:]]+"?([^" )]+)"?.*/\1/p' "$_cmakelists" | head -n1)" @@ -109,7 +111,7 @@ warn_python_wrapper_absent() { detect_wrap_root() { local _candidate - for _candidate in "./wrap" "./lib/wrap" "../wrap"; do + for _candidate in "${project_root}/wrap" "${project_root}/lib/wrap" "${project_root}/../wrap"; do if [[ -f "${_candidate}/cmake/PybindWrap.cmake" ]]; then (cd "${_candidate}" && pwd -P) return 0 @@ -118,61 +120,13 @@ detect_wrap_root() { return 1 } -update_wrap_checkout() { - local _root="$1" - local _branch="$2" - - if [[ ! -d "${_root}/.git" ]]; then - warn "wrap root '${_root}' is not a git checkout; skipping master update" - return 0 - fi - - if ! command -v git >/dev/null 2>&1; then - warn "git not found; skipping wrap checkout update" - return 0 - fi - - info "Updating wrap checkout '${_root}' to latest origin/${_branch}" - if ! git -C "${_root}" remote get-url origin >/dev/null 2>&1; then - warn "wrap checkout '${_root}' has no 'origin' remote; skipping update" - return 0 - fi - - if ! git -C "${_root}" fetch origin "${_branch}"; then - warn "failed to fetch origin/${_branch} for wrap checkout '${_root}'; continuing with local state" - return 0 - fi - if ! git -C "${_root}" show-ref --verify --quiet "refs/remotes/origin/${_branch}"; then - warn "origin/${_branch} not found in wrap checkout '${_root}'; continuing with local state" - return 0 - fi - - if git -C "${_root}" show-ref --verify --quiet "refs/heads/${_branch}"; then - if ! git -C "${_root}" checkout "${_branch}"; then - warn "failed to checkout wrap branch '${_branch}'; continuing with local state" - return 0 - fi - else - # Handle detached HEAD/tag clones by creating local branch from origin. - if ! git -C "${_root}" checkout -B "${_branch}" "origin/${_branch}"; then - warn "failed to create local wrap branch '${_branch}'; continuing with local state" - return 0 - fi - fi - - if ! git -C "${_root}" pull --ff-only origin "${_branch}"; then - warn "failed to fast-forward wrap branch '${_branch}'; continuing with local state" - return 0 - fi -} - # Helper function to print instructions usage() { cat <<'USAGE' Usage: build_lib.sh [OPTIONS] Options: - -B, --buildpath Build directory (default: ./build) + -B, --buildpath Build directory (default: /build) -j, --jobs Parallel build jobs (default: $(nproc or 4)) -r, --rebuild-only Skip CMake configure; build existing tree only -t, --type|--type-build Build type: debug|release|relwithdebinfo|minsizerel @@ -185,9 +139,11 @@ Options: -m, --matlab-wrap Enable MATLAB wrapper defaults (-DGTWRAP_BUILD_MATLAB_DEFAULT=ON) --gtwrap-root Path to wrap checkout root for gtwrap (maps to -D_GTWRAP_ROOT_DIR=) - --no-wrap-update Disable auto-update of local wrap checkout to latest master + --wrap-update Explicitly update a local wrap checkout to latest master + --no-wrap-update Keep the local wrap checkout unchanged (default) + --wrap-submodule-init Explicitly initialize a declared wrap submodule fallback --no-wrap-submodule-init - Disable wrap submodule initialization fallback + Do not initialize a wrap submodule (default) -i, --install Run "install" target after tests -N, --ninja-build Use Ninja generator (requires `ninja`) -n, --no-optim Set -DNO_OPTIMIZATION=ON in the CMake cache @@ -223,11 +179,14 @@ Notes: directory was already configured with those wrappers enabled. * "--clean" is ignored with "--rebuild-only". Otherwise it accepts only conventional in-repository paths owned by this checkout's CMake cache. + * Relative build paths resolve against the checkout containing this script, + even when the script is invoked from another working directory. * The default wrapper interface file is "src/wrap_interface.i". If it is missing, wrapper generation is auto-disabled unless you pass a valid *_WRAPPER_INTERFACE_FILES or *_WRAPPER_AUTODISCOVER_INTERFACE_FILES option. * If no local wrap checkout is found, CMake tries find_package(gtwrap) before optionally initializing a declared wrap submodule. + * Wrapper checkout updates and submodule initialization are opt-in operations. * This script requires GNU getopt (standard on Debian/Ubuntu). USAGE } @@ -238,20 +197,18 @@ info() { echo -e "\e[34m[INFO]\e[0m $*"; } # Print info warn() { echo -e "\e[33m[WARN]\e[0m $*"; } # Print warning trap 'echo -e "\e[31mBuild failed (line $LINENO).\e[0m"' ERR # Exit condition -# Normalize the requested clean path and prove that an existing directory is a -# conventional CMake build owned by the checkout in the current directory. +# Prove that an existing recursive-removal target is a conventional CMake build +# owned by the checkout containing this script. validate_clean_build_path() { - local project_root_ local relative_buildpath_ local build_cache_ local cached_source_dir_ # Constrain recursive removal to one CMake build owned by this checkout. - project_root_="$(pwd -P)" buildpath="$(realpath -m "$buildpath")" - relative_buildpath_="${buildpath#"${project_root_}/"}" + relative_buildpath_="${buildpath#"${project_root}/"}" if [[ "$relative_buildpath_" == "$buildpath" ]]; then - die "--clean requires a build directory inside '${project_root_}'" + die "--clean requires a build directory inside '${project_root}'" fi case "$relative_buildpath_" in build|build/*|build[^/]*|out/*) ;; @@ -271,7 +228,7 @@ validate_clean_build_path() { [[ -n "$cached_source_dir_" ]] || die "CMake source marker is missing from '$build_cache_'" cached_source_dir_="$(realpath -m "$cached_source_dir_")" - [[ "$cached_source_dir_" == "$project_root_" ]] || + [[ "$cached_source_dir_" == "$project_root" ]] || die "Refusing to clean a build owned by '$cached_source_dir_'" fi } @@ -282,7 +239,7 @@ if ! command -v getopt > /dev/null 2>&1; then fi OPTIONS=B:j:rt:c:f:D:pmhNni -LONGOPTIONS=buildpath:,jobs:,rebuild-only,type:,type-build:,checks,flagsCXX:,define:,python-wrap,matlab-wrap,gtwrap-root:,no-wrap-update,no-wrap-submodule-init,help,ninja-build,no-optim,skip-tests,clean,install,profile,toolchain:,python-test-conda-env:,python-test-conda-prefix:,python-test-executable:,ctest-extra-args: +LONGOPTIONS=buildpath:,jobs:,rebuild-only,type:,type-build:,checks,flagsCXX:,define:,python-wrap,matlab-wrap,gtwrap-root:,wrap-update,no-wrap-update,wrap-submodule-init,no-wrap-submodule-init,help,ninja-build,no-optim,skip-tests,clean,install,profile,toolchain:,python-test-conda-env:,python-test-conda-prefix:,python-test-executable:,ctest-extra-args: PARSED=$(getopt -o "$OPTIONS" -l "$LONGOPTIONS" -- "$@") || { usage; exit 2; } eval set -- "$PARSED" @@ -299,7 +256,9 @@ while true; do -p|--python-wrap) python_wrap=true; shift ;; -m|--matlab-wrap) matlab_wrap=true; shift ;; --gtwrap-root) gtwrap_root="$2"; shift 2 ;; + --wrap-update) wrap_update=true; shift ;; --no-wrap-update) wrap_update=false; shift ;; + --wrap-submodule-init) wrap_submodule_init=true; shift ;; --no-wrap-submodule-init) wrap_submodule_init=false; shift ;; -i|--install) install=true; shift ;; -N|--ninja-build) use_ninja=true; shift ;; @@ -317,6 +276,14 @@ while true; do esac done +# Resolve every relative build location against the helper's checkout before +# any validation, configuration, build, test, or install operation consumes it. +if [[ "$buildpath" == /* ]]; then + buildpath="$(realpath -m -- "$buildpath")" +else + buildpath="$(realpath -m -- "${project_root}/${buildpath}")" +fi + # --- normalize & validate build type --- bt="${build_type,,}" case "$bt" in @@ -364,7 +331,7 @@ prepare_wrap_checkout=false if [[ "$rebuild_only" == false && ( "$python_wrap" == true || "$matlab_wrap" == true ) ]]; then if has_wrapper_interface_override; then prepare_wrap_checkout=true - elif [[ -f "src/wrap_interface.i" ]]; then + elif [[ -f "${project_root}/src/wrap_interface.i" ]]; then prepare_wrap_checkout=true else if [[ -n "$project_name" ]]; then @@ -379,9 +346,6 @@ if [[ "$rebuild_only" == false && "$prepare_wrap_checkout" == true ]]; then if [[ -z "$gtwrap_root" ]]; then gtwrap_root="$(detect_wrap_root || true)" fi - if [[ -n "$gtwrap_root" && "$wrap_update" == true ]]; then - update_wrap_checkout "$gtwrap_root" "$wrap_branch" - fi fi # Pre-build checks @@ -404,7 +368,7 @@ info "Python wrapper : $python_wrap" info "MATLAB wrapper : $matlab_wrap" info "Detected project : ${project_name:-}" info "GTWRAP root : ${gtwrap_root:-}" -info "GTWRAP auto-update : $wrap_update (branch: $wrap_branch)" +info "GTWRAP update : $wrap_update (branch: $wrap_branch)" info "GTWRAP submodule : $wrap_submodule_init" info "Generator : $([[ "$use_ninja" == true ]] && echo Ninja || echo 'Unix Makefiles')" info "Profiling build : $profiling" @@ -433,7 +397,7 @@ if [[ "$rebuild_only" == false ]]; then fi cmake_args=( - -S . + -S "$project_root" -B "$buildpath" "-DCMAKE_BUILD_TYPE=$cmake_bt" "-DEXTRA_CXX_FLAGS=$CXX_FLAGS" @@ -464,14 +428,14 @@ if [[ "$rebuild_only" == false ]]; then fi if [[ "$prepare_wrap_checkout" == true ]]; then if [[ "$wrap_update" == true ]]; then - cmake_args+=( "-DGTWRAP_BRANCH=$wrap_branch" -DGTWRAP_SYNC_TO_MASTER=ON ) - else - cmake_args+=( -DGTWRAP_SYNC_TO_MASTER=OFF ) + cmake_args+=( + "-DGTWRAP_BRANCH=$wrap_branch" + -DGTWRAP_MAINTENANCE_UPDATE=ON + -DGTWRAP_SYNC_TO_MASTER=ON + ) fi if [[ "$wrap_submodule_init" == true ]]; then cmake_args+=( -DGTWRAP_INIT_SUBMODULE_IF_MISSING=ON ) - else - cmake_args+=( -DGTWRAP_INIT_SUBMODULE_IF_MISSING=OFF ) fi fi [[ "$no_optim" == true ]] && cmake_args+=( -DNO_OPTIMIZATION=ON ) diff --git a/cmake/FindTensorRT.cmake b/cmake/FindTensorRT.cmake new file mode 100644 index 0000000..31a8a2e --- /dev/null +++ b/cmake/FindTensorRT.cmake @@ -0,0 +1,173 @@ +#[=======================================================================[.rst: +FindTensorRT +------------ + +Find NVIDIA TensorRT headers and runtime libraries without enabling them in +the base template. + +Input hints +^^^^^^^^^^^ + +``TensorRT_ROOT`` + Preferred CMake package-root spelling. +``TENSORRT_ROOT`` + Compatibility spelling used by existing TensorRT projects. + +Result variables +^^^^^^^^^^^^^^^^ + +``TensorRT_FOUND`` +``TensorRT_VERSION`` +``TensorRT_INCLUDE_DIRS`` +``TensorRT_LIBRARIES`` + +Imported targets +^^^^^^^^^^^^^^^^ + +``TensorRT::nvinfer`` +``TensorRT::nvinfer_plugin`` +#]=======================================================================] + +include(FindPackageHandleStandardArgs) + +set(TensorRT_ROOT "" CACHE PATH + "TensorRT root directory containing include/ and lib directories.") +set(TENSORRT_ROOT "" CACHE PATH + "Compatibility TensorRT root directory hint.") + +# Accept package-style and established compatibility hints before conventional +# system locations. Environment variables follow the same precedence. +set(_TensorRT_root_hints) +foreach(_TensorRT_root_variable IN ITEMS TensorRT_ROOT TENSORRT_ROOT) + if(DEFINED ${_TensorRT_root_variable} + AND NOT "${${_TensorRT_root_variable}}" STREQUAL "") + list(APPEND _TensorRT_root_hints "${${_TensorRT_root_variable}}") + endif() + if(DEFINED ENV{${_TensorRT_root_variable}} + AND NOT "$ENV{${_TensorRT_root_variable}}" STREQUAL "") + list(APPEND _TensorRT_root_hints "$ENV{${_TensorRT_root_variable}}") + endif() +endforeach() +list(APPEND _TensorRT_root_hints + /usr/local/tensorrt + /usr/local/TensorRT) +list(REMOVE_DUPLICATES _TensorRT_root_hints) + +# TensorRT archives use targets/ on multiple host architectures. Use +# the active toolchain triplet first and retain common archive layouts as +# fallbacks for metadata-only and cross-toolchain discovery. +set(_TensorRT_target_architectures) +if(CMAKE_LIBRARY_ARCHITECTURE) + list(APPEND _TensorRT_target_architectures + "${CMAKE_LIBRARY_ARCHITECTURE}") +endif() +list(APPEND _TensorRT_target_architectures + x86_64-linux-gnu + aarch64-linux-gnu) +list(REMOVE_DUPLICATES _TensorRT_target_architectures) + +set(_TensorRT_include_suffixes include) +set(_TensorRT_library_suffixes lib lib64) +foreach(_TensorRT_target_architecture IN LISTS _TensorRT_target_architectures) + list(APPEND _TensorRT_include_suffixes + "targets/${_TensorRT_target_architecture}/include") + list(APPEND _TensorRT_library_suffixes + "targets/${_TensorRT_target_architecture}/lib" + "targets/${_TensorRT_target_architecture}/lib64") +endforeach() + +find_path( + TensorRT_INCLUDE_DIR + NAMES NvInfer.h + HINTS ${_TensorRT_root_hints} + PATH_SUFFIXES ${_TensorRT_include_suffixes}) + +find_library( + TensorRT_NVINFER_LIBRARY + NAMES nvinfer + HINTS ${_TensorRT_root_hints} + PATH_SUFFIXES ${_TensorRT_library_suffixes}) + +find_library( + TensorRT_NVINFER_PLUGIN_LIBRARY + NAMES nvinfer_plugin + HINTS ${_TensorRT_root_hints} + PATH_SUFFIXES ${_TensorRT_library_suffixes}) + +# Parse the public version header without compiling or executing vendor code. +if(TensorRT_INCLUDE_DIR AND EXISTS "${TensorRT_INCLUDE_DIR}/NvInferVersion.h") + file(STRINGS "${TensorRT_INCLUDE_DIR}/NvInferVersion.h" + _TensorRT_version_lines + REGEX "^#define NV_TENSORRT_(MAJOR|MINOR|PATCH|BUILD)[ \t]+[0-9]+") + foreach(_TensorRT_component MAJOR MINOR PATCH BUILD) + foreach(_TensorRT_version_line IN LISTS _TensorRT_version_lines) + if(_TensorRT_version_line MATCHES + "^#define NV_TENSORRT_${_TensorRT_component}[ \t]+([0-9]+)") + set(_TensorRT_version_${_TensorRT_component} "${CMAKE_MATCH_1}") + endif() + endforeach() + endforeach() + if(DEFINED _TensorRT_version_MAJOR + AND DEFINED _TensorRT_version_MINOR + AND DEFINED _TensorRT_version_PATCH) + set(TensorRT_VERSION + "${_TensorRT_version_MAJOR}.${_TensorRT_version_MINOR}.${_TensorRT_version_PATCH}") + if(DEFINED _TensorRT_version_BUILD) + string(APPEND TensorRT_VERSION ".${_TensorRT_version_BUILD}") + endif() + endif() +endif() + +find_package_handle_standard_args( + TensorRT + REQUIRED_VARS + TensorRT_INCLUDE_DIR + TensorRT_NVINFER_LIBRARY + TensorRT_NVINFER_PLUGIN_LIBRARY + VERSION_VAR TensorRT_VERSION) + +if(TensorRT_FOUND) + if(NOT TARGET TensorRT::nvinfer) + add_library(TensorRT::nvinfer UNKNOWN IMPORTED) + set_target_properties( + TensorRT::nvinfer + PROPERTIES + IMPORTED_LOCATION "${TensorRT_NVINFER_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${TensorRT_INCLUDE_DIR}") + endif() + + if(NOT TARGET TensorRT::nvinfer_plugin) + add_library(TensorRT::nvinfer_plugin UNKNOWN IMPORTED) + set_target_properties( + TensorRT::nvinfer_plugin + PROPERTIES + IMPORTED_LOCATION "${TensorRT_NVINFER_PLUGIN_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${TensorRT_INCLUDE_DIR}" + INTERFACE_LINK_LIBRARIES TensorRT::nvinfer) + endif() +endif() + +set(TensorRT_INCLUDE_DIRS "${TensorRT_INCLUDE_DIR}") +set(TensorRT_LIBRARIES + "${TensorRT_NVINFER_LIBRARY};${TensorRT_NVINFER_PLUGIN_LIBRARY}") +set(TENSORRT_INCLUDE_DIRS "${TensorRT_INCLUDE_DIRS}") +set(TENSORRT_LIBRARIES "${TensorRT_LIBRARIES}") + +mark_as_advanced( + TensorRT_INCLUDE_DIR + TensorRT_NVINFER_LIBRARY + TensorRT_NVINFER_PLUGIN_LIBRARY) + +unset(_TensorRT_root_hints) +unset(_TensorRT_root_variable) +unset(_TensorRT_target_architectures) +unset(_TensorRT_target_architecture) +unset(_TensorRT_include_suffixes) +unset(_TensorRT_library_suffixes) +unset(_TensorRT_version_lines) +unset(_TensorRT_version_line) +unset(_TensorRT_component) +unset(_TensorRT_version_MAJOR) +unset(_TensorRT_version_MINOR) +unset(_TensorRT_version_PATCH) +unset(_TensorRT_version_BUILD) diff --git a/cmake/HandleMatlabWrapper.cmake b/cmake/HandleMatlabWrapper.cmake index ed8db9d..8925cb3 100644 --- a/cmake/HandleMatlabWrapper.cmake +++ b/cmake/HandleMatlabWrapper.cmake @@ -18,6 +18,13 @@ function(configure_matlab_gtwrapper) endif() message(STATUS "Including MATLAB directories...") + # Let container launchers expose a host MATLAB installation without + # overriding an explicit CMake cache or command-line selection. + if((NOT DEFINED Matlab_ROOT_DIR OR "${Matlab_ROOT_DIR}" STREQUAL "") + AND DEFINED ENV{MATLAB_ROOT_DIR} + AND NOT "$ENV{MATLAB_ROOT_DIR}" STREQUAL "") + set(Matlab_ROOT_DIR "$ENV{MATLAB_ROOT_DIR}") + endif() find_package(Matlab REQUIRED) set(MATLAB_MEX_INCLUDE "${Matlab_ROOT_DIR}/extern/include") diff --git a/cmake/HandlePythonWrapper.cmake b/cmake/HandlePythonWrapper.cmake index 6a1be90..e1328b3 100644 --- a/cmake/HandlePythonWrapper.cmake +++ b/cmake/HandlePythonWrapper.cmake @@ -23,8 +23,8 @@ function(set_python_target_properties set(_python_runtime_rpath "") endif() - # Suppress automatic configuration subdirectories because the source package - # is one shared checkout workspace populated by one configuration at a time. + # Suppress automatic configuration subdirectories so every native artifact + # remains co-located in the build-owned Python package. set_target_properties( "${PYTHON_TARGET}" PROPERTIES @@ -73,6 +73,40 @@ function(_resolve_python_install_root OUT_VAR) PARENT_SCOPE) endfunction() +# Reconstruct a build-owned Python package from stable checkout inputs. +function(_stage_python_package_sources SOURCE_DIRECTORY STAGING_DIRECTORY) + file(REMOVE_RECURSE "${STAGING_DIRECTORY}") + file(MAKE_DIRECTORY "${STAGING_DIRECTORY}") + + if(NOT EXISTS "${SOURCE_DIRECTORY}") + return() + endif() + + file(GLOB_RECURSE + _python_source_package_files + CONFIGURE_DEPENDS + LIST_DIRECTORIES FALSE + RELATIVE "${SOURCE_DIRECTORY}" + "${SOURCE_DIRECTORY}/*") + list(FILTER + _python_source_package_files + EXCLUDE REGEX + "(^|/)(_wrapper_build\\.py|__pycache__/.*|.*\\.py[co]|.*\\.so(\\..*)?|.*\\.(dylib|dll|pyd))$") + + foreach(_python_source_package_file IN LISTS _python_source_package_files) + get_filename_component( + _python_source_package_subdir + "${_python_source_package_file}" + DIRECTORY) + file(MAKE_DIRECTORY + "${STAGING_DIRECTORY}/${_python_source_package_subdir}") + configure_file( + "${SOURCE_DIRECTORY}/${_python_source_package_file}" + "${STAGING_DIRECTORY}/${_python_source_package_file}" + COPYONLY) + endforeach() +endfunction() + # Configure one collision-safe staging operation for a Python extension. # # PYTHON_TARGET is the extension whose resolved filename reserves the package @@ -306,18 +340,19 @@ function(configure_python_gtwrapper) "gtwrap root or installed with CMake config files.") endif() - # Establish the source-package and generated-extension layout used by direct - # checkout imports, wheels, and CMake installs. + # Establish separate source-input and build-owned package layouts. Ordinary + # wrapper configuration must not materialize generated files in the checkout. set(PROJECT_PYTHON_SOURCE_DIR "${PROJECT_SOURCE_DIR}/python") set(PROJECT_PYTHON_PACKAGE_DIR "${PROJECT_PYTHON_SOURCE_DIR}/${PROJECT_NAME}") set(PROJECT_PYTHON_BUILD_DIRECTORY "${PROJECT_BINARY_DIR}/python") set(PROJECT_PYTHON_BUILD_PACKAGE_DIR "${PROJECT_PYTHON_BUILD_DIRECTORY}/${PROJECT_NAME}") - set(PROJECT_PYTHON_SOURCE_METADATA_FILE - "${PROJECT_PYTHON_SOURCE_DIR}/pyproject.toml") - set(PROJECT_PYTHON_SOURCE_SETUP_FILE "${PROJECT_PYTHON_SOURCE_DIR}/setup.py") + set(PROJECT_PYTHON_BUILD_METADATA_FILE + "${PROJECT_PYTHON_BUILD_DIRECTORY}/pyproject.toml") + set(PROJECT_PYTHON_BUILD_SETUP_FILE + "${PROJECT_PYTHON_BUILD_DIRECTORY}/setup.py") set(PROJECT_PYTHON_WRAPPER_LINK_FILE - "${PROJECT_PYTHON_PACKAGE_DIR}/_wrapper_build.py") + "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}/_wrapper_build.py") set(PROJECT_PYTHON_TARGET_NAME "${LIB_NAMESPACE}_py") set( ${PROJECT_NAME}_PYTHON_WRAPPER_TARGET @@ -325,14 +360,18 @@ function(configure_python_gtwrapper) CACHE INTERNAL "Resolved Python wrapper target name for the project." FORCE) + # Stage stable package sources into the build tree while excluding stale + # generated/import-cache artifacts that may exist in an older checkout. + _stage_python_package_sources( + "${PROJECT_PYTHON_PACKAGE_DIR}" + "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}") if(NOT EXISTS "${PROJECT_PYTHON_PACKAGE_DIR}") message(WARNING "Missing python package directory '${PROJECT_PYTHON_PACKAGE_DIR}'. " - "Creating it.") - file(MAKE_DIRECTORY "${PROJECT_PYTHON_PACKAGE_DIR}") + "Generating a minimal package in the build tree.") endif() - if(NOT EXISTS "${PROJECT_PYTHON_PACKAGE_DIR}/__init__.py") + if(NOT EXISTS "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}/__init__.py") string(CONFIGURE [=[ """Python package entrypoint for @PROJECT_NAME@ bindings.""" @@ -349,14 +388,12 @@ else: HAS_WRAPPER = True ]=] _default_python_package_init @ONLY) file(WRITE - "${PROJECT_PYTHON_PACKAGE_DIR}/__init__.py" + "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}/__init__.py" "${_default_python_package_init}") endif() - file(MAKE_DIRECTORY "${PROJECT_PYTHON_BUILD_DIRECTORY}") - - # Materialize package metadata so `pip install python/` remains the public - # installation entrypoint. + # Materialize build metadata beside the staged package so pip and CMake use + # one complete, disposable packaging root. set(_pyproject_template "${PROJECT_PYTHON_SOURCE_DIR}/pyproject.toml.in") if(NOT EXISTS "${_pyproject_template}") @@ -372,9 +409,9 @@ build-backend = "setuptools.build_meta" [project] name = "@PROJECT_NAME@" -version = "@PROJECT_VERSION@" +version = "@FULL_VERSION@" description = "Python bindings for @PROJECT_NAME@" -requires-python = ">=3.8" +requires-python = ">=@PROJECT_PYTHON_VERSION@" [tool.setuptools] packages = ["@PROJECT_NAME@"] @@ -387,7 +424,7 @@ include-package-data = true configure_file( "${_pyproject_template}" - "${PROJECT_PYTHON_SOURCE_METADATA_FILE}" + "${PROJECT_PYTHON_BUILD_METADATA_FILE}" @ONLY) # Keep setup.py as a compatibility entrypoint for tooling that has not moved @@ -396,7 +433,7 @@ include-package-data = true if(EXISTS "${_setup_py_template}") configure_file( "${_setup_py_template}" - "${PROJECT_PYTHON_SOURCE_SETUP_FILE}" + "${PROJECT_PYTHON_BUILD_SETUP_FILE}" @ONLY) else() set(_generated_setup_py_template @@ -408,7 +445,7 @@ setup(zip_safe=False) ]=]) configure_file( "${_generated_setup_py_template}" - "${PROJECT_PYTHON_SOURCE_SETUP_FILE}" + "${PROJECT_PYTHON_BUILD_SETUP_FILE}" @ONLY) endif() @@ -588,7 +625,7 @@ namespace py = pybind11; "${PROJECT_PYTHON_WRAPPER_LINK_FILE}" ${_python_runtime_targets}) - # Exercise direct checkout import against the generated link metadata. + # Exercise the staged build package against the generated link metadata. if(ENABLE_TESTS AND BUILD_TESTING) set(_python_import_test_name "${LIB_NAMESPACE}_python_import") set(_python_import_test_code @@ -597,12 +634,12 @@ namespace py = pybind11; NAME ${_python_import_test_name} COMMAND ${CMAKE_COMMAND} -E env - "PYTHONPATH=${PROJECT_PYTHON_SOURCE_DIR}:$ENV{PYTHONPATH}" + "PYTHONPATH=${PROJECT_PYTHON_BUILD_DIRECTORY}:$ENV{PYTHONPATH}" ${PYTHON_EXECUTABLE} -c "${_python_import_test_code}") set_tests_properties( ${_python_import_test_name} PROPERTIES - WORKING_DIRECTORY "${PROJECT_PYTHON_SOURCE_DIR}") + WORKING_DIRECTORY "${PROJECT_PYTHON_BUILD_DIRECTORY}") endif() install( @@ -611,17 +648,21 @@ namespace py = pybind11; RUNTIME DESTINATION "${_python_package_install_destination}") install( - DIRECTORY "${PROJECT_PYTHON_PACKAGE_DIR}/" + DIRECTORY "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}/" DESTINATION "${_python_package_install_destination}" PATTERN "_wrapper_build.py" EXCLUDE PATTERN "__pycache__" EXCLUDE - PATTERN "*.pyc" EXCLUDE) + PATTERN "*.pyc" EXCLUDE + PATTERN "*.so*" EXCLUDE + PATTERN "*.dylib" EXCLUDE + PATTERN "*.dll" EXCLUDE + PATTERN "*.pyd" EXCLUDE) install( - FILES "${PROJECT_PYTHON_SOURCE_METADATA_FILE}" + FILES "${PROJECT_PYTHON_BUILD_METADATA_FILE}" DESTINATION "${_python_install_root}") - # Install the source package only after its native wrapper is current. + # Install the staged package only after its native wrapper is current. set(_python_pip_install_target "${LIB_NAMESPACE}_python-install") if(NOT TARGET "${_python_pip_install_target}") add_custom_target( @@ -630,7 +671,7 @@ namespace py = pybind11; ${PYTHON_EXECUTABLE} -c "import subprocess, sys; cmd=[sys.executable, '-m', 'pip', 'install', '--no-build-isolation', '--no-deps', '.']; subprocess.check_call(cmd)" DEPENDS "${PROJECT_PYTHON_TARGET_NAME}" - WORKING_DIRECTORY "${PROJECT_PYTHON_SOURCE_DIR}" + WORKING_DIRECTORY "${PROJECT_PYTHON_BUILD_DIRECTORY}" VERBATIM) endif() @@ -638,17 +679,17 @@ namespace py = pybind11; add_custom_target(python-install DEPENDS ${_python_pip_install_target}) endif() - # Generate stubs from the same checkout package used by the import test. + # Generate stubs from the same staged package used by the import test. set(_python_stubs_target "${LIB_NAMESPACE}_python-stubs") if(NOT TARGET "${_python_stubs_target}") add_custom_target( "${_python_stubs_target}" COMMAND ${CMAKE_COMMAND} -E env - "PYTHONPATH=${PROJECT_PYTHON_SOURCE_DIR}:$ENV{PYTHONPATH}" + "PYTHONPATH=${PROJECT_PYTHON_BUILD_DIRECTORY}:$ENV{PYTHONPATH}" ${PYTHON_EXECUTABLE} -m pybind11_stubgen ${PROJECT_NAME} -o . DEPENDS "${PROJECT_PYTHON_TARGET_NAME}" - WORKING_DIRECTORY "${PROJECT_PYTHON_SOURCE_DIR}" + WORKING_DIRECTORY "${PROJECT_PYTHON_BUILD_DIRECTORY}" VERBATIM) endif() diff --git a/cmake/HandleWrapper.cmake b/cmake/HandleWrapper.cmake index 769d275..b834b6e 100644 --- a/cmake/HandleWrapper.cmake +++ b/cmake/HandleWrapper.cmake @@ -61,7 +61,7 @@ function(resolve_local_wrap_root OUT_VAR) set(${OUT_VAR} "" PARENT_SCOPE) endfunction() -# Initialize or add the configured gtwrap submodule when policy permits it. +# Initialize a declared project-local gtwrap submodule when policy permits it. function(maybe_init_wrap_submodule OUT_VAR) set(${OUT_VAR} "" PARENT_SCOPE) @@ -81,23 +81,7 @@ function(maybe_init_wrap_submodule OUT_VAR) endif() if("${_submodule_path}" STREQUAL "") - if(NOT GTWRAP_ADD_SUBMODULE_IF_MISSING) - return() - endif() - - if(NOT DEFINED GTWRAP_SUBMODULE_PATH OR "${GTWRAP_SUBMODULE_PATH}" STREQUAL "") - set(_submodule_path "lib/wrap") - else() - set(_submodule_path "${GTWRAP_SUBMODULE_PATH}") - endif() - - if(NOT DEFINED GTWRAP_SUBMODULE_REPO OR "${GTWRAP_SUBMODULE_REPO}" STREQUAL "") - set(_gtwrap_submodule_repo "git@github.com:PeterCalifano/wrap.git") - else() - set(_gtwrap_submodule_repo "${GTWRAP_SUBMODULE_REPO}") - endif() - else() - set(_gtwrap_submodule_repo "") + return() endif() set(_candidate_root "${PROJECT_SOURCE_DIR}/${_submodule_path}") @@ -117,30 +101,7 @@ function(maybe_init_wrap_submodule OUT_VAR) return() endif() - if(NOT "${_gtwrap_submodule_repo}" STREQUAL "") - get_filename_component(_submodule_parent "${_candidate_root}" DIRECTORY) - file(MAKE_DIRECTORY "${_submodule_parent}") - - message(STATUS "Adding wrap submodule '${_gtwrap_submodule_repo}' at '${_submodule_path}'...") - execute_process( - COMMAND git submodule add "${_gtwrap_submodule_repo}" "${_submodule_path}" - WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" - RESULT_VARIABLE _add_result - OUTPUT_QUIET - ERROR_VARIABLE _add_error - ) - if(NOT _add_result EQUAL 0) - string(STRIP "${_add_error}" _add_error) - if("${_add_error}" STREQUAL "") - set(_add_error "unknown error") - endif() - message(WARNING - "Failed to add wrap submodule '${_gtwrap_submodule_repo}' at '${_submodule_path}': ${_add_error}") - return() - endif() - else() - message(STATUS "Initializing wrap submodule at '${_submodule_path}'...") - endif() + message(STATUS "Initializing wrap submodule at '${_submodule_path}'...") execute_process( COMMAND git submodule sync --recursive @@ -257,27 +218,27 @@ function(configure_gtwrappers_common) set(GTWRAP_BRANCH "master" CACHE STRING "wrap branch used when syncing local checkout") endif() if(NOT DEFINED GTWRAP_SYNC_TO_MASTER) - option(GTWRAP_SYNC_TO_MASTER "Sync local wrap checkout to latest origin/" ON) + option(GTWRAP_SYNC_TO_MASTER + "Request synchronization of wrap to origin/" + OFF) + endif() + if(NOT DEFINED GTWRAP_MAINTENANCE_UPDATE) + option(GTWRAP_MAINTENANCE_UPDATE + "Explicitly permit configure-time maintenance of a local wrap checkout" + OFF) endif() if(NOT DEFINED GTWRAP_INIT_SUBMODULE_IF_MISSING) option(GTWRAP_INIT_SUBMODULE_IF_MISSING "Initialize the wrap git submodule only after local search and find_package(gtwrap) both fail." - ON) - endif() - if(NOT DEFINED GTWRAP_ADD_SUBMODULE_IF_MISSING) - option(GTWRAP_ADD_SUBMODULE_IF_MISSING - "Add wrap as a git submodule when it is not yet declared in .gitmodules and wrapper resolution fails." - ON) - endif() - if(NOT DEFINED GTWRAP_SUBMODULE_REPO) - set(GTWRAP_SUBMODULE_REPO "git@github.com:PeterCalifano/wrap.git" CACHE STRING - "Git repository used when auto-adding wrap as a submodule.") + OFF) endif() - if(NOT DEFINED GTWRAP_SUBMODULE_PATH) - set(GTWRAP_SUBMODULE_PATH "lib/wrap" CACHE STRING - "Relative path used when auto-adding wrap as a submodule.") + # Require an explicit maintenance grant before configure may move a local + # checkout. A synchronization request alone must remain non-mutating. + if(GTWRAP_SYNC_TO_MASTER AND NOT GTWRAP_MAINTENANCE_UPDATE) + message(FATAL_ERROR + "GTWRAP_SYNC_TO_MASTER=ON requires GTWRAP_MAINTENANCE_UPDATE=ON. " + "Ordinary configuration must not move the wrapper checkout.") endif() - if(NOT DEFINED ${_gtwrap_root_var_name}) set(${_gtwrap_root_var_name} "" CACHE PATH "Optional path to a local wrap checkout (contains cmake/PybindWrap.cmake)." @@ -385,21 +346,13 @@ function(configure_gtwrappers_common) endif() endif() if(_local_wrap_root) - if(GTWRAP_SYNC_TO_MASTER) + if(GTWRAP_SYNC_TO_MASTER AND GTWRAP_MAINTENANCE_UPDATE) sync_wrap_checkout("${_local_wrap_root}" "${GTWRAP_BRANCH}") endif() message(STATUS "Using local wrap checkout: ${_local_wrap_root}") - if(${${_gtwrap_matlab_option_name}} AND - EXISTS "${_local_wrap_root}/templates/matlab_wrapper.tpl.in") - get_filename_component(_local_wrap_include_name "${_local_wrap_root}" NAME) - file(READ "${_local_wrap_root}/templates/matlab_wrapper.tpl.in" - _local_matlab_wrapper_template) - string(REPLACE "\${GTWRAP_INCLUDE_NAME}" "${_local_wrap_include_name}" - _local_matlab_wrapper_template "${_local_matlab_wrapper_template}") - file(WRITE "${_local_wrap_root}/gtwrap/matlab_wrapper/matlab_wrapper.tpl" - "${_local_matlab_wrapper_template}") - endif() + # Current gtwrap configures the MATLAB include template in memory. Keep the + # resolved checkout read-only and let the wrapper frontend own that logic. list(APPEND CMAKE_MODULE_PATH "${_local_wrap_root}/cmake") set(CMAKE_MODULE_PATH "${CMAKE_MODULE_PATH}" PARENT_SCOPE) @@ -527,8 +480,7 @@ function(configure_gtwrappers_common) message(FATAL_ERROR "Could not locate wrap/gtwrap. Provide a local checkout at 'wrap/' or 'lib/wrap/', " "or set ${_gtwrap_root_var_name}=, or install gtwrap so find_package(gtwrap) succeeds. " - "Set GTWRAP_INIT_SUBMODULE_IF_MISSING=ON to allow submodule initialization when declared in .gitmodules, " - "or GTWRAP_ADD_SUBMODULE_IF_MISSING=ON to auto-add '${GTWRAP_SUBMODULE_PATH}' from '${GTWRAP_SUBMODULE_REPO}'.") + "Set GTWRAP_INIT_SUBMODULE_IF_MISSING=ON to initialize a wrap submodule already declared in .gitmodules.") endif() endif() diff --git a/cmake/RefreshCPackSourceIgnores.cmake.in b/cmake/RefreshCPackSourceIgnores.cmake.in new file mode 100644 index 0000000..1dc60e4 --- /dev/null +++ b/cmake/RefreshCPackSourceIgnores.cmake.in @@ -0,0 +1,130 @@ +# Refresh source-package exclusions from current checkout ownership evidence. +# +# CPack loads this project configuration immediately before generating each +# package. Discovering build trees here prevents a build created after CMake +# configuration from leaking into a source archive. + +set(_template_source_root [==[@CMAKE_CURRENT_SOURCE_DIR@]==]) +set(_template_binary_root [==[@CMAKE_BINARY_DIR@]==]) + +# Keep recursive cache discovery inside the physical checkout instead of +# traversing symlinked directory trees. +cmake_policy(PUSH) +cmake_policy(SET CMP0009 NEW) + +get_filename_component( + _template_source_root_real + "${_template_source_root}" + REALPATH) +get_filename_component( + _template_binary_root_real + "${_template_binary_root}" + REALPATH) + +# The active binary tree is owned by this configure whenever it is nested +# below the source checkout. +file( + RELATIVE_PATH + _template_binary_relative_to_source + "${_template_source_root_real}" + "${_template_binary_root_real}") +set(_template_owned_build_directories) +if(NOT IS_ABSOLUTE "${_template_binary_relative_to_source}" + AND NOT "${_template_binary_relative_to_source}" MATCHES "^\\.\\.(/|$)" + AND NOT "${_template_binary_relative_to_source}" STREQUAL "") + list(APPEND + _template_owned_build_directories + "${_template_binary_root}") +endif() + +# A nested cache proves ownership only when its configured home resolves to +# this exact source checkout. Skip already-owned and fixed generated trees +# before reading caches so concurrent cleanup cannot create a read race. +file( + GLOB_RECURSE + _template_cache_candidates + LIST_DIRECTORIES FALSE + "${_template_source_root}/*/CMakeCache.txt") +foreach(_template_cache_candidate IN LISTS _template_cache_candidates) + file( + RELATIVE_PATH + _template_cache_relative_to_source + "${_template_source_root}" + "${_template_cache_candidate}") + if("${_template_cache_relative_to_source}" MATCHES "^install/" + OR "${_template_cache_relative_to_source}" + MATCHES "^ros2/(build|install|log)/") + continue() + endif() + + set(_template_cache_is_within_owned_build FALSE) + foreach(_template_known_build IN LISTS _template_owned_build_directories) + file( + RELATIVE_PATH + _template_cache_relative_to_build + "${_template_known_build}" + "${_template_cache_candidate}") + if(NOT IS_ABSOLUTE "${_template_cache_relative_to_build}" + AND NOT "${_template_cache_relative_to_build}" MATCHES "^\\.\\.(/|$)") + set(_template_cache_is_within_owned_build TRUE) + break() + endif() + endforeach() + if(_template_cache_is_within_owned_build + OR NOT EXISTS "${_template_cache_candidate}") + continue() + endif() + + file( + STRINGS + "${_template_cache_candidate}" + _template_cache_home_entries + REGEX "^CMAKE_HOME_DIRECTORY:INTERNAL=" + LIMIT_COUNT 1) + if(NOT _template_cache_home_entries) + continue() + endif() + + list(GET _template_cache_home_entries 0 _template_cache_home_entry) + string( + REGEX MATCH + "^CMAKE_HOME_DIRECTORY:INTERNAL=(.*)$" + _template_cache_home_match + "${_template_cache_home_entry}") + get_filename_component( + _template_cache_home_real + "${CMAKE_MATCH_1}" + REALPATH) + if(NOT "${_template_cache_home_real}" STREQUAL + "${_template_source_root_real}") + continue() + endif() + + get_filename_component( + _template_owned_build_directory + "${_template_cache_candidate}" + DIRECTORY) + list(APPEND + _template_owned_build_directories + "${_template_owned_build_directory}") +endforeach() + +# Source configuration maps its stable rules to CPACK_IGNORE_FILES before this +# script runs. Append each absolute expression to both the descriptive source +# list and the active generator list. +list(REMOVE_DUPLICATES _template_owned_build_directories) +foreach(_template_owned_build_directory + IN LISTS _template_owned_build_directories) + string( + REGEX REPLACE "([][+.*^$()|?\\\\])" "\\\\\\1" + _template_owned_build_regex + "${_template_owned_build_directory}") + list(APPEND + CPACK_SOURCE_IGNORE_FILES + "^${_template_owned_build_regex}(/|$)") + list(APPEND + CPACK_IGNORE_FILES + "^${_template_owned_build_regex}(/|$)") +endforeach() + +cmake_policy(POP) diff --git a/cmake/StagePackageVersion.cmake.in b/cmake/StagePackageVersion.cmake.in new file mode 100644 index 0000000..d4a5018 --- /dev/null +++ b/cmake/StagePackageVersion.cmake.in @@ -0,0 +1,12 @@ +# Stage authoritative version metadata inside CPack's private package root. + +set(_template_package_version_file [==[@PROJECT_BINARY_DIR@/VERSION]==]) +if(NOT EXISTS "${_template_package_version_file}") + message( + FATAL_ERROR + "Generated package VERSION is missing: ${_template_package_version_file}") +endif() + +file( + COPY "${_template_package_version_file}" + DESTINATION "${CMAKE_INSTALL_PREFIX}") diff --git a/doc/build_script_doc.md b/doc/build_script_doc.md index 39ef6e7..7526bc3 100644 --- a/doc/build_script_doc.md +++ b/doc/build_script_doc.md @@ -24,7 +24,7 @@ The script uses out-of-source builds, strict shell error handling, generator-ind | Option | Purpose | |---|---| -| `-B, --buildpath ` | Build directory. Default: `./build`. | +| `-B, --buildpath ` | Build directory relative to the script's checkout. Default: `/build`. | | `--clean` | Remove an owned conventional in-repository build directory before configure. Ignored with `--rebuild-only`. | | `-N, --ninja-build` | Configure with the Ninja generator. | | `-j, --jobs ` | Build/test parallelism. Default: `$JOBS`, then `nproc`, then `4`. | @@ -33,11 +33,13 @@ The script uses out-of-source builds, strict shell error handling, generator-ind The generated CMake build exports `compile_commands.json` by default for tools such as clangd and static analyzers. Clean removal is intentionally narrower than ordinary configuration. The -target must resolve below the current checkout as `build`, `build*`, or -`out/*`. If it already exists, its `CMakeCache.txt` must identify this exact -checkout through `CMAKE_HOME_DIRECTORY`. Configure unusual or external build -layouts without `--clean` and remove them explicitly only after independent -review. +target must resolve below the checkout containing `build_lib.sh` as `build`, +`build*`, or `out/*`. If it already exists, its `CMakeCache.txt` must identify +this exact checkout through `CMAKE_HOME_DIRECTORY`. Relative build paths and +the CMake source path remain anchored to that checkout even when the helper is +invoked by absolute path from another working directory. Configure unusual or +external build layouts without `--clean` and remove them explicitly only after +independent review. ## Configure Options @@ -168,8 +170,10 @@ OptiX builds require at least one compiled library source and at least one `*.pt | `-p, --python-wrap` | Enable Python wrapper generation. | | `-m, --matlab-wrap` | Enable MATLAB wrapper generation. | | `--gtwrap-root ` | Use a specific local gtwrap checkout. | -| `--no-wrap-update` | Do not update a resolved local gtwrap checkout. | -| `--no-wrap-submodule-init` | Do not initialize a declared `wrap` submodule fallback. | +| `--wrap-update` | Explicitly update a resolved local gtwrap checkout. | +| `--no-wrap-update` | Keep the resolved local checkout unchanged (default). | +| `--wrap-submodule-init` | Explicitly initialize a declared `wrap` submodule fallback. | +| `--no-wrap-submodule-init` | Do not initialize a submodule fallback (default). | Wrapper resolution order: @@ -183,10 +187,14 @@ Examples: ```bash ./build_lib.sh -p ./build_lib.sh -p -m --gtwrap-root /path/to/wrap +./build_lib.sh -p --wrap-update ./build_lib.sh -r -p ``` `--rebuild-only` with wrapper flags only works when the existing CMake cache was already configured with those wrappers enabled. +Ordinary wrapper configuration does not update, initialize, or add a wrapper +checkout. `--wrap-submodule-init` applies only to an existing declaration in +`.gitmodules`; add a new submodule with an explicit Git maintenance command. ## Project Binaries And Examples @@ -203,7 +211,7 @@ After tailoring, replace `template_project` with the project namespace used by t ## Troubleshooting - Use `--clean` after changing CMake options or wrapper settings. -- Use `--no-wrap-update` when a wrapper build must stay pinned to an existing gtwrap checkout. +- Use `--wrap-update` only when intentionally advancing a local gtwrap checkout. - Set `CPU_ENABLE_NATIVE_TUNING=OFF` for portable binaries. - Set `CUDA_ARCHITECTURES` explicitly on CI runners without reliable GPU discovery. - For Python tests in conda, prefer `--python-test-conda-env` or `--python-test-conda-prefix` instead of activating conda around the whole CTest run. diff --git a/doc/ros2_overlay.md b/doc/ros2_overlay.md index 38e2d38..568b902 100644 --- a/doc/ros2_overlay.md +++ b/doc/ros2_overlay.md @@ -63,15 +63,16 @@ CUDA and OptiX flow through a workspace option facade: | User flag | Colcon CMake argument | Shim mapping | Core CMake option | |---|---|---|---| -| `--cuda` | `-DTEMPLATE_PROJECT_ENABLE_CUDA=ON` | cache-forces `ENABLE_CUDA` | `ENABLE_CUDA=ON` | -| `--optix` | `-DTEMPLATE_PROJECT_ENABLE_OPTIX=ON` and CUDA ON | cache-forces `ENABLE_OPTIX` | `ENABLE_OPTIX=ON` | +| `--cuda` | `-DTEMPLATE_PROJECT_ENABLE_CUDA=ON` | cache-forces `template_project_ENABLE_CUDA` | `template_project_ENABLE_CUDA=ON` | +| `--optix` | `-DTEMPLATE_PROJECT_ENABLE_OPTIX=ON` and CUDA ON | cache-forces `template_project_ENABLE_OPTIX` | `template_project_ENABLE_OPTIX=ON` | `TEMPLATE_PROJECT_ENABLE_CUDA` and `TEMPLATE_PROJECT_ENABLE_OPTIX` are stable overlay facade names. They intentionally survive CMake project and ROS package renaming so build automation has one consistent interface across derived repositories. The shim cache-forces the core options from these facade values, -so direct `--cmake-arg -DENABLE_CUDA=ON` or -`--cmake-arg -DENABLE_OPTIX=ON` values are overwritten by the shim. Use +so direct `--cmake-arg -Dtemplate_project_ENABLE_CUDA=ON` or +`--cmake-arg -Dtemplate_project_ENABLE_OPTIX=ON` values are overwritten by the +shim. Use `--cuda`, `--optix`, or set the corresponding facade variables instead. Use a ROS 2 Jazzy environment or the ROS devcontainer for local GPU checks: diff --git a/doc/template_usage.md b/doc/template_usage.md index 37220de..678075c 100644 --- a/doc/template_usage.md +++ b/doc/template_usage.md @@ -126,10 +126,21 @@ Nested consumers should override the internal target namespace if they include m ```cmake set(LIB_NAMESPACE_OVERRIDE nested_my_project CACHE STRING "" FORCE) set(LIB_TARGET_NAME_OVERRIDE nested_my_project_library CACHE STRING "" FORCE) +set(my_project_METADATA_ONLY OFF CACHE BOOL "" FORCE) +set(my_project_ENABLE_CUDA OFF CACHE BOOL "" FORCE) +set(my_project_ENABLE_OPTIX OFF CACHE BOOL "" FORCE) add_subdirectory(path/to/my_project) target_link_libraries(parent_target PRIVATE nested_my_project::my_project) ``` +The project-qualified metadata, CUDA, and OptiX options are canonical for +`add_subdirectory()` consumers and cannot collide with an application's generic +cache entries. The historical `PROJECT_METADATA_ONLY`, `ENABLE_CUDA`, and +`ENABLE_OPTIX` spellings remain one-config, top-level compatibility aliases +only. When supplied, a legacy alias wins for that configure, migrates its value +to the canonical project-qualified option, and is removed from the cache. +Replace `my_project` with the renamed root `project_name`. + Only the main project configures documentation, tests, examples, wrappers, and generic `doc` targets. Nested projects keep their library target available without publishing documentation for the parent build. ## Tests diff --git a/doc/versioning.md b/doc/versioning.md index b3a46ca..9c42a9d 100644 --- a/doc/versioning.md +++ b/doc/versioning.md @@ -40,6 +40,11 @@ the synchronization explicitly. Keeping source writes opt-in prevents CI and testfield configure runs from dirtying the checkout. +CPack stages the generated build-tree `VERSION` into binary and source +packages. The ignored source-tree file remains a fallback for configuring a +checkout without usable Git metadata, but it is excluded from CPack input so a +stale fallback cannot overwrite the version resolved for the package build. + ## C++ Access Include the configured header: @@ -55,7 +60,12 @@ The header also exposes numeric macros such as `PROJECT_VERSION_MAJOR`. ## Python and Packages -`python/pyproject.toml.in` receives `@PROJECT_VERSION@`, while CPack package filenames use `FULL_VERSION` when available. Keep public release tags, package uploads, and generated docs aligned by building release artifacts from an exact `vMAJOR.MINOR.PATCH` tag. +`python/pyproject.toml.in` receives `@FULL_VERSION@`, and CPack package +filenames use the same value. Python build backends normalize the semantic +version to its equivalent PEP 440 representation when required; for example, +`1.2.3-rc.1+4.gabc1234` becomes `1.2.3rc1+4.gabc1234` in wheel metadata. Keep +public release tags, package uploads, and generated docs aligned by building +release artifacts from an exact `vMAJOR.MINOR.PATCH[-PRERELEASE]` tag. ## Release tagging with the ROS 2 overlay @@ -116,12 +126,20 @@ without Git tag context or that metadata is not a valid release input. The TGZ produced from `CPackSourceConfig.cmake` is the canonical source release. It is validated outside Git against the same strict core and full version as the -tagged checkout, and it excludes build trees plus ROS-generated `build`, +tagged checkout. CPack injects the generated build-tree `VERSION` and excludes +the ignored source-tree fallback, build trees, plus ROS-generated `build`, `install`, and `log` outputs. GitHub's automatic source links are non-canonical: -they are repository snapshots and do not include the ignored, exact-tag -`VERSION` file required by this release contract. Uploading the CPack TGZ to a -GitHub release remains a deliberate manual step; CI upload automation is not yet -part of the release workflow. +they are repository snapshots and do not include the generated `VERSION` file +required by this release contract. Uploading the CPack TGZ to a GitHub release +remains a deliberate manual step; CI upload automation is not yet part of the +release workflow. + +Build-tree ownership is refreshed when CPack starts, not only when CMake first +configures the release tree. The active binary directory and any nested cache +whose `CMAKE_HOME_DIRECTORY` resolves to this exact checkout are excluded even +when they appeared after configuration. Build-prefixed source directories and +foreign child-project caches remain package input because their names alone do +not prove that this checkout generated them. Pushes of `v*.*.*` tags run the native CPU, CUDA, and ROS workflows. The ROS workflow regenerates metadata, derives the expected strict core version from diff --git a/doc/wrappers.md b/doc/wrappers.md index a354a65..5342e32 100644 --- a/doc/wrappers.md +++ b/doc/wrappers.md @@ -27,30 +27,35 @@ The wrapper resolver checks, in order: 3. An installed `gtwrap` CMake package. 4. A declared `wrap` or `lib/wrap` submodule when submodule initialization is enabled. -Disable automatic update with: +Wrapper checkout maintenance is disabled by default. Explicitly update a +resolved local checkout with: ```bash -./build_lib.sh -p --no-wrap-update +./build_lib.sh -p --wrap-update ``` -Disable submodule initialization fallback with: +Explicitly initialize a declared submodule fallback with: ```bash -./build_lib.sh -p --no-wrap-submodule-init +./build_lib.sh -p --wrap-submodule-init ``` +Direct CMake callers must set both `GTWRAP_MAINTENANCE_UPDATE=ON` and +`GTWRAP_SYNC_TO_MASTER=ON` to update a checkout. Initialization is limited to a +`wrap` or `lib/wrap` entry already declared in `.gitmodules`; use Git directly +when intentionally adding a new submodule. + ## Python Package -The source package under `python//` is the supported import and -install entrypoint. CMake configures `python/pyproject.toml` and -`python/setup.py` when Python wrapping is enabled. Building the wrapper target -validates and stages its native runtime set, then writes -`python//_wrapper_build.py` for direct checkout imports and wheel -construction. +The source package under `python//` is immutable wrapper input. CMake +copies it into `/python/` and configures `pyproject.toml` plus +`setup.py` beside that staged package. Building the wrapper target validates and +stages its native runtime set, then writes build-only `_wrapper_build.py` +metadata for build-tree imports and wheel construction. ```bash ./build_lib.sh -p -cd python +cd build/python python -m pip install . python -c "import template_project; assert template_project.HAS_WRAPPER" ``` @@ -68,10 +73,9 @@ The wrapper build fails before copying anything when different owners resolve to the same destination. CMake alias target names are not accepted. System libraries remain the responsibility of the target platform. -The checkout package directory is shared by all build configurations. Build -one configuration at a time when producing a wheel or importing directly from -the source checkout; the most recently staged configuration owns -`_wrapper_build.py`. +Each build tree owns its staged package and `_wrapper_build.py`. Separate build +directories can therefore package different configurations without competing +for generated files in the checkout. Wrapper install destinations remain relative to `CMAKE_INSTALL_PREFIX`. In particular, keep `CMAKE_INSTALL_LIBDIR` relative when Python wrapping is diff --git a/python/pyproject.toml.in b/python/pyproject.toml.in index 35a7361..9fb4a5e 100644 --- a/python/pyproject.toml.in +++ b/python/pyproject.toml.in @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "@PROJECT_NAME@" -version = "@PROJECT_VERSION@" +version = "@FULL_VERSION@" description = "Python bindings for @PROJECT_NAME@." requires-python = ">=3.12" license = {text = "MIT"} diff --git a/ros2/template_project/CMakeLists.txt b/ros2/template_project/CMakeLists.txt index af4c26a..d6c2ba8 100644 --- a/ros2/template_project/CMakeLists.txt +++ b/ros2/template_project/CMakeLists.txt @@ -10,8 +10,8 @@ get_filename_component(TEMPLATE_PROJECT_REPOSITORY_ROOT option(TEMPLATE_PROJECT_ENABLE_CUDA "Enable CUDA support in the template_project core." OFF) option(TEMPLATE_PROJECT_ENABLE_OPTIX "Enable OptiX support in the template_project core." OFF) -set(ENABLE_CUDA "${TEMPLATE_PROJECT_ENABLE_CUDA}" CACHE BOOL "Enable CUDA support in the template_project core." FORCE) -set(ENABLE_OPTIX "${TEMPLATE_PROJECT_ENABLE_OPTIX}" CACHE BOOL "Enable OptiX support in the template_project core." FORCE) +set(template_project_ENABLE_CUDA "${TEMPLATE_PROJECT_ENABLE_CUDA}" CACHE BOOL "Enable CUDA support in the template_project core." FORCE) +set(template_project_ENABLE_OPTIX "${TEMPLATE_PROJECT_ENABLE_OPTIX}" CACHE BOOL "Enable OptiX support in the template_project core." FORCE) set(ENABLE_TESTS OFF CACHE BOOL "Disable core template tests in the ROS 2 overlay build." FORCE) set(ENABLE_FETCH_CATCH2 OFF CACHE BOOL "Do not fetch Catch2 while building the ROS 2 overlay." FORCE) diff --git a/run_in_container.sh b/run_in_container.sh index 0f0ce03..2b19f70 100755 --- a/run_in_container.sh +++ b/run_in_container.sh @@ -1,55 +1,116 @@ #!/usr/bin/env bash -# Launch a binary or command inside this repo's container image, built -# standalone from .devcontainer/Dockerfile (no VS Code / devcontainers CLI -# required). The repository is mounted at /workspace, so binaries built on -# the host (e.g. ./build/my_app) can be executed directly. +# Launch commands in the repository's standalone development image or start a +# persistent container prepared for VS Code attachment. Both modes preserve +# host ownership for files written through the repository bind mount. # # Examples: # ./run_in_container.sh # interactive bash # ./run_in_container.sh ./build/my_app --flag # run a binary # ./run_in_container.sh --build -- ctest --test-dir build +# ./run_in_container.sh --vscode --engine podman # attach-ready container set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_NAME="$(basename "$ROOT_DIR")" -IMAGE_TAG="$(echo "${REPO_NAME}" | tr '[:upper:]' '[:lower:]')-dev:latest" +PROJECT_SLUG="$( + printf '%s' "$REPO_NAME" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9_.-]+/-/g; s/^[._-]+//; s/[._-]+$//' +)" +if [[ -z "$PROJECT_SLUG" ]]; then + echo "Could not derive a valid container name from '${REPO_NAME}'." + exit 1 +fi + +IMAGE_TAG="${PROJECT_SLUG}-dev:latest" ENGINE="" FORCE_BUILD="no" USE_GPU="yes" CUDA_VERSION="12.9" +MATLAB_ROOT="" +VSCODE_MODE="no" +VSCODE_USER="vscode" +VSCODE_CONTAINER_NAME="${PROJECT_SLUG}-vscode" +VSCODE_WORKSPACE="/workspaces/${PROJECT_SLUG}" +MATLAB_LABEL_KEY="dev.${PROJECT_SLUG}.matlab-root" usage() { cat < Container name for --vscode + (default: ${VSCODE_CONTAINER_NAME}). + --matlab-root Read-only MATLAB installation exposed at the same path. --image Image tag (default: ${IMAGE_TAG}). --engine Container engine: docker or podman (default: autodetect). - --cuda-version CUDA toolkit version build-arg (default: ${CUDA_VERSION}). + --cuda-version CUDA toolkit version build argument + (default: ${CUDA_VERSION}). -h, --help Show this help. GPU notes: - - Docker: requires the NVIDIA Container Toolkit (uses --gpus all). - - Podman: requires a CDI spec, e.g. - sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml - (uses --device nvidia.com/gpu=all). + - Docker requires the NVIDIA Container Toolkit and uses --gpus all. + - Podman requires an NVIDIA CDI specification and uses + --device nvidia.com/gpu=all. +EOF +} + +print_vscode_instructions() { + cat </dev/null || true)" + if [[ -z "$MATLAB_ROOT" || ! -d "$MATLAB_ROOT" ]]; then + echo "--matlab-root must identify an existing directory." + exit 1 + fi + if [[ ! -f "${MATLAB_ROOT}/extern/include/mex.h" ]]; then + echo "--matlab-root does not contain extern/include/mex.h: ${MATLAB_ROOT}" + exit 1 + fi + + matlab_args_=( + --mount "type=bind,source=${MATLAB_ROOT},target=${MATLAB_ROOT},readonly" + --env "MATLAB_ROOT_DIR=${MATLAB_ROOT}" + ) +fi + +# Select the engine explicitly before image inspection so all later commands +# use one stable runtime and ownership model. if [[ -z "$ENGINE" ]]; then if command -v docker >/dev/null 2>&1; then ENGINE="docker" @@ -83,44 +179,192 @@ if [[ -z "$ENGINE" ]]; then fi fi -# Build the image if missing or forced -need_build="$FORCE_BUILD" -if [[ "$need_build" != "yes" ]] && ! "$ENGINE" image inspect "$IMAGE_TAG" >/dev/null 2>&1; then - need_build="yes" +need_build_="$FORCE_BUILD" +if [[ "$need_build_" != "yes" ]] \ + && ! "$ENGINE" image inspect "$IMAGE_TAG" >/dev/null 2>&1; then + need_build_="yes" fi -if [[ "$need_build" == "yes" ]]; then - echo "Building image ${IMAGE_TAG} with ${ENGINE} (INSTALL_CUDA=on, CUDA ${CUDA_VERSION})..." +if [[ "$need_build_" == "yes" ]]; then + echo "Building ${IMAGE_TAG} with ${ENGINE} (CUDA ${CUDA_VERSION})..." "$ENGINE" build \ --build-arg INSTALL_CUDA=on \ --build-arg CUDA_VERSION="$CUDA_VERSION" \ - -t "$IMAGE_TAG" \ + --tag "$IMAGE_TAG" \ "$ROOT_DIR/.devcontainer" fi -# GPU flags per engine -gpu_args=() +# Keep Podman labeling and user-namespace behavior separate from GPU flags. +# This preserves bind-mount ownership even when GPU access is disabled. +podman_rootless_="" +engine_args_=() +if [[ "$ENGINE" == "podman" ]]; then + podman_rootless_="$( + "$ENGINE" info --format '{{.Host.Security.Rootless}}' 2>/dev/null || true + )" + if [[ "$podman_rootless_" != "true" \ + && "$podman_rootless_" != "false" ]]; then + echo "Could not determine whether Podman is rootless." + exit 1 + fi + engine_args_+=(--security-opt=label=disable) +fi + +gpu_args_=() if [[ "$USE_GPU" == "yes" ]]; then if [[ "$ENGINE" == "docker" ]]; then - gpu_args=(--gpus all) + gpu_args_=(--gpus all) else - gpu_args=(--device nvidia.com/gpu=all --security-opt=label=disable) + gpu_args_=(--device nvidia.com/gpu=all) fi fi -# Allocate a TTY only when attached to one -tty_args=() -if [[ -t 0 && -t 1 ]]; then - tty_args=(-it) +if [[ "$VSCODE_MODE" == "yes" ]]; then + # Reuse an existing attachment container only when its immutable MATLAB + # mount matches the requested configuration. + if "$ENGINE" container inspect "$VSCODE_CONTAINER_NAME" \ + >/dev/null 2>&1; then + container_running_="$( + "$ENGINE" container inspect \ + --format '{{.State.Running}}' "$VSCODE_CONTAINER_NAME" + )" + if [[ "$container_running_" == "true" ]]; then + container_matlab_root_="$( + "$ENGINE" container inspect \ + --format "{{index .Config.Labels \"${MATLAB_LABEL_KEY}\"}}" \ + "$VSCODE_CONTAINER_NAME" 2>/dev/null || true + )" + [[ "$container_matlab_root_" == "" ]] \ + && container_matlab_root_="" + if [[ -n "$MATLAB_ROOT" \ + && "$MATLAB_ROOT" != "$container_matlab_root_" ]]; then + echo "The running container uses a different MATLAB root: " \ + "${container_matlab_root_:-not mounted}" + echo "Stop it before recreating it with ${MATLAB_ROOT}." + exit 1 + fi + [[ -n "$container_matlab_root_" ]] \ + && MATLAB_ROOT="$container_matlab_root_" + echo "VS Code container is already running." + print_vscode_instructions + exit 0 + fi + + echo "Container '${VSCODE_CONTAINER_NAME}' exists but is stopped." + echo "Remove it before recreating the attachment container:" + echo " ${ENGINE} rm ${VSCODE_CONTAINER_NAME}" + exit 1 + fi + + # Query the image rather than assuming the devcontainer user's numeric IDs. + # Docker requires an exact host match; rootless Podman maps the host user to + # the image's declared development user through keep-id. + if ! image_user_ids_="$( + # The substitutions are intentionally evaluated by the image shell. + # shellcheck disable=SC2016 + "$ENGINE" run --rm --entrypoint /bin/sh "$IMAGE_TAG" \ + -c 'printf "%s:%s\n" "$(id -u vscode)" "$(id -g vscode)"' + )"; then + echo "Image '${IMAGE_TAG}' does not provide the '${VSCODE_USER}' user." + exit 1 + fi + if [[ ! "$image_user_ids_" =~ ^[0-9]+:[0-9]+$ ]]; then + echo "Could not resolve ${VSCODE_USER} numeric IDs in ${IMAGE_TAG}." + exit 1 + fi + image_user_uid_="${image_user_ids_%%:*}" + image_user_gid_="${image_user_ids_##*:}" + host_uid_="$(id -u)" + host_gid_="$(id -g)" + + vscode_engine_args_=("${engine_args_[@]}") + if [[ "$ENGINE" == "podman" ]]; then + if [[ "$podman_rootless_" != "true" ]]; then + echo "--vscode requires rootless Podman for ownership-preserving keep-id." + exit 1 + fi + vscode_engine_args_+=( + --userns="keep-id:uid=${image_user_uid_},gid=${image_user_gid_}" + ) + if [[ "$USE_GPU" == "yes" ]]; then + vscode_engine_args_+=(--group-add keep-groups) + fi + elif [[ "$image_user_ids_" != "${host_uid_}:${host_gid_}" ]]; then + echo "Docker cannot safely map ${VSCODE_USER} (${image_user_ids_}) to " \ + "host ${host_uid_}:${host_gid_}." + echo "Use the normal Dev Containers workflow or tailor the image user IDs." + exit 1 + fi + + # Forward only a live SSH-agent socket. The ephemeral container is removed + # when stopped, so a later host session can supply a fresh socket path. + vscode_ssh_args_=() + if [[ -n "${SSH_AUTH_SOCK:-}" ]]; then + ssh_agent_path_="$(readlink -f -- "$SSH_AUTH_SOCK" 2>/dev/null || true)" + if [[ -S "$ssh_agent_path_" ]]; then + vscode_ssh_args_=( + --mount \ + "type=bind,source=${ssh_agent_path_},target=/tmp/host-ssh-agent.sock" + --env SSH_AUTH_SOCK=/tmp/host-ssh-agent.sock + ) + else + echo "Warning: SSH_AUTH_SOCK is not a socket; forwarding is disabled." + fi + else + echo "Warning: SSH_AUTH_SOCK is unset; forwarding is disabled." + fi + + container_id_="$( + "$ENGINE" run --detach --rm --init \ + --name "$VSCODE_CONTAINER_NAME" \ + "${vscode_engine_args_[@]}" \ + "${gpu_args_[@]}" \ + "${vscode_ssh_args_[@]}" \ + "${matlab_args_[@]}" \ + --user "$VSCODE_USER" \ + --label "${MATLAB_LABEL_KEY}=${MATLAB_ROOT}" \ + --mount "type=bind,source=${ROOT_DIR},target=${VSCODE_WORKSPACE}" \ + --workdir "$VSCODE_WORKSPACE" \ + --env DISPLAY="${DISPLAY:-}" \ + --entrypoint /usr/bin/sleep \ + "$IMAGE_TAG" infinity + )" + + echo "Started VS Code container ${container_id_}." + print_vscode_instructions + exit 0 +fi + +# Ordinary command mode uses the host numeric IDs and a universally writable +# temporary HOME so files created in the repository remain host-owned without +# depending on the image's passwd database. +host_uid_="$(id -u)" +host_gid_="$(id -g)" +identity_args_=( + --user "${host_uid_}:${host_gid_}" + --env HOME=/tmp +) +if [[ "$ENGINE" == "podman" && "$podman_rootless_" == "true" ]]; then + identity_args_+=(--userns=keep-id) + if [[ "$USE_GPU" == "yes" ]]; then + identity_args_+=(--group-add keep-groups) + fi fi -# Default command: interactive shell +tty_args_=() +if [[ -t 0 && -t 1 ]]; then + tty_args_=(-it) +fi if [[ $# -eq 0 ]]; then set -- bash fi -exec "$ENGINE" run --rm "${tty_args[@]}" \ - "${gpu_args[@]}" \ - -v "$ROOT_DIR":/workspace \ - -w /workspace \ - -e DISPLAY="${DISPLAY:-}" \ +exec "$ENGINE" run --rm \ + "${tty_args_[@]}" \ + "${engine_args_[@]}" \ + "${identity_args_[@]}" \ + "${gpu_args_[@]}" \ + "${matlab_args_[@]}" \ + --mount "type=bind,source=${ROOT_DIR},target=/workspace" \ + --workdir /workspace \ + --env DISPLAY="${DISPLAY:-}" \ "$IMAGE_TAG" "$@" diff --git a/scripts/use_system_matlab_libraries.sh b/scripts/use_system_matlab_libraries.sh new file mode 100755 index 0000000..6ad1fa0 --- /dev/null +++ b/scripts/use_system_matlab_libraries.sh @@ -0,0 +1,460 @@ +#!/usr/bin/env bash +# Replace selected MATLAB runtime-library symlinks with host-default libraries. +# Inspection is the default; --apply and --restore are explicit root-only modes. + +set -Eeuo pipefail + +readonly BACKUP_SUFFIX='.matlab-backup' +readonly DEFAULT_MATLAB_PREFIX='/usr/local/MATLAB' +readonly ORIGINAL_ARGUMENTS=("$@") + +declare MATLAB_ROOT='' +declare MATLAB_ROOT_ARGUMENT='' +declare MATLAB_VERSION='' +declare MATLAB_PREFIX="${DEFAULT_MATLAB_PREFIX}" +declare MODE='dry-run' +declare CAPTURED_OUTPUT='' +declare -i SELECT_LIBSTDCXX=0 +declare -i SELECT_OPENCV=0 + +declare -a LINK_PATHS=() +declare -a CURRENT_TARGETS=() +declare -a PLANNED_TARGETS=() +declare -a LIBRARY_FAMILIES=() +declare -A HOST_LIBRARY_TARGETS=() +declare -A HOST_LIBRARY_SONAMES=() +declare LDCONFIG_CACHE='' + +usage() { + cat <<'EOF' +Use host-default C++ and OpenCV libraries in a MATLAB installation. + +Usage: + use_system_matlab_libraries.sh [location] [selection] [mode] + +Location: + --matlab-root PATH Use an exact MATLAB installation root. + --matlab-version RELEASE Use RELEASE below --matlab-prefix. + --matlab-prefix PATH MATLAB prefix (default: /usr/local/MATLAB). + +Selection (at least one is required): + --libstdcxx Manage active libstdc++.so.6 links. + --opencv Manage MATLAB libopencv SONAME links. + --all Select both library families. + +Mode: + [no mode] Inspect and print the plan without changes. + --apply Back up and replace links; requires sudo/root. + --restore Restore links from backups; requires sudo/root. + +Options: + -h, --help Show this help. + +Examples: + ./scripts/use_system_matlab_libraries.sh --matlab-version R2024b --all + sudo ./scripts/use_system_matlab_libraries.sh --matlab-version R2024b --all --apply + sudo ./scripts/use_system_matlab_libraries.sh --matlab-version R2024b --all --restore +EOF +} + +info() { + printf '[INFO] %s\n' "$*" +} + +warn() { + printf '[WARN] %s\n' "$*" >&2 +} + +die() { + printf '[ERROR] %s\n' "$*" >&2 + exit 1 +} + +print_command_() { + printf '[CMD]' + printf ' %q' "$@" + printf '\n' +} + +run_capture_() { + local status_ + + print_command_ "$@" + set +e + CAPTURED_OUTPUT="$("$@" 2>&1)" + status_=$? + set -e + + if [[ -n "${CAPTURED_OUTPUT}" ]]; then + printf '[OUTPUT]\n%s\n' "${CAPTURED_OUTPUT}" + else + printf '[OUTPUT] \n' + fi + printf '[EXIT] %d\n' "${status_}" + + ((status_ == 0)) || die "Command failed with exit status ${status_}: $*" +} + +parse_arguments_() { + while (($# > 0)); do + case "$1" in + --matlab-root) + (($# >= 2)) || die '--matlab-root requires a path.' + [[ -z "${MATLAB_ROOT_ARGUMENT}" ]] || die '--matlab-root may be specified only once.' + MATLAB_ROOT_ARGUMENT="$2" + shift 2 + ;; + --matlab-version) + (($# >= 2)) || die '--matlab-version requires a release such as R2024b.' + [[ -z "${MATLAB_VERSION}" ]] || die '--matlab-version may be specified only once.' + MATLAB_VERSION="$2" + shift 2 + ;; + --matlab-prefix) + (($# >= 2)) || die '--matlab-prefix requires a path.' + MATLAB_PREFIX="$2" + shift 2 + ;; + --libstdcxx) + SELECT_LIBSTDCXX=1 + shift + ;; + --opencv) + SELECT_OPENCV=1 + shift + ;; + --all) + SELECT_LIBSTDCXX=1 + SELECT_OPENCV=1 + shift + ;; + --apply) + [[ "${MODE}" == 'dry-run' ]] || die '--apply conflicts with --restore.' + MODE='apply' + shift + ;; + --restore) + [[ "${MODE}" == 'dry-run' ]] || die '--restore conflicts with --apply.' + MODE='restore' + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1" + ;; + esac + done + + [[ -z "${MATLAB_ROOT_ARGUMENT}" || -z "${MATLAB_VERSION}" ]] || + die '--matlab-root and --matlab-version are mutually exclusive.' + ((SELECT_LIBSTDCXX || SELECT_OPENCV)) || + die 'Select at least one library family: --libstdcxx, --opencv, or --all.' +} + +canonicalize_path_() { + local input_path_="$1" + + [[ -e "${input_path_}" || -L "${input_path_}" ]] || die "Path does not exist: ${input_path_}" + run_capture_ readlink -f -- "${input_path_}" + [[ -n "${CAPTURED_OUTPUT}" ]] || die "Unable to canonicalize path: ${input_path_}" +} + +validate_matlab_root_() { + local candidate_root_="$1" + + [[ -x "${candidate_root_}/bin/matlab" ]] || + die "MATLAB launcher is missing or not executable: ${candidate_root_}/bin/matlab" + [[ -d "${candidate_root_}/sys/os/glnxa64" ]] || + die "MATLAB Linux runtime directory is missing: ${candidate_root_}/sys/os/glnxa64" +} + +resolve_matlab_root_() { + local candidate_path_='' + local canonical_launcher_='' + local -a discovered_launchers_=() + + if [[ -n "${MATLAB_ROOT_ARGUMENT}" ]]; then + candidate_path_="${MATLAB_ROOT_ARGUMENT}" + elif [[ -n "${MATLAB_VERSION}" ]]; then + candidate_path_="${MATLAB_PREFIX%/}/${MATLAB_VERSION}" + elif command -v matlab >/dev/null 2>&1; then + candidate_path_="$(command -v matlab)" + info "MATLAB launcher found on PATH: ${candidate_path_}" + canonicalize_path_ "${candidate_path_}" + canonical_launcher_="${CAPTURED_OUTPUT}" + [[ "${canonical_launcher_}" == */bin/matlab ]] || + die "Cannot infer MATLAB root from launcher: ${canonical_launcher_}" + candidate_path_="${canonical_launcher_%/bin/matlab}" + else + shopt -s nullglob + discovered_launchers_=("${MATLAB_PREFIX%/}"/R*/bin/matlab) + shopt -u nullglob + + ((${#discovered_launchers_[@]} > 0)) || + die "No MATLAB installation found below ${MATLAB_PREFIX}." + if ((${#discovered_launchers_[@]} > 1)); then + printf '[ERROR] Multiple MATLAB installations found:\n' >&2 + printf ' %s\n' "${discovered_launchers_[@]}" >&2 + die 'Use --matlab-root or --matlab-version to select one installation.' + fi + candidate_path_="${discovered_launchers_[0]%/bin/matlab}" + fi + + canonicalize_path_ "${candidate_path_}" + MATLAB_ROOT="${CAPTURED_OUTPUT}" + validate_matlab_root_ "${MATLAB_ROOT}" + info "MATLAB root: ${MATLAB_ROOT}" +} + +load_ldconfig_cache_() { + [[ -n "${LDCONFIG_CACHE}" ]] && return + command -v ldconfig >/dev/null 2>&1 || die 'ldconfig is required but was not found on PATH.' + run_capture_ ldconfig -p + LDCONFIG_CACHE="${CAPTURED_OUTPUT}" +} + +resolve_host_library_() { + local lookup_name_="$1" + local family_="$2" + local line_='' + local target_path_='' + local canonical_target_='' + local file_description_='' + local soname_output_='' + local soname_regex_='' + local soname_='' + + if [[ -n "${HOST_LIBRARY_TARGETS[${lookup_name_}]:-}" ]]; then + return + fi + + load_ldconfig_cache_ + while IFS= read -r line_; do + if [[ "${line_}" == *"${lookup_name_} ("* && + "${line_}" == *'x86-64'* && "${line_}" == *'=> '* ]]; then + target_path_="${line_##*=> }" + break + fi + done <<<"${LDCONFIG_CACHE}" + [[ -n "${target_path_}" ]] || die "Host library not found in ldconfig cache: ${lookup_name_}" + + canonicalize_path_ "${target_path_}" + canonical_target_="${CAPTURED_OUTPUT}" + [[ -f "${canonical_target_}" ]] || die "Host library is not a regular file: ${canonical_target_}" + [[ "${canonical_target_}" != "${MATLAB_ROOT}" && + "${canonical_target_}" != "${MATLAB_ROOT}/"* ]] || + die "Resolved host library is inside MATLAB: ${canonical_target_}" + + run_capture_ file -L -- "${canonical_target_}" + file_description_="${CAPTURED_OUTPUT}" + [[ "${file_description_}" == *'ELF 64-bit'* && "${file_description_}" == *'x86-64'* ]] || + die "Host library is not an x86-64 ELF shared object: ${canonical_target_}" + + run_capture_ readelf -d -- "${canonical_target_}" + soname_output_="${CAPTURED_OUTPUT}" + [[ "${soname_output_}" == *"${lookup_name_}"* ]] || + die "Host library SONAME does not match ${family_}: ${canonical_target_}" + + soname_regex_="${lookup_name_//./\\.}\\.([0-9]+)" + if [[ "${soname_output_}" =~ ${soname_regex_} ]]; then + soname_="${BASH_REMATCH[0]}" + else + soname_="${lookup_name_}" + fi + + HOST_LIBRARY_TARGETS["${lookup_name_}"]="${canonical_target_}" + HOST_LIBRARY_SONAMES["${lookup_name_}"]="${soname_}" + info "Host ${family_}: ${lookup_name_} -> ${canonical_target_}" +} + +append_plan_entry_() { + local link_path_="$1" + local planned_target_="$2" + local family_="$3" + local current_target_ + + [[ -L "${link_path_}" ]] || die "MATLAB library candidate is not a symlink: ${link_path_}" + run_capture_ readlink -- "${link_path_}" + current_target_="${CAPTURED_OUTPUT}" + + LINK_PATHS+=("${link_path_}") + CURRENT_TARGETS+=("${current_target_}") + PLANNED_TARGETS+=("${planned_target_}") + LIBRARY_FAMILIES+=("${family_}") +} + +planned_target_for_() { + local link_path_="$1" + local lookup_name_="$2" + local family_="$3" + local backup_path_="${link_path_}${BACKUP_SUFFIX}" + + if [[ "${MODE}" == 'restore' ]]; then + [[ -L "${backup_path_}" ]] || die "Restore backup is missing or invalid: ${backup_path_}" + run_capture_ readlink -- "${backup_path_}" + else + resolve_host_library_ "${lookup_name_}" "${family_}" + CAPTURED_OUTPUT="${HOST_LIBRARY_TARGETS[${lookup_name_}]}" + fi +} + +discover_libstdcxx_() { + local discovery_output_='' + local link_path_='' + local planned_target_='' + local -i count_=0 + + run_capture_ find "${MATLAB_ROOT}" -name 'libstdc++.so.6' -not -path '*/orig/*' -print + discovery_output_="${CAPTURED_OUTPUT}" + while IFS= read -r link_path_; do + [[ -n "${link_path_}" ]] || continue + planned_target_for_ "${link_path_}" 'libstdc++.so.6' 'libstdc++' + planned_target_="${CAPTURED_OUTPUT}" + append_plan_entry_ "${link_path_}" "${planned_target_}" 'libstdc++' + count_+=1 + done <<<"${discovery_output_}" + + ((count_ > 0)) || die "No active libstdc++.so.6 links found below ${MATLAB_ROOT}." +} + +discover_opencv_() { + local opencv_dir_="${MATLAB_ROOT}/bin/glnxa64" + local discovery_output_='' + local link_path_='' + local link_name_='' + local lookup_name_='' + local matlab_soname_version_='' + local system_soname_='' + local system_soname_version_='' + local planned_target_='' + local -i count_=0 + + [[ -d "${opencv_dir_}" ]] || die "MATLAB OpenCV directory not found: ${opencv_dir_}" + run_capture_ find "${opencv_dir_}" -maxdepth 1 -mindepth 1 -name 'libopencv_*.so.*' -print + discovery_output_="${CAPTURED_OUTPUT}" + + while IFS= read -r link_path_; do + [[ -n "${link_path_}" ]] || continue + link_name_="${link_path_##*/}" + [[ "${link_name_}" =~ ^(libopencv_.*\.so)\.([0-9]+)$ ]] || continue + lookup_name_="${BASH_REMATCH[1]}" + matlab_soname_version_="${BASH_REMATCH[2]}" + + planned_target_for_ "${link_path_}" "${lookup_name_}" 'OpenCV' + planned_target_="${CAPTURED_OUTPUT}" + if [[ "${MODE}" != 'restore' ]]; then + system_soname_="${HOST_LIBRARY_SONAMES[${lookup_name_}]}" + if [[ "${system_soname_}" =~ \.so\.([0-9]+)$ ]]; then + system_soname_version_="${BASH_REMATCH[1]}" + if [[ "${matlab_soname_version_}" != "${system_soname_version_}" ]]; then + warn "OpenCV SONAME change: ${matlab_soname_version_} -> ${system_soname_version_} (${link_name_})" + fi + fi + fi + + append_plan_entry_ "${link_path_}" "${planned_target_}" 'OpenCV' + count_+=1 + done <<<"${discovery_output_}" + + ((count_ > 0)) || die "No MATLAB OpenCV SONAME links found in ${opencv_dir_}." +} + +validate_backups_() { + local index_ + local backup_path_='' + + [[ "${MODE}" == 'apply' ]] || return 0 + for index_ in "${!LINK_PATHS[@]}"; do + [[ "${CURRENT_TARGETS[index_]}" != "${PLANNED_TARGETS[index_]}" ]] || continue + backup_path_="${LINK_PATHS[index_]}${BACKUP_SUFFIX}" + if [[ -e "${backup_path_}" || -L "${backup_path_}" ]]; then + [[ -L "${backup_path_}" ]] || die "Backup exists but is not a symlink: ${backup_path_}" + fi + done +} + +print_plan_() { + local index_ + + for index_ in "${!LINK_PATHS[@]}"; do + if [[ "${CURRENT_TARGETS[index_]}" == "${PLANNED_TARGETS[index_]}" ]]; then + printf '[UNCHANGED] %s: %s -> %s\n' \ + "${LIBRARY_FAMILIES[index_]}" "${LINK_PATHS[index_]}" "${CURRENT_TARGETS[index_]}" + else + printf '[%s] %s: %s\n' "${MODE^^}" "${LIBRARY_FAMILIES[index_]}" "${LINK_PATHS[index_]}" + printf ' current: %s\n' "${CURRENT_TARGETS[index_]}" + printf ' target : %s\n' "${PLANNED_TARGETS[index_]}" + fi + done +} + +require_root_() { + local effective_uid_ + + [[ "${MODE}" != 'dry-run' ]] || return 0 + run_capture_ id -u + effective_uid_="${CAPTURED_OUTPUT}" + if [[ "${effective_uid_}" != '0' ]]; then + printf '[ERROR] %s requires root privileges. Re-run with:\n sudo' "${MODE}" >&2 + printf ' %q' "$0" "${ORIGINAL_ARGUMENTS[@]}" >&2 + printf '\n' >&2 + exit 1 + fi +} + +apply_plan_() { + local index_ + local link_path_='' + local current_target_='' + local planned_target_='' + local backup_path_='' + + [[ "${MODE}" != 'dry-run' ]] || return 0 + for index_ in "${!LINK_PATHS[@]}"; do + link_path_="${LINK_PATHS[index_]}" + current_target_="${CURRENT_TARGETS[index_]}" + planned_target_="${PLANNED_TARGETS[index_]}" + [[ "${current_target_}" != "${planned_target_}" ]] || continue + + if [[ "${MODE}" == 'apply' ]]; then + backup_path_="${link_path_}${BACKUP_SUFFIX}" + if [[ ! -L "${backup_path_}" ]]; then + run_capture_ ln -s -- "${current_target_}" "${backup_path_}" + info "Backup created: ${backup_path_} -> ${current_target_}" + else + info "Backup retained: ${backup_path_}" + fi + fi + + run_capture_ ln -sfn -- "${planned_target_}" "${link_path_}" + info "Link updated: ${link_path_} -> ${planned_target_}" + done +} + +main() { + parse_arguments_ "$@" + resolve_matlab_root_ + + if ((SELECT_LIBSTDCXX)); then + discover_libstdcxx_ + fi + if ((SELECT_OPENCV)); then + discover_opencv_ + fi + + validate_backups_ + print_plan_ + require_root_ + apply_plan_ + + if [[ "${MODE}" == 'dry-run' ]]; then + info 'Dry-run complete; no links were changed.' + else + info "${MODE^} complete. Backup links were retained." + fi +} + +main "$@" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0104588..92f1a7e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,18 +25,11 @@ add_subdirectory(template_src_kernels) message(STATUS "Adding module: wrapped_impl") add_subdirectory(wrapped_impl) - - - if (BUILD_AS_MAIN_PROJECT AND ${${BUILD_PROGRAMS_OPTION_NAME}}) message(STATUS "Adding module: bin") add_subdirectory(bin) endif() -# Exclude EXCLUDED_LIST from the list of src files -set(EXCLUDED_LIST "") -list(REMOVE_ITEM srcLibFiles ${EXCLUDED_LIST}) - # Install headers from this directory file(GLOB installable_headers "*.h") # Discover and install all header files install(FILES ${installable_headers} @@ -221,12 +214,27 @@ configure_package_config_file( INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${project_name} ) +# Stage optional package-owned find modules for build-tree and installed +# consumers while leaving the base template dependency set unchanged. +set(_package_module_build_dir "${PROJECT_BINARY_DIR}/modules") +file(MAKE_DIRECTORY "${_package_module_build_dir}") +configure_file( + "${PROJECT_SOURCE_DIR}/cmake/FindTensorRT.cmake" + "${_package_module_build_dir}/FindTensorRT.cmake" + COPYONLY +) + install(FILES "${PROJECT_BINARY_DIR}/${project_name}Config.cmake" "${PROJECT_BINARY_DIR}/${project_name}ConfigVersion.cmake" DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${project_name} ) +install(FILES + "${PROJECT_SOURCE_DIR}/cmake/FindTensorRT.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${project_name}/modules +) + # Add target export to allow usage of build by other consumer projects export(EXPORT ${project_name} FILE "${PROJECT_BINARY_DIR}/${project_name}Target.cmake" diff --git a/src/cmake/template_projectConfig.cmake.in b/src/cmake/template_projectConfig.cmake.in index de12633..df2cc0b 100644 --- a/src/cmake/template_projectConfig.cmake.in +++ b/src/cmake/template_projectConfig.cmake.in @@ -2,6 +2,11 @@ include(CMakeFindDependencyMacro) +# Make optional package-owned find modules available to derived ML libraries +# without enabling any additional dependency in the base template. +set(_template_project_saved_module_path "${CMAKE_MODULE_PATH}") +list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/modules") + # Automatically call find_dependency for each dependency set(_DEPENDENCIES @EXPORT_TARGET_DEPS@) @@ -9,6 +14,9 @@ foreach(dep ${_DEPENDENCIES}) find_dependency(${dep} REQUIRED) endforeach() +set(CMAKE_MODULE_PATH "${_template_project_saved_module_path}") +unset(_template_project_saved_module_path) + if(@ENABLE_OPTIX@) set(_template_project_optix_root_hints "") foreach(_optix_root_variable IN ITEMS OPTIX_ROOT OptiX_ROOT OptiX_INSTALL_DIR) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d545f6c..135d05d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -123,6 +123,58 @@ set_tests_properties( TIMEOUT 60 ) +add_test( + NAME template_project_wrapper_maintenance + COMMAND bash + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/test_wrapper_maintenance.sh +) +set_tests_properties( + template_project_wrapper_maintenance + PROPERTIES + LABELS "wrapper;maintenance;safety;template" + TIMEOUT 60 +) + +add_test( + NAME template_project_container_launcher + COMMAND bash + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/test_run_in_container.sh +) +set_tests_properties( + template_project_container_launcher + PROPERTIES + LABELS "container;safety;template" + TIMEOUT 60 +) + +add_test( + NAME template_project_nested_option_isolation + COMMAND ${CMAKE_COMMAND} + -DTEST_TEMPLATE_SOURCE_DIR=${PROJECT_SOURCE_DIR} + -DTEST_BINARY_ROOT=${PROJECT_BINARY_DIR}/nested_option_isolation + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/VerifyTemplateProjectNestedOptionIsolation.cmake +) +set_tests_properties( + template_project_nested_option_isolation + PROPERTIES + LABELS "nested;options;template" + TIMEOUT 180 +) + +add_test( + NAME template_project_tensorrt_module + COMMAND ${CMAKE_COMMAND} + -DTEST_TEMPLATE_SOURCE_DIR=${PROJECT_SOURCE_DIR} + -DTEST_BINARY_ROOT=${PROJECT_BINARY_DIR}/tensorrt_module + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/VerifyTemplateProjectTensorRTModule.cmake +) +set_tests_properties( + template_project_tensorrt_module + PROPERTIES + LABELS "package;tensorrt;template" + TIMEOUT 300 +) + add_test( NAME template_project_python_packaging COMMAND ${CMAKE_COMMAND} diff --git a/tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake b/tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake index a3bd0bf..56d713f 100644 --- a/tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake +++ b/tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake @@ -116,6 +116,45 @@ _expect_clean_rejection( "${_foreign_build}" "Refusing to clean a build owned by") +# Invoke the copied helper through an absolute path from a different checkout. +# A relative build path must still resolve against the helper's owning checkout, +# leaving the caller's equally named CMake build untouched. +set(_foreign_cwd_build "${_foreign_source}/build_absolute_invocation") +_run_process( + "Configure foreign-CWD build" + "${CMAKE_COMMAND}" + -S "${_foreign_source}" + -B "${_foreign_cwd_build}") +file(WRITE + "${_foreign_cwd_build}/must_be_preserved.txt" + "foreign checkout build output\n") +execute_process( + COMMAND + bash "${_fixture_build_script}" + -B build_absolute_invocation + --clean + --skip-tests + WORKING_DIRECTORY "${_foreign_source}" + RESULT_VARIABLE _foreign_cwd_result + OUTPUT_VARIABLE _foreign_cwd_stdout + ERROR_VARIABLE _foreign_cwd_stderr) +if(NOT _foreign_cwd_result EQUAL 0) + message(FATAL_ERROR + "Absolute helper invocation from a foreign CWD failed with exit code " + "${_foreign_cwd_result}.\n" + "stdout:\n${_foreign_cwd_stdout}\n" + "stderr:\n${_foreign_cwd_stderr}") +endif() +if(NOT EXISTS "${_foreign_cwd_build}/must_be_preserved.txt") + message(FATAL_ERROR + "Absolute helper invocation removed the foreign checkout's build tree.") +endif() +if(NOT EXISTS + "${_fixture_source}/build_absolute_invocation/CMakeCache.txt") + message(FATAL_ERROR + "Relative build path did not resolve against the helper's checkout.") +endif() + # Accept an owned cache, remove its sentinel, and recreate a usable build tree. set(_valid_build "${_fixture_source}/build_valid") _run_process( diff --git a/tests/cmake/VerifyTemplateProjectNestedOptionIsolation.cmake b/tests/cmake/VerifyTemplateProjectNestedOptionIsolation.cmake new file mode 100644 index 0000000..e9d8d39 --- /dev/null +++ b/tests/cmake/VerifyTemplateProjectNestedOptionIsolation.cmake @@ -0,0 +1,187 @@ +cmake_minimum_required(VERSION 3.15) + +# Verify nested project-option isolation and top-level legacy-option migration. +foreach(required_var TEST_TEMPLATE_SOURCE_DIR TEST_BINARY_ROOT) + if(NOT DEFINED ${required_var}) + message(FATAL_ERROR "Missing required variable: ${required_var}") + endif() +endforeach() + +if(NOT EXISTS "${TEST_TEMPLATE_SOURCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR + "Invalid template source directory: ${TEST_TEMPLATE_SOURCE_DIR}") +endif() + +function(_run_success step_name) + execute_process( + COMMAND ${ARGN} + RESULT_VARIABLE _result + OUTPUT_VARIABLE _stdout + ERROR_VARIABLE _stderr) + if(NOT _result EQUAL 0) + message(FATAL_ERROR + "${step_name} failed with exit code ${_result}.\n" + "stdout:\n${_stdout}\n" + "stderr:\n${_stderr}") + endif() +endfunction() + +function(_read_cache_value cache_path cache_key out_var) + file(STRINGS "${cache_path}" _cache_lines REGEX "^${cache_key}:") + list(LENGTH _cache_lines _cache_line_count) + if(NOT _cache_line_count EQUAL 1) + message(FATAL_ERROR "Missing generated CMake cache field: ${cache_key}") + endif() + list(GET _cache_lines 0 _cache_line) + string(REGEX REPLACE "^[^=]*=" "" _cache_value "${_cache_line}") + set(${out_var} "${_cache_value}" PARENT_SCOPE) +endfunction() + +function(_require_cache_value cache_path cache_key expected_value) + _read_cache_value("${cache_path}" "${cache_key}" _actual_value) + if(NOT _actual_value STREQUAL expected_value) + message(FATAL_ERROR + "Expected ${cache_key}=${expected_value}, got ${_actual_value}.") + endif() +endfunction() + +function(_require_cache_entry_absent cache_path cache_key) + file(STRINGS "${cache_path}" _cache_lines REGEX "^${cache_key}:") + if(_cache_lines) + message(FATAL_ERROR + "Legacy cache field ${cache_key} was not removed after migration.") + endif() +endfunction() + +file(REMOVE_RECURSE "${TEST_BINARY_ROOT}") +set(_parent_source "${TEST_BINARY_ROOT}/parent") +set(_parent_build "${TEST_BINARY_ROOT}/build") +file(MAKE_DIRECTORY "${_parent_source}") + +# The parent deliberately owns conflicting generic values. Canonical template +# selectors keep the nested library active while disabling its CUDA/OptiX path. +file(WRITE "${_parent_source}/CMakeLists.txt" +"cmake_minimum_required(VERSION 3.15) +project(template_project_option_isolation_parent LANGUAGES CXX) + +set(PROJECT_METADATA_ONLY ON CACHE BOOL \"Parent metadata selector\" FORCE) +set(ENABLE_CUDA ON CACHE BOOL \"Parent CUDA selector\" FORCE) +set(ENABLE_OPTIX ON CACHE BOOL \"Parent OptiX selector\" FORCE) + +set(template_project_METADATA_ONLY OFF CACHE BOOL \"\" FORCE) +set(template_project_ENABLE_CUDA OFF CACHE BOOL \"\" FORCE) +set(template_project_ENABLE_OPTIX OFF CACHE BOOL \"\" FORCE) +set(ENABLE_TESTS OFF CACHE BOOL \"\" FORCE) +set(ENABLE_FETCH_CATCH2 OFF CACHE BOOL \"\" FORCE) +set(template_project_BUILD_PROGRAMS OFF CACHE BOOL \"\" FORCE) +set(template_project_BUILD_EXAMPLES OFF CACHE BOOL \"\" FORCE) + +add_subdirectory( + \"${TEST_TEMPLATE_SOURCE_DIR}\" + \"\${CMAKE_CURRENT_BINARY_DIR}/template_project_subbuild\" + EXCLUDE_FROM_ALL) + +if(NOT TARGET template_project::template_project) + message(FATAL_ERROR \"Nested template target was not created.\") +endif() +if(DEFINED CMAKE_CUDA_COMPILER) + message(FATAL_ERROR + \"Nested template consumed the parent's generic ENABLE_CUDA option.\") +endif() +if(NOT PROJECT_METADATA_ONLY OR NOT ENABLE_CUDA OR NOT ENABLE_OPTIX) + message(FATAL_ERROR \"Nested template changed parent-owned generic options.\") +endif() +") + +execute_process( + COMMAND + "${CMAKE_COMMAND}" + -S "${_parent_source}" + -B "${_parent_build}" + -DCMAKE_BUILD_TYPE=Release + RESULT_VARIABLE _configure_result + OUTPUT_VARIABLE _configure_stdout + ERROR_VARIABLE _configure_stderr) +if(NOT _configure_result EQUAL 0) + message(FATAL_ERROR + "Nested option-isolation configure failed with exit code " + "${_configure_result}.\n" + "stdout:\n${_configure_stdout}\n" + "stderr:\n${_configure_stderr}") +endif() + +# Compatibility aliases are one-config inputs. Reconfigure the same build to +# prove they update, rather than merely initialize, their canonical options. +set(_metadata_alias_build "${TEST_BINARY_ROOT}/metadata_alias_build") +_run_success( + "Enable metadata-only mode through the legacy alias" + "${CMAKE_COMMAND}" + -S "${TEST_TEMPLATE_SOURCE_DIR}" + -B "${_metadata_alias_build}" + -Dtemplate_project_METADATA_ONLY=OFF + -DPROJECT_METADATA_ONLY=ON) +set(_metadata_alias_cache "${_metadata_alias_build}/CMakeCache.txt") +_require_cache_value( + "${_metadata_alias_cache}" "template_project_METADATA_ONLY" "ON") +_require_cache_entry_absent( + "${_metadata_alias_cache}" "PROJECT_METADATA_ONLY") +file(STRINGS "${_metadata_alias_cache}" _metadata_cxx_compiler + REGEX "^CMAKE_CXX_COMPILER:") +if(_metadata_cxx_compiler) + message(FATAL_ERROR "Metadata-only alias unexpectedly enabled C++.") +endif() + +_run_success( + "Disable metadata-only mode through the legacy alias" + "${CMAKE_COMMAND}" + -S "${TEST_TEMPLATE_SOURCE_DIR}" + -B "${_metadata_alias_build}" + -Dtemplate_project_METADATA_ONLY=ON + -DPROJECT_METADATA_ONLY=OFF + -DENABLE_TESTS=OFF + -DENABLE_FETCH_CATCH2=OFF + -Dtemplate_project_BUILD_PROGRAMS=OFF + -Dtemplate_project_BUILD_EXAMPLES=OFF) +_require_cache_value( + "${_metadata_alias_cache}" "template_project_METADATA_ONLY" "OFF") +_require_cache_entry_absent( + "${_metadata_alias_cache}" "PROJECT_METADATA_ONLY") +_read_cache_value( + "${_metadata_alias_cache}" "CMAKE_CXX_COMPILER" _metadata_cxx_compiler) + +# Keep language selection disabled while exercising the CUDA and OptiX aliases; +# this validates cache migration without requiring either SDK on a CPU runner. +set(_feature_alias_build "${TEST_BINARY_ROOT}/feature_alias_build") +_run_success( + "Enable CUDA and OptiX through legacy aliases" + "${CMAKE_COMMAND}" + -S "${TEST_TEMPLATE_SOURCE_DIR}" + -B "${_feature_alias_build}" + -Dtemplate_project_METADATA_ONLY=ON + -Dtemplate_project_ENABLE_CUDA=OFF + -Dtemplate_project_ENABLE_OPTIX=OFF + -DENABLE_CUDA=ON + -DENABLE_OPTIX=ON) +set(_feature_alias_cache "${_feature_alias_build}/CMakeCache.txt") +foreach(_feature IN ITEMS CUDA OPTIX) + _require_cache_value( + "${_feature_alias_cache}" "template_project_ENABLE_${_feature}" "ON") + _require_cache_entry_absent( + "${_feature_alias_cache}" "ENABLE_${_feature}") +endforeach() + +_run_success( + "Disable CUDA and OptiX through legacy aliases" + "${CMAKE_COMMAND}" + -S "${TEST_TEMPLATE_SOURCE_DIR}" + -B "${_feature_alias_build}" + -Dtemplate_project_ENABLE_CUDA=ON + -Dtemplate_project_ENABLE_OPTIX=ON + -DENABLE_CUDA=OFF + -DENABLE_OPTIX=OFF) +foreach(_feature IN ITEMS CUDA OPTIX) + _require_cache_value( + "${_feature_alias_cache}" "template_project_ENABLE_${_feature}" "OFF") + _require_cache_entry_absent( + "${_feature_alias_cache}" "ENABLE_${_feature}") +endforeach() diff --git a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake index e20f71e..a064824 100644 --- a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake +++ b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake @@ -63,6 +63,7 @@ set(_generator_output_build "${TEST_BINARY_ROOT}/generator_output_build") set(_wheel_output "${TEST_BINARY_ROOT}/wheel_output") set(_wheel_install "${TEST_BINARY_ROOT}/wheel_install") set(_cmake_install "${TEST_BINARY_ROOT}/cmake_install") +set(_expected_wheel_version "1.0.0rc1+5.gabc1234") file(MAKE_DIRECTORY "${_fixture_source}/python/fixture_package" "${_wheel_output}") @@ -134,6 +135,9 @@ from .fixture_package import Runtime_value __all__ = [\"Runtime_value\"] ") +file(WRITE + "${_fixture_source}/python/fixture_package/stale_checkout_runtime.so.99" + "stale checkout-native artifact\n") set(_fixture_cmake_template [=[ cmake_minimum_required(VERSION 3.15) @@ -274,9 +278,15 @@ if(FIXTURE_GENERATOR_OUTPUT_NAME) "$,fixture_packaged_debug,fixture_packaged_release>") endif() +# Treat the generated package root as a build product and preserve the fixture +# source directory as immutable input. set(_package_source "${CMAKE_CURRENT_SOURCE_DIR}/python") -set(_package_dir "${_package_source}/fixture_package") -set(_package_build_dir "${CMAKE_CURRENT_BINARY_DIR}/python/fixture_package") +set(_package_source_dir "${_package_source}/fixture_package") +set(_package_build_root "${CMAKE_CURRENT_BINARY_DIR}/python") +set(_package_build_dir "${_package_build_root}/fixture_package") +_stage_python_package_sources( + "${_package_source_dir}" + "${_package_build_dir}") set_python_target_properties( fixture_package "fixture_package" @@ -302,7 +312,7 @@ configure_python_runtime_artifacts( fixture_package "${_package_build_dir}" "${_package_install_destination}" - "${_package_dir}/_wrapper_build.py" + "${_package_build_dir}/_wrapper_build.py" fixture_runtime fixture_dependency fixture_packaged @@ -310,13 +320,14 @@ configure_python_runtime_artifacts( set(PROJECT_NAME fixture_package) set(PROJECT_VERSION 1.0.0) +set(FULL_VERSION "1.0.0-rc.1+5.gabc1234") configure_file( "@TEST_TEMPLATE_SOURCE_DIR@/python/pyproject.toml.in" - "${_package_source}/pyproject.toml" + "${_package_build_root}/pyproject.toml" @ONLY) configure_file( "@TEST_TEMPLATE_SOURCE_DIR@/python/setup.py.in" - "${_package_source}/setup.py" + "${_package_build_root}/setup.py" @ONLY) install( @@ -324,11 +335,15 @@ install( LIBRARY DESTINATION "${_package_install_destination}" RUNTIME DESTINATION "${_package_install_destination}") install( - DIRECTORY "${_package_dir}/" + DIRECTORY "${_package_build_dir}/" DESTINATION "${_package_install_destination}" PATTERN "_wrapper_build.py" EXCLUDE PATTERN "__pycache__" EXCLUDE - PATTERN "*.pyc" EXCLUDE) + PATTERN "*.pyc" EXCLUDE + PATTERN "*.so*" EXCLUDE + PATTERN "*.dylib" EXCLUDE + PATTERN "*.dll" EXCLUDE + PATTERN "*.pyd" EXCLUDE) file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/python_install_root.txt" @@ -449,7 +464,8 @@ _run_step( # Verify exact wheel contents using target-derived names emitted by the fixture # configure, not platform-specific names duplicated in this verifier. file(WRITE "${TEST_BINARY_ROOT}/verify_wheel.py" -"from pathlib import Path +"from email.parser import Parser +from pathlib import Path import sys from zipfile import ZipFile @@ -457,6 +473,7 @@ wheel_path_ = Path(sys.argv[1]) expected_names_path_ = Path(sys.argv[2]) wrapper_name_path_ = Path(sys.argv[3]) unrelated_name_path_ = Path(sys.argv[4]) +expected_version_ = sys.argv[5] expected_runtime_names_ = { name_.strip() @@ -469,6 +486,15 @@ expected_native_names_ = expected_runtime_names_ | {wrapper_name_} with ZipFile(wheel_path_) as wheel_file_: archive_names_ = set(wheel_file_.namelist()) + metadata_names_ = [ + name_ + for name_ in archive_names_ + if name_.endswith(\".dist-info/METADATA\") + ] + assert len(metadata_names_) == 1, metadata_names_ + metadata_ = Parser().parsestr( + wheel_file_.read(metadata_names_[0]).decode(\"utf-8\") + ) packaged_native_names_ = { Path(name_).name @@ -486,6 +512,7 @@ assert packaged_native_names_ == expected_native_names_, ( ) assert unrelated_name_ not in packaged_native_names_ assert not any(name_.endswith(\"_wrapper_build.py\") for name_ in archive_names_) +assert metadata_[\"Version\"] == expected_version_, metadata_[\"Version\"] print(\"wheel_contents=ok\") ") file(WRITE "${TEST_BINARY_ROOT}/verify_install.py" @@ -535,12 +562,49 @@ _run_step( -S "${_fixture_source}" -B "${_fixture_build}" -DCMAKE_BUILD_TYPE=RelWithDebInfo) +if(EXISTS + "${_fixture_build}/python/fixture_package/stale_checkout_runtime.so.99") + message(FATAL_ERROR + "Configure staged a stale checkout-native package artifact.") +endif() _run_step( "Build self-contained Python packaging fixture" "${CMAKE_COMMAND}" --build "${_fixture_build}" --parallel 4) +# A configured package directory is a disposable build product. Reconfigure +# after injecting an undeclared file and require the package to be reconstructed +# exclusively from its source inputs. +set(_stale_package_marker + "${_fixture_build}/python/fixture_package/stale_review_marker.py") +file(WRITE "${_stale_package_marker}" "raise RuntimeError('stale package file')\n") +_run_step( + "Reconfigure fixture with a stale build-package file" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_fixture_build}" + -DCMAKE_BUILD_TYPE=RelWithDebInfo) +if(EXISTS "${_stale_package_marker}") + message(FATAL_ERROR + "Reconfigure retained undeclared Python package file: " + "${_stale_package_marker}") +endif() + +# Configuration and build must not add packaging outputs beside the source +# package used to seed the disposable fixture. +file(GLOB_RECURSE + _source_python_files + LIST_DIRECTORIES FALSE + RELATIVE "${_fixture_source}/python" + "${_fixture_source}/python/*") +set(_expected_source_python_files + "fixture_package/__init__.py;fixture_package/stale_checkout_runtime.so.99") +if(NOT "${_source_python_files}" STREQUAL "${_expected_source_python_files}") + message(FATAL_ERROR + "Python packaging mutated fixture sources: ${_source_python_files}") +endif() + # Rebuild an unlinked declared runtime through the wrapper target. Its staged # file must refresh even though the extension itself does not need to relink. file(WRITE "${_fixture_source}/packaged.c" @@ -569,7 +633,7 @@ _run_step( "Build isolated fixture wheel" "${_python_executable}" -m pip wheel - "${_fixture_source}/python" + "${_fixture_build}/python" --no-build-isolation --no-deps --wheel-dir "${_wheel_output}") @@ -588,7 +652,8 @@ _run_step( "${_wheel_path}" "${_fixture_build}/expected_runtime_names.txt" "${_fixture_build}/expected_wrapper_name.txt" - "${_fixture_build}/unrelated_name.txt") + "${_fixture_build}/unrelated_name.txt" + "${_expected_wheel_version}") _run_step( "Install fixture wheel into isolated target" "${_python_executable}" diff --git a/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake b/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake index ac062b5..92edd33 100644 --- a/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake +++ b/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake @@ -297,6 +297,28 @@ _run_success( -DENABLE_FETCH_CATCH2=OFF -Dtemplate_project_BUILD_PROGRAMS=OFF -Dtemplate_project_BUILD_EXAMPLES=OFF) + +# Create another checkout-owned build after the release tree was configured. +# CPack must refresh ownership at package time rather than preserving a stale +# configure-time inventory. +set(_late_owned_build "${_scratch_root}/build_late_sentinel") +file(MAKE_DIRECTORY "${_late_owned_build}") +file(WRITE + "${_late_owned_build}/CMakeCache.txt" + "CMAKE_HOME_DIRECTORY:INTERNAL=${_scratch_root}\n") +file(WRITE + "${_late_owned_build}/must_not_ship.txt" + "late generated build output\n") + +# A source-tree VERSION is only a fallback input. Make it stale after configure +# so the archive must retain the exact metadata generated in the build tree. +file(WRITE + "${_scratch_root}/VERSION" + "Project version: 1.2.3\n" + "Project version core: 1.2.3\n" + "Project version prerelease: stale\n" + "Project version metadata: source\n" + "Full version: 1.2.3-stale+source\n") _run_success( "Create canonical CPack source TGZ" "${CMAKE_COMMAND}" -E chdir "${_archive_output}" @@ -338,6 +360,10 @@ if(EXISTS "${_extracted_root}/generated/current_output") message(FATAL_ERROR "Canonical source archive contains the active nested binary tree") endif() +if(EXISTS "${_extracted_root}/build_late_sentinel") + message(FATAL_ERROR + "Canonical source archive contains a build created after configure") +endif() if(NOT EXISTS "${_extracted_root}/build_assets/must_ship.txt") message(FATAL_ERROR "Canonical source archive omitted legitimate build-prefixed source content") @@ -380,6 +406,7 @@ _run_failure( file(REMOVE_RECURSE "${_scratch_root}/build_assets" + "${_scratch_root}/build_late_sentinel" "${_scratch_root}/build_release_sentinel" "${_scratch_root}/build_transient" "${_scratch_root}/examples/build_release_sentinel" diff --git a/tests/cmake/VerifyTemplateProjectRos2Overlay.cmake b/tests/cmake/VerifyTemplateProjectRos2Overlay.cmake index ea28c1d..f4f7895 100644 --- a/tests/cmake/VerifyTemplateProjectRos2Overlay.cmake +++ b/tests/cmake/VerifyTemplateProjectRos2Overlay.cmake @@ -193,6 +193,45 @@ if(EXISTS "${_metadata_probe}/src") message(FATAL_ERROR "Metadata-only configure unexpectedly entered src/.") endif() +# Configure the overlay shim without compilers to verify that its stable facade +# forwards directly to the canonical root options used by nested consumers. +set(_ros2_facade_probe "${TEST_BINARY_ROOT}/ros2_facade_probe") +_run_success( + "Enable ROS 2 shim GPU facades in metadata-only mode" + "${CMAKE_COMMAND}" + -S "${_root}/ros2/template_project" + -B "${_ros2_facade_probe}" + -Dtemplate_project_METADATA_ONLY=ON + -DTEMPLATE_PROJECT_ENABLE_CUDA=ON + -DTEMPLATE_PROJECT_ENABLE_OPTIX=ON) +set(_ros2_facade_cache_path "${_ros2_facade_probe}/CMakeCache.txt") +foreach(_feature IN ITEMS CUDA OPTIX) + _read_cache_value( + "${_ros2_facade_cache_path}" "template_project_ENABLE_${_feature}" + _ros2_core_feature) + if(NOT _ros2_core_feature STREQUAL "ON") + message(FATAL_ERROR + "ROS 2 ${_feature} facade did not enable its canonical core option.") + endif() +endforeach() + +_run_success( + "Disable ROS 2 shim GPU facades in the same build" + "${CMAKE_COMMAND}" + -S "${_root}/ros2/template_project" + -B "${_ros2_facade_probe}" + -DTEMPLATE_PROJECT_ENABLE_CUDA=OFF + -DTEMPLATE_PROJECT_ENABLE_OPTIX=OFF) +foreach(_feature IN ITEMS CUDA OPTIX) + _read_cache_value( + "${_ros2_facade_cache_path}" "template_project_ENABLE_${_feature}" + _ros2_core_feature) + if(NOT _ros2_core_feature STREQUAL "OFF") + message(FATAL_ERROR + "ROS 2 ${_feature} facade did not disable its canonical core option.") + endif() +endforeach() + _run_success( "Parse and validate source ROS manifests" "${_python_executable}" diff --git a/tests/cmake/VerifyTemplateProjectTensorRTModule.cmake b/tests/cmake/VerifyTemplateProjectTensorRTModule.cmake new file mode 100644 index 0000000..bb8f89e --- /dev/null +++ b/tests/cmake/VerifyTemplateProjectTensorRTModule.cmake @@ -0,0 +1,132 @@ +cmake_minimum_required(VERSION 3.15) + +# Verify portable TensorRT discovery and module delivery without requiring a +# real SDK or linking vendor binaries. +foreach(required_var TEST_TEMPLATE_SOURCE_DIR TEST_BINARY_ROOT) + if(NOT DEFINED ${required_var}) + message(FATAL_ERROR "Missing required variable: ${required_var}") + endif() +endforeach() + +function(_run_step step_name) + execute_process( + COMMAND ${ARGN} + RESULT_VARIABLE _result + OUTPUT_VARIABLE _stdout + ERROR_VARIABLE _stderr) + if(NOT _result EQUAL 0) + message(FATAL_ERROR + "${step_name} failed with exit code ${_result}.\n" + "stdout:\n${_stdout}\n" + "stderr:\n${_stderr}") + endif() +endfunction() + +function(_configure_consumer step_name module_dir build_dir root_argument) + _run_step( + "${step_name}" + "${CMAKE_COMMAND}" + -S "${_consumer_source}" + -B "${build_dir}" + "-DTEST_TENSORRT_MODULE_DIR=${module_dir}" + "-D${root_argument}=${_tensorrt_root}") +endfunction() + +file(REMOVE_RECURSE "${TEST_BINARY_ROOT}") +set(_tensorrt_arch "aarch64-linux-gnu") +set(_tensorrt_root "${TEST_BINARY_ROOT}/TensorRT") +set(_tensorrt_include + "${_tensorrt_root}/targets/${_tensorrt_arch}/include") +set(_tensorrt_lib "${_tensorrt_root}/targets/${_tensorrt_arch}/lib") +set(_consumer_source "${TEST_BINARY_ROOT}/consumer") +file(MAKE_DIRECTORY + "${_tensorrt_include}" + "${_tensorrt_lib}" + "${_consumer_source}") + +file(WRITE "${_tensorrt_include}/NvInfer.h" "#pragma once\n") +file(WRITE "${_tensorrt_include}/NvInferVersion.h" +"#define NV_TENSORRT_MAJOR 10 +#define NV_TENSORRT_MINOR 7 +#define NV_TENSORRT_PATCH 0 +#define NV_TENSORRT_BUILD 1 +") +file(WRITE "${_tensorrt_lib}/libnvinfer.so" "fixture\n") +file(WRITE "${_tensorrt_lib}/libnvinfer_plugin.so" "fixture\n") + +file(WRITE "${_consumer_source}/CMakeLists.txt" +"cmake_minimum_required(VERSION 3.15) +project(tensorrt_module_consumer LANGUAGES NONE) +set(CMAKE_LIBRARY_ARCHITECTURE \"${_tensorrt_arch}\") +set(CMAKE_FIND_USE_SYSTEM_ENVIRONMENT_PATH FALSE) +set(CMAKE_FIND_USE_CMAKE_SYSTEM_PATH FALSE) +list(PREPEND CMAKE_MODULE_PATH \"\${TEST_TENSORRT_MODULE_DIR}\") +find_package(TensorRT 10.7 REQUIRED) +if(NOT TARGET TensorRT::nvinfer OR NOT TARGET TensorRT::nvinfer_plugin) + message(FATAL_ERROR \"TensorRT imported targets are unavailable.\") +endif() +if(NOT TensorRT_VERSION STREQUAL \"10.7.0.1\") + message(FATAL_ERROR \"Unexpected TensorRT version: \${TensorRT_VERSION}\") +endif() +if(NOT TensorRT_INCLUDE_DIRS STREQUAL \"${_tensorrt_include}\") + message(FATAL_ERROR \"Unexpected TensorRT includes: \${TensorRT_INCLUDE_DIRS}\") +endif() +list(LENGTH TensorRT_LIBRARIES _library_count) +if(NOT _library_count EQUAL 2) + message(FATAL_ERROR \"Unexpected TensorRT libraries: \${TensorRT_LIBRARIES}\") +endif() +") + +# Accept the canonical package-name hint and the established all-uppercase +# compatibility spelling against a non-x86 SDK layout. +_configure_consumer( + "Discover source TensorRT module through TensorRT_ROOT" + "${TEST_TEMPLATE_SOURCE_DIR}/cmake" + "${TEST_BINARY_ROOT}/consumer_source_canonical" + TensorRT_ROOT) +_configure_consumer( + "Discover source TensorRT module through TENSORRT_ROOT" + "${TEST_TEMPLATE_SOURCE_DIR}/cmake" + "${TEST_BINARY_ROOT}/consumer_source_compatibility" + TENSORRT_ROOT) + +set(_template_build "${TEST_BINARY_ROOT}/template_build") +set(_template_install "${TEST_BINARY_ROOT}/template_install") +_run_step( + "Configure template for TensorRT module delivery" + "${CMAKE_COMMAND}" + -S "${TEST_TEMPLATE_SOURCE_DIR}" + -B "${_template_build}" + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_INSTALL_LIBDIR=lib + -DENABLE_TESTS=OFF + -DENABLE_FETCH_CATCH2=OFF + -Dtemplate_project_ENABLE_CUDA=OFF + -Dtemplate_project_BUILD_PROGRAMS=OFF + -Dtemplate_project_BUILD_EXAMPLES=OFF) +_run_step( + "Build template for TensorRT module delivery" + "${CMAKE_COMMAND}" --build "${_template_build}" --parallel 4) +_run_step( + "Install template TensorRT module" + "${CMAKE_COMMAND}" --install "${_template_build}" --prefix "${_template_install}") + +set(_build_module_dir "${_template_build}/modules") +set(_install_module_dir + "${_template_install}/lib/cmake/template_project/modules") +foreach(_module_dir IN ITEMS "${_build_module_dir}" "${_install_module_dir}") + if(NOT EXISTS "${_module_dir}/FindTensorRT.cmake") + message(FATAL_ERROR "TensorRT module was not delivered to ${_module_dir}.") + endif() +endforeach() + +_configure_consumer( + "Discover build-tree TensorRT module" + "${_build_module_dir}" + "${TEST_BINARY_ROOT}/consumer_build_tree" + TensorRT_ROOT) +_configure_consumer( + "Discover installed TensorRT module" + "${_install_module_dir}" + "${TEST_BINARY_ROOT}/consumer_install_tree" + TensorRT_ROOT) diff --git a/tests/scripts/test_run_in_container.sh b/tests/scripts/test_run_in_container.sh new file mode 100644 index 0000000..fc92663 --- /dev/null +++ b/tests/scripts/test_run_in_container.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Validate container-launch arguments without requiring a daemon or image. + +set -Eeuo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +readonly REPO_ROOT +SCRIPT_PATH="${REPO_ROOT}/run_in_container.sh" +readonly SCRIPT_PATH +TEST_ROOT="$(mktemp -d)" +readonly TEST_ROOT +FAKE_BIN="${TEST_ROOT}/fake-bin" +ENGINE_LOG="${TEST_ROOT}/engine.log" +readonly FAKE_BIN ENGINE_LOG +PASS_COUNT=0 + +cleanup() { + if [[ -d "${TEST_ROOT}" ]]; then + rm -rf -- "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail() { + printf '[FAIL] %s\n' "$*" >&2 + exit 1 +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf '[PASS] %s\n' "$*" +} + +assert_log_contains() { + local expected_text_="$1" + grep -Fqx -- "ARG ${expected_text_}" "${ENGINE_LOG}" || { + sed -n '1,260p' "${ENGINE_LOG}" >&2 + fail "engine log does not contain argument '${expected_text_}'" + } +} + +assert_log_excludes() { + local unexpected_text_="$1" + if grep -Fqx -- "ARG ${unexpected_text_}" "${ENGINE_LOG}"; then + sed -n '1,260p' "${ENGINE_LOG}" >&2 + fail "engine log unexpectedly contains argument '${unexpected_text_}'" + fi +} + +create_fake_engine() { + mkdir -p "${FAKE_BIN}" + cat >"${FAKE_BIN}/container-engine" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail + +printf 'CALL %s\n' "$(basename "$0")" >>"${CONTAINER_ENGINE_LOG}" +for argument_ in "$@"; do + printf 'ARG %s\n' "${argument_}" >>"${CONTAINER_ENGINE_LOG}" +done + +if [[ "${1:-}" == "info" ]]; then + printf '%s\n' "${FAKE_PODMAN_ROOTLESS:-true}" + exit 0 +fi +if [[ "${1:-}" == "image" && "${2:-}" == "inspect" ]]; then + exit 0 +fi +if [[ "${1:-}" == "container" && "${2:-}" == "inspect" ]]; then + exit 1 +fi +if [[ "${1:-}" == "run" ]]; then + if [[ "$*" == *'id -u vscode'* ]]; then + printf '%s\n' "${FAKE_IMAGE_IDS}" + elif [[ " $* " == *' --detach '* ]]; then + printf 'fixture-container-id\n' + fi + exit 0 +fi + +exit 0 +EOF + chmod +x "${FAKE_BIN}/container-engine" + ln -s container-engine "${FAKE_BIN}/docker" + ln -s container-engine "${FAKE_BIN}/podman" +} + +run_launcher() { + : >"${ENGINE_LOG}" + env -u SSH_AUTH_SOCK \ + PATH="${FAKE_BIN}:${PATH}" \ + CONTAINER_ENGINE_LOG="${ENGINE_LOG}" \ + FAKE_IMAGE_IDS="$(id -u):$(id -g)" \ + bash "${SCRIPT_PATH}" "$@" >/dev/null +} + +test_docker_command_ownership() { + run_launcher --engine docker --no-gpu -- printf fixture + + assert_log_contains run + assert_log_contains "$(id -u):$(id -g)" + assert_log_contains HOME=/tmp + assert_log_contains "type=bind,source=${REPO_ROOT},target=/workspace" + assert_log_excludes --gpus + pass 'Docker command mode preserves host ownership without GPU flags' +} + +test_podman_command_ownership() { + run_launcher --engine podman -- printf fixture + + assert_log_contains --security-opt=label=disable + assert_log_contains --userns=keep-id + assert_log_contains nvidia.com/gpu=all + assert_log_contains keep-groups + pass 'rootless Podman command mode preserves ownership and GPU groups' +} + +test_vscode_attachment_contract() { + run_launcher --engine docker --no-gpu --vscode \ + --container-name fixture-vscode + + assert_log_contains --detach + assert_log_contains fixture-vscode + assert_log_contains vscode + assert_log_contains "type=bind,source=${REPO_ROOT},target=/workspaces/cpp_cuda_template_project" + assert_log_contains /usr/bin/sleep + assert_log_contains infinity + pass 'VS Code mode starts a stable host-owned attachment container' +} + +test_invalid_matlab_root_stops_before_engine() { + local invalid_matlab_root_="${TEST_ROOT}/invalid-matlab" + local output_file_="${TEST_ROOT}/invalid-matlab.out" + + mkdir -p "${invalid_matlab_root_}" + : >"${ENGINE_LOG}" + if env -u SSH_AUTH_SOCK \ + PATH="${FAKE_BIN}:${PATH}" \ + CONTAINER_ENGINE_LOG="${ENGINE_LOG}" \ + FAKE_IMAGE_IDS="$(id -u):$(id -g)" \ + bash "${SCRIPT_PATH}" --engine docker --matlab-root \ + "${invalid_matlab_root_}" -- true >"${output_file_}" 2>&1; then + fail 'invalid MATLAB root unexpectedly succeeded' + fi + [[ ! -s "${ENGINE_LOG}" ]] || fail 'invalid MATLAB root invoked engine' + grep -Fq 'extern/include/mex.h' "${output_file_}" || { + sed -n '1,120p' "${output_file_}" >&2 + fail 'invalid MATLAB root produced no actionable diagnostic' + } + pass 'invalid MATLAB roots fail before container-engine access' +} + +create_fake_engine +test_docker_command_ownership +test_podman_command_ownership +test_vscode_attachment_contract +test_invalid_matlab_root_stops_before_engine + +printf '[SUMMARY] %d tests passed\n' "${PASS_COUNT}" diff --git a/tests/scripts/test_use_system_matlab_libraries.sh b/tests/scripts/test_use_system_matlab_libraries.sh new file mode 100755 index 0000000..5f4d83b --- /dev/null +++ b/tests/scripts/test_use_system_matlab_libraries.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Verify dry-run, privilege, backup, apply, restore, and discovery contracts +# against a disposable MATLAB-library fixture. + +set -Eeuo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +readonly REPO_ROOT +readonly SCRIPT_PATH="${REPO_ROOT}/scripts/use_system_matlab_libraries.sh" +TEST_ROOT="$(mktemp -d)" +readonly TEST_ROOT + +PASS_COUNT=0 + +cleanup() { + if [[ -n "${TEST_ROOT:-}" && -d "${TEST_ROOT}" ]]; then + rm -rf -- "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail() { + printf '[FAIL] %s\n' "$*" >&2 + exit 1 +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf '[PASS] %s\n' "$*" +} + +assert_contains() { + local haystack_="$1" + local needle_="$2" + local context_="$3" + + [[ "${haystack_}" == *"${needle_}"* ]] || + fail "${context_}: expected output to contain '${needle_}'" +} + +assert_link_text() { + local link_path_="$1" + local expected_text_="$2" + local actual_text_ + + [[ -L "${link_path_}" ]] || fail "Expected symlink: ${link_path_}" + actual_text_="$(readlink "${link_path_}")" + [[ "${actual_text_}" == "${expected_text_}" ]] || + fail "${link_path_}: expected '${expected_text_}', got '${actual_text_}'" +} + +run_expect_success() { + local output_file_="$1" + shift + + if ! "$@" >"${output_file_}" 2>&1; then + sed -n '1,240p' "${output_file_}" >&2 + fail "Command unexpectedly failed: $*" + fi +} + +run_expect_failure() { + local output_file_="$1" + shift + + if "$@" >"${output_file_}" 2>&1; then + sed -n '1,240p' "${output_file_}" >&2 + fail "Command unexpectedly succeeded: $*" + fi +} + +create_executable_() { + local path_="$1" + shift + + printf '%s\n' "$@" >"${path_}" + chmod +x "${path_}" +} + +setup_fixture() { + FIXTURE_PREFIX="${TEST_ROOT}/MATLAB" + FIXTURE_MATLAB_ROOT="${FIXTURE_PREFIX}/R2024b" + FIXTURE_BIN="${TEST_ROOT}/fake-bin" + FIXTURE_SYSTEM_LIB="${TEST_ROOT}/system-lib" + + mkdir -p \ + "${FIXTURE_MATLAB_ROOT}/bin/glnxa64" \ + "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/orig" \ + "${FIXTURE_MATLAB_ROOT}/toolbox/compiler_sdk/runtime/glnxa64" \ + "${FIXTURE_BIN}" \ + "${FIXTURE_SYSTEM_LIB}" + + create_executable_ "${FIXTURE_MATLAB_ROOT}/bin/matlab" \ + '#!/usr/bin/env bash' \ + 'exit 0' + ln -s "${FIXTURE_MATLAB_ROOT}/bin/matlab" "${FIXTURE_BIN}/matlab" + + touch \ + "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/libstdc++.so.6.0.30" \ + "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_core.so.4.7.0" \ + "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_imgproc.so.4.7.0" \ + "${FIXTURE_SYSTEM_LIB}/libstdc++.so.6.0.35" \ + "${FIXTURE_SYSTEM_LIB}/libopencv_core.so.4.10.0" \ + "${FIXTURE_SYSTEM_LIB}/libopencv_imgproc.so.4.10.0" + + ln -s 'libstdc++.so.6.0.30' \ + "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/libstdc++.so.6" + ln -s 'libstdc++.so.6.0.30' \ + "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/orig/libstdc++.so.6" + ln -s 'libstdc++.so.6.0.30' \ + "${FIXTURE_MATLAB_ROOT}/toolbox/compiler_sdk/runtime/glnxa64/libstdc++.so.6" + ln -s 'libopencv_core.so.4.7.0' \ + "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_core.so.407" + ln -s 'libopencv_imgproc.so.4.7.0' \ + "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_imgproc.so.407" + + # The single-quoted strings are the literal source of the generated shim. + # shellcheck disable=SC2016 + create_executable_ "${FIXTURE_BIN}/id" \ + '#!/usr/bin/env bash' \ + 'if [[ "${1:-}" == "-u" ]]; then' \ + ' printf "%s\\n" "${FAKE_ID_UID:-1000}"' \ + 'else' \ + ' /usr/bin/id "$@"' \ + 'fi' + + # shellcheck disable=SC2016 + create_executable_ "${FIXTURE_BIN}/ldconfig" \ + '#!/usr/bin/env bash' \ + 'printf "3 libs found in cache\\n"' \ + 'printf "\\tlibstdc++.so.6 (libc6,x86-64) => %s/libstdc++.so.6.0.35\\n" "${FAKE_SYSTEM_LIB}"' \ + 'printf "\\tlibopencv_core.so (libc6,x86-64) => %s/libopencv_core.so.4.10.0\\n" "${FAKE_SYSTEM_LIB}"' \ + 'printf "\\tlibopencv_imgproc.so (libc6,x86-64) => %s/libopencv_imgproc.so.4.10.0\\n" "${FAKE_SYSTEM_LIB}"' + + # shellcheck disable=SC2016 + create_executable_ "${FIXTURE_BIN}/file" \ + '#!/usr/bin/env bash' \ + 'printf "%s: ELF 64-bit LSB shared object, x86-64\\n" "${@: -1}"' + + # shellcheck disable=SC2016 + create_executable_ "${FIXTURE_BIN}/readelf" \ + '#!/usr/bin/env bash' \ + 'case "${@: -1}" in' \ + ' *libstdc++*) soname_="libstdc++.so.6" ;;' \ + ' *libopencv_core*) soname_="libopencv_core.so.410" ;;' \ + ' *libopencv_imgproc*) soname_="libopencv_imgproc.so.410" ;;' \ + ' *) exit 1 ;;' \ + 'esac' \ + 'printf " 0x000000000000000e (SONAME) Library soname: [%s]\\n" "${soname_}"' +} + +main() { + local output_file_="${TEST_ROOT}/command-output.txt" + local output_ + + setup_fixture + + run_expect_failure "${output_file_}" bash "${SCRIPT_PATH}" --matlab-root "${FIXTURE_MATLAB_ROOT}" + output_="$(<"${output_file_}")" + assert_contains "${output_}" 'Select at least one library family' 'selector guard' + pass 'requires an explicit library selector' + + run_expect_success "${output_file_}" env \ + PATH="${FIXTURE_BIN}:/usr/bin:/bin" \ + FAKE_SYSTEM_LIB="${FIXTURE_SYSTEM_LIB}" \ + bash "${SCRIPT_PATH}" --matlab-version R2024b \ + --matlab-prefix "${FIXTURE_PREFIX}" --all + output_="$(<"${output_file_}")" + assert_contains "${output_}" '[DRY-RUN]' 'dry-run mode' + assert_contains "${output_}" 'OpenCV SONAME change: 407 -> 410' 'OpenCV mismatch warning' + assert_contains "${output_}" '[CMD] ldconfig -p' 'command logging' + assert_contains "${output_}" '[EXIT] 0' 'command exit logging' + assert_link_text "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/libstdc++.so.6" 'libstdc++.so.6.0.30' + assert_link_text "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_core.so.407" 'libopencv_core.so.4.7.0' + pass 'dry-run plans all selected replacements without mutation' + + run_expect_failure "${output_file_}" env \ + PATH="${FIXTURE_BIN}:/usr/bin:/bin" \ + FAKE_SYSTEM_LIB="${FIXTURE_SYSTEM_LIB}" \ + FAKE_ID_UID=1000 \ + bash "${SCRIPT_PATH}" --matlab-root "${FIXTURE_MATLAB_ROOT}" --all --apply + output_="$(<"${output_file_}")" + assert_contains "${output_}" 'sudo' 'root guard' + assert_link_text "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/libstdc++.so.6" 'libstdc++.so.6.0.30' + pass 'apply refuses to mutate without root' + + run_expect_success "${output_file_}" env \ + PATH="${FIXTURE_BIN}:/usr/bin:/bin" \ + FAKE_SYSTEM_LIB="${FIXTURE_SYSTEM_LIB}" \ + FAKE_ID_UID=0 \ + bash "${SCRIPT_PATH}" --matlab-root "${FIXTURE_MATLAB_ROOT}" --all --apply + assert_link_text "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/libstdc++.so.6" \ + "${FIXTURE_SYSTEM_LIB}/libstdc++.so.6.0.35" + assert_link_text "${FIXTURE_MATLAB_ROOT}/toolbox/compiler_sdk/runtime/glnxa64/libstdc++.so.6" \ + "${FIXTURE_SYSTEM_LIB}/libstdc++.so.6.0.35" + assert_link_text "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/orig/libstdc++.so.6" \ + 'libstdc++.so.6.0.30' + assert_link_text "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_core.so.407" \ + "${FIXTURE_SYSTEM_LIB}/libopencv_core.so.4.10.0" + assert_link_text "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/libstdc++.so.6.matlab-backup" \ + 'libstdc++.so.6.0.30' + assert_link_text "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_core.so.407.matlab-backup" \ + 'libopencv_core.so.4.7.0' + pass 'apply replaces selected links and preserves one-time backups' + + run_expect_success "${output_file_}" env \ + PATH="${FIXTURE_BIN}:/usr/bin:/bin" \ + FAKE_SYSTEM_LIB="${FIXTURE_SYSTEM_LIB}" \ + FAKE_ID_UID=0 \ + bash "${SCRIPT_PATH}" --matlab-root "${FIXTURE_MATLAB_ROOT}" --all --apply + output_="$(<"${output_file_}")" + assert_contains "${output_}" '[UNCHANGED]' 'idempotent apply' + pass 'repeated apply is idempotent' + + run_expect_success "${output_file_}" env \ + PATH="${FIXTURE_BIN}:/usr/bin:/bin" \ + FAKE_SYSTEM_LIB="${FIXTURE_SYSTEM_LIB}" \ + FAKE_ID_UID=0 \ + bash "${SCRIPT_PATH}" --matlab-root "${FIXTURE_MATLAB_ROOT}" --all --restore + assert_link_text "${FIXTURE_MATLAB_ROOT}/sys/os/glnxa64/libstdc++.so.6" 'libstdc++.so.6.0.30' + assert_link_text "${FIXTURE_MATLAB_ROOT}/toolbox/compiler_sdk/runtime/glnxa64/libstdc++.so.6" \ + 'libstdc++.so.6.0.30' + assert_link_text "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_core.so.407" \ + 'libopencv_core.so.4.7.0' + [[ -L "${FIXTURE_MATLAB_ROOT}/bin/glnxa64/libopencv_core.so.407.matlab-backup" ]] || + fail 'Restore removed the recovery backup' + pass 'restore reinstates exact original link text and retains backups' + + run_expect_success "${output_file_}" env \ + PATH="${FIXTURE_BIN}:/usr/bin:/bin" \ + FAKE_SYSTEM_LIB="${FIXTURE_SYSTEM_LIB}" \ + bash "${SCRIPT_PATH}" --libstdcxx + output_="$(<"${output_file_}")" + assert_contains "${output_}" "MATLAB root: ${FIXTURE_MATLAB_ROOT}" 'PATH autodetection' + pass 'autodetects MATLAB from PATH' + + printf '[SUMMARY] %d tests passed\n' "${PASS_COUNT}" +} + +main "$@" diff --git a/tests/scripts/test_wrapper_maintenance.sh b/tests/scripts/test_wrapper_maintenance.sh new file mode 100644 index 0000000..3fafb60 --- /dev/null +++ b/tests/scripts/test_wrapper_maintenance.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Verify that wrapper checkout maintenance is explicit and CMake-owned. + +set -Eeuo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +readonly REPO_ROOT +REAL_CMAKE="$(command -v cmake)" +readonly REAL_CMAKE +TEST_ROOT="$(mktemp -d)" +readonly TEST_ROOT +PASS_COUNT=0 + +cleanup() { + if [[ -d "${TEST_ROOT}" ]]; then + rm -rf -- "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail() { + printf '[FAIL] %s\n' "$*" >&2 + exit 1 +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf '[PASS] %s\n' "$*" +} + +assert_not_contains() { + local file_path_="$1" + local unexpected_text_="$2" + local context_="$3" + + if grep -Fq -- "${unexpected_text_}" "${file_path_}"; then + sed -n '1,240p' "${file_path_}" >&2 + fail "${context_}: found '${unexpected_text_}'" + fi +} + +assert_contains_once() { + local file_path_="$1" + local expected_text_="$2" + local context_="$3" + local match_count_ + + match_count_="$(grep -Fxc -- "${expected_text_}" "${file_path_}" || true)" + [[ "${match_count_}" == "1" ]] || { + sed -n '1,240p' "${file_path_}" >&2 + fail "${context_}: expected one '${expected_text_}', found ${match_count_}" + } +} + +create_fixture() { + FIXTURE_PROJECT="${TEST_ROOT}/project" + FIXTURE_WRAP="${TEST_ROOT}/wrap" + FAKE_BIN="${TEST_ROOT}/fake-bin" + readonly FIXTURE_PROJECT FIXTURE_WRAP FAKE_BIN + + mkdir -p \ + "${FIXTURE_PROJECT}/src" \ + "${FIXTURE_WRAP}/cmake" \ + "${FIXTURE_WRAP}/.git" \ + "${FAKE_BIN}" + cp "${REPO_ROOT}/build_lib.sh" "${FIXTURE_PROJECT}/build_lib.sh" + touch "${FIXTURE_PROJECT}/src/wrap_interface.i" + touch "${FIXTURE_WRAP}/cmake/PybindWrap.cmake" + + printf '%s\n' \ + 'cmake_minimum_required(VERSION 3.15)' \ + 'set(project_name "maintenance_fixture")' \ + 'project(maintenance_fixture LANGUAGES NONE)' \ + >"${FIXTURE_PROJECT}/CMakeLists.txt" + + cat >"${FAKE_BIN}/cmake" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ "${1:-}" == "--version" ]]; then + printf 'cmake version 3.28.3\n' + exit 0 +fi +if [[ "${1:-}" == "--build" ]]; then + exit 0 +fi + +printf '%s\n' "$@" >>"${WRAPPER_CMAKE_LOG}" +EOF + chmod +x "${FAKE_BIN}/cmake" + + cat >"${FAKE_BIN}/git" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +printf '%s\n' "$*" >>"${WRAPPER_GIT_LOG}" +exit 0 +EOF + chmod +x "${FAKE_BIN}/git" +} + +run_build_helper() { + local build_name_="$1" + shift + + : >"${WRAPPER_CMAKE_LOG}" + : >"${WRAPPER_GIT_LOG}" + PATH="${FAKE_BIN}:${PATH}" \ + WRAPPER_CMAKE_LOG="${WRAPPER_CMAKE_LOG}" \ + WRAPPER_GIT_LOG="${WRAPPER_GIT_LOG}" \ + bash "${FIXTURE_PROJECT}/build_lib.sh" \ + -B "${build_name_}" \ + -p \ + --gtwrap-root "${FIXTURE_WRAP}" \ + --skip-tests \ + "$@" \ + >/dev/null +} + +test_default_is_non_mutating() { + run_build_helper build_default + + [[ ! -s "${WRAPPER_GIT_LOG}" ]] || fail "default build invoked Git" + assert_not_contains "${WRAPPER_CMAKE_LOG}" \ + '-DGTWRAP_MAINTENANCE_UPDATE=' 'default maintenance grant' + assert_not_contains "${WRAPPER_CMAKE_LOG}" \ + '-DGTWRAP_SYNC_TO_MASTER=' 'default synchronization request' + assert_not_contains "${WRAPPER_CMAKE_LOG}" \ + '-DGTWRAP_INIT_SUBMODULE_IF_MISSING=' 'default submodule request' + assert_not_contains "${WRAPPER_CMAKE_LOG}" \ + '-DGTWRAP_ADD_SUBMODULE_IF_MISSING=' 'removed submodule-add request' + pass 'default wrapper build passes no maintenance policy' +} + +test_update_is_delegated_to_cmake() { + run_build_helper build_update --wrap-update + + [[ ! -s "${WRAPPER_GIT_LOG}" ]] || fail "--wrap-update invoked Git directly" + assert_contains_once "${WRAPPER_CMAKE_LOG}" \ + '-DGTWRAP_MAINTENANCE_UPDATE=ON' 'maintenance grant' + assert_contains_once "${WRAPPER_CMAKE_LOG}" \ + '-DGTWRAP_SYNC_TO_MASTER=ON' 'synchronization request' + assert_contains_once "${WRAPPER_CMAKE_LOG}" \ + '-DGTWRAP_BRANCH=master' 'maintenance branch' + pass '--wrap-update delegates one explicit maintenance request to CMake' +} + +test_declared_submodule_only() { + local cmake_source_="${TEST_ROOT}/submodule-fixture" + local cmake_build_="${TEST_ROOT}/submodule-build" + + mkdir -p "${cmake_source_}/project" + touch "${cmake_source_}/project/.gitmodules" + : >"${WRAPPER_GIT_LOG}" + cat >"${cmake_source_}/CMakeLists.txt" </dev/null + + [[ ! -s "${WRAPPER_GIT_LOG}" ]] || { + sed -n '1,240p' "${WRAPPER_GIT_LOG}" >&2 + fail 'undeclared wrapper submodule invoked Git' + } + pass 'wrapper initialization ignores undeclared submodules' +} + +create_fixture +WRAPPER_CMAKE_LOG="${TEST_ROOT}/cmake.log" +WRAPPER_GIT_LOG="${TEST_ROOT}/git.log" +export WRAPPER_CMAKE_LOG WRAPPER_GIT_LOG + +test_default_is_non_mutating +test_update_is_delegated_to_cmake +test_declared_submodule_only + +printf '[SUMMARY] %d tests passed\n' "${PASS_COUNT}" From 41d341ab949703dfdd63e5a6c243a117a2221666 Mon Sep 17 00:00:00 2001 From: PeterC Date: Tue, 11 Aug 2026 14:12:23 +0200 Subject: [PATCH 2/2] [BUGFIX] Convert SemVer prereleases for Python packages - Derive PEP 440 package metadata from structured SemVer fields without changing release or CPack versions. - Preserve arbitrary prerelease labels through development-release local metadata in checked-in and fallback templates. - Cover canonical labels and real wheel metadata while documenting the version boundary. --- cmake/HandlePythonWrapper.cmake | 82 ++++++++++++++++++- doc/versioning.md | 14 ++-- python/pyproject.toml.in | 2 +- ...VerifyTemplateProjectPythonPackaging.cmake | 38 ++++++++- 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/cmake/HandlePythonWrapper.cmake b/cmake/HandlePythonWrapper.cmake index e1328b3..4ef0729 100644 --- a/cmake/HandlePythonWrapper.cmake +++ b/cmake/HandlePythonWrapper.cmake @@ -73,6 +73,81 @@ function(_resolve_python_install_root OUT_VAR) PARENT_SCOPE) endfunction() +# Normalize one SemVer identifier sequence for a PEP 440 local-version label. +function(_normalize_python_local_version_label OUT_VAR INPUT_VALUE) + string(TOLOWER "${INPUT_VALUE}" _python_local_label) + string(REGEX REPLACE "[^0-9a-z]+" "." + _python_local_label "${_python_local_label}") + string(REGEX REPLACE "^\\.+" "" + _python_local_label "${_python_local_label}") + string(REGEX REPLACE "\\.+$" "" + _python_local_label "${_python_local_label}") + if("${_python_local_label}" STREQUAL "") + message(FATAL_ERROR + "Cannot convert '${INPUT_VALUE}' to a PEP 440 local-version label.") + endif() + set("${OUT_VAR}" "${_python_local_label}" PARENT_SCOPE) +endfunction() + +# Project structured SemVer fields into PEP 440 without changing FULL_VERSION. +function(_compose_python_package_version + OUT_VAR VERSION_CORE VERSION_PRERELEASE VERSION_METADATA) + if(NOT "${VERSION_CORE}" MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$") + message(FATAL_ERROR + "Python package version requires a numeric SemVer core, got " + "'${VERSION_CORE}'.") + endif() + + set(_python_public_version "${VERSION_CORE}") + set(_python_local_parts) + if(NOT "${VERSION_PRERELEASE}" STREQUAL "") + string(TOLOWER "${VERSION_PRERELEASE}" _python_prerelease) + if(_python_prerelease MATCHES + "^(alpha|a|beta|b|preview|pre|rc|c|dev)([.-]?([0-9]+))?$") + set(_python_prerelease_label "${CMAKE_MATCH_1}") + set(_python_prerelease_number "${CMAKE_MATCH_3}") + if("${_python_prerelease_number}" STREQUAL "") + set(_python_prerelease_number "0") + endif() + + if(_python_prerelease_label MATCHES "^(alpha|a)$") + set(_python_prerelease_label "a") + elseif(_python_prerelease_label MATCHES "^(beta|b)$") + set(_python_prerelease_label "b") + elseif(NOT _python_prerelease_label STREQUAL "dev") + set(_python_prerelease_label "rc") + endif() + + if(_python_prerelease_label STREQUAL "dev") + string(APPEND _python_public_version + ".dev${_python_prerelease_number}") + else() + string(APPEND _python_public_version + "${_python_prerelease_label}${_python_prerelease_number}") + endif() + else() + # PEP 440 has no arbitrary prerelease label. Preserve its pre-release + # ordering as dev0 and retain the SemVer identifier as local metadata. + string(APPEND _python_public_version ".dev0") + _normalize_python_local_version_label( + _python_prerelease_local "${_python_prerelease}") + list(APPEND _python_local_parts "${_python_prerelease_local}") + endif() + endif() + + if(NOT "${VERSION_METADATA}" STREQUAL "") + _normalize_python_local_version_label( + _python_metadata_local "${VERSION_METADATA}") + list(APPEND _python_local_parts "${_python_metadata_local}") + endif() + if(_python_local_parts) + string(JOIN "." _python_local_version ${_python_local_parts}) + string(APPEND _python_public_version "+${_python_local_version}") + endif() + + set("${OUT_VAR}" "${_python_public_version}" PARENT_SCOPE) +endfunction() + # Reconstruct a build-owned Python package from stable checkout inputs. function(_stage_python_package_sources SOURCE_DIRECTORY STAGING_DIRECTORY) file(REMOVE_RECURSE "${STAGING_DIRECTORY}") @@ -394,6 +469,11 @@ else: # Materialize build metadata beside the staged package so pip and CMake use # one complete, disposable packaging root. + _compose_python_package_version( + PYTHON_PACKAGE_VERSION + "${PROJECT_VERSION_CORE}" + "${PROJECT_VERSION_PRERELEASE}" + "${PROJECT_VERSION_METADATA}") set(_pyproject_template "${PROJECT_PYTHON_SOURCE_DIR}/pyproject.toml.in") if(NOT EXISTS "${_pyproject_template}") @@ -409,7 +489,7 @@ build-backend = "setuptools.build_meta" [project] name = "@PROJECT_NAME@" -version = "@FULL_VERSION@" +version = "@PYTHON_PACKAGE_VERSION@" description = "Python bindings for @PROJECT_NAME@" requires-python = ">=@PROJECT_PYTHON_VERSION@" diff --git a/doc/versioning.md b/doc/versioning.md index 9c42a9d..160649f 100644 --- a/doc/versioning.md +++ b/doc/versioning.md @@ -60,12 +60,14 @@ The header also exposes numeric macros such as `PROJECT_VERSION_MAJOR`. ## Python and Packages -`python/pyproject.toml.in` receives `@FULL_VERSION@`, and CPack package -filenames use the same value. Python build backends normalize the semantic -version to its equivalent PEP 440 representation when required; for example, -`1.2.3-rc.1+4.gabc1234` becomes `1.2.3rc1+4.gabc1234` in wheel metadata. Keep -public release tags, package uploads, and generated docs aligned by building -release artifacts from an exact `vMAJOR.MINOR.PATCH[-PRERELEASE]` tag. +CPack package filenames retain `FULL_VERSION` as SemVer. Python metadata uses a +separate PEP 440 projection: recognized alpha, beta, release-candidate, and +development labels become their canonical PEP 440 forms. An arbitrary SemVer +prerelease such as `1.2.3-feature.x+4.gabc1234` becomes +`1.2.3.dev0+feature.x.4.gabc1234`, preserving prerelease ordering and its label. +Keep public release tags, package uploads, and generated docs aligned by +building release artifacts from an exact +`vMAJOR.MINOR.PATCH[-PRERELEASE]` tag. ## Release tagging with the ROS 2 overlay diff --git a/python/pyproject.toml.in b/python/pyproject.toml.in index 9fb4a5e..7242cf5 100644 --- a/python/pyproject.toml.in +++ b/python/pyproject.toml.in @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "@PROJECT_NAME@" -version = "@FULL_VERSION@" +version = "@PYTHON_PACKAGE_VERSION@" description = "Python bindings for @PROJECT_NAME@." requires-python = ">=3.12" license = {text = "MIT"} diff --git a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake index a064824..a2ace8f 100644 --- a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake +++ b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake @@ -63,7 +63,7 @@ set(_generator_output_build "${TEST_BINARY_ROOT}/generator_output_build") set(_wheel_output "${TEST_BINARY_ROOT}/wheel_output") set(_wheel_install "${TEST_BINARY_ROOT}/wheel_install") set(_cmake_install "${TEST_BINARY_ROOT}/cmake_install") -set(_expected_wheel_version "1.0.0rc1+5.gabc1234") +set(_expected_wheel_version "1.0.0.dev0+feature.x.5.gabc1234") file(MAKE_DIRECTORY "${_fixture_source}/python/fixture_package" "${_wheel_output}") @@ -320,7 +320,41 @@ configure_python_runtime_artifacts( set(PROJECT_NAME fixture_package) set(PROJECT_VERSION 1.0.0) -set(FULL_VERSION "1.0.0-rc.1+5.gabc1234") +set(PROJECT_VERSION_CORE "1.0.0") +set(PROJECT_VERSION_PRERELEASE "feature.x") +set(PROJECT_VERSION_METADATA "5.gabc1234") +set(FULL_VERSION "1.0.0-feature.x+5.gabc1234") + +# Keep recognized SemVer labels canonical while mapping arbitrary labels to a +# PEP 440 development release that retains the source label as local metadata. +function(_assert_python_package_version PRERELEASE EXPECTED_VERSION) + _compose_python_package_version( + _actual_version + "${PROJECT_VERSION_CORE}" + "${PRERELEASE}" + "${PROJECT_VERSION_METADATA}") + if(NOT _actual_version STREQUAL EXPECTED_VERSION) + message(FATAL_ERROR + "Unexpected package version for '${PRERELEASE}': " + "${_actual_version}; expected ${EXPECTED_VERSION}") + endif() +endfunction() + +_assert_python_package_version("alpha.1" "1.0.0a1+5.gabc1234") +_assert_python_package_version("beta.2" "1.0.0b2+5.gabc1234") +_assert_python_package_version("rc.3" "1.0.0rc3+5.gabc1234") +_assert_python_package_version("dev.4" "1.0.0.dev4+5.gabc1234") + +_compose_python_package_version( + PYTHON_PACKAGE_VERSION + "${PROJECT_VERSION_CORE}" + "${PROJECT_VERSION_PRERELEASE}" + "${PROJECT_VERSION_METADATA}") +if(NOT PYTHON_PACKAGE_VERSION STREQUAL + "@_expected_wheel_version@") + message(FATAL_ERROR + "Unexpected PEP 440 package version: ${PYTHON_PACKAGE_VERSION}") +endif() configure_file( "@TEST_TEMPLATE_SOURCE_DIR@/python/pyproject.toml.in" "${_package_build_root}/pyproject.toml"