From 7b0429c7c54797b4acca4c1402b77469c42a216f Mon Sep 17 00:00:00 2001 From: PeterC Date: Mon, 27 Jul 2026 11:03:25 +0200 Subject: [PATCH 1/9] Constrain cleanup to owned build trees (build_lib.sh) --- README.md | 6 +- build_lib.sh | 57 +++++- doc/build_script_doc.md | 9 +- tailor_template_cleanup.sh | 1 + tests/CMakeLists.txt | 14 ++ ...fyTemplateProjectBuildLibCleanSafety.cmake | 179 ++++++++++++++++++ ...VerifyTemplateProjectTailoringScript.cmake | 2 + 7 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake diff --git a/README.md b/README.md index eb4bd80..1ed9c5f 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ All options are passed via `build_lib.sh` flags or directly as `-D=` t -N, --ninja-build Use Ninja generator -f, --flagsCXX "" Extra compiler flags (e.g. "-march=native") -D, --define Extra CMake cache definitions (repeatable) - --clean Delete build dir before configure + --clean Safely delete an owned in-repository build before configure --profile Enable profiling build (see Profiling section) --skip-tests Do not run tests after build -i, --install Run install target after tests @@ -186,6 +186,10 @@ All options are passed via `build_lib.sh` flags or directly as `-D=` t See [`doc/build_script_doc.md`](doc/build_script_doc.md) for a detailed option reference. +`--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`. + ### CMake feature flags | Option | Default | Description | diff --git a/build_lib.sh b/build_lib.sh index cd93892..01e4173 100755 --- a/build_lib.sh +++ b/build_lib.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Build helper for CMake-based C++ projects (Linux) -# - Created Jan 2024; updated Aug 2025 +# - Created Jan 2024; updated Jul 2026 # - Uses GNU getopt for long options # - Generator-agnostic build via `cmake --build` @@ -221,6 +221,8 @@ Notes: For CMake defines, use "-DVAR=ON" or "-D VAR=ON". * Wrapper rebuilds with "-r -p" or "-r -m" only work if the existing build 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. * 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. @@ -236,6 +238,44 @@ 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. +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_}/"}" + if [[ "$relative_buildpath_" == "$buildpath" ]]; then + die "--clean requires a build directory inside '${project_root_}'" + fi + case "$relative_buildpath_" in + build|build/*|build[^/]*|out/*) ;; + *) + die "--clean requires a conventional build path (build, build*, or out/*)" + ;; + esac + + if [[ -e "$buildpath" ]]; then + build_cache_="${buildpath}/CMakeCache.txt" + [[ -f "$build_cache_" ]] || + die "Refusing to clean a directory without a CMake cache: $buildpath" + cached_source_dir_="$( + sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' "$build_cache_" | + tail -n 1 + )" + [[ -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_" ]] || + die "Refusing to clean a build owned by '$cached_source_dir_'" + fi +} + # --- argument parsing (GNU getopt) --- if ! command -v getopt > /dev/null 2>&1; then die "GNU getopt is required. On macOS: brew install gnu-getopt and adjust PATH." @@ -314,6 +354,10 @@ if [[ -n "$python_test_executable" && ! -x "$python_test_executable" ]]; then die "Python test executable is not executable: $python_test_executable" fi +if [[ "$clean_first" == true && "$rebuild_only" == false ]]; then + validate_clean_build_path +fi + project_name="$(detect_project_name || true)" prepare_wrap_checkout=false @@ -378,9 +422,14 @@ sleep 0.2 # --- Configure --- if [[ "$rebuild_only" == false ]]; then - if [[ "$clean_first" == true && -d "$buildpath" ]]; then - info "Removing existing build dir '$buildpath'" - rm -rf -- "$buildpath" + if [[ "$clean_first" == true ]]; then + # Revalidate at the destructive boundary in case the path or cache changed + # while wrapper prerequisites were being prepared. + validate_clean_build_path + if [[ -d "$buildpath" ]]; then + info "Removing existing build dir '$buildpath'" + rm -rf -- "$buildpath" + fi fi cmake_args=( diff --git a/doc/build_script_doc.md b/doc/build_script_doc.md index 0fb0ea4..39ef6e7 100644 --- a/doc/build_script_doc.md +++ b/doc/build_script_doc.md @@ -25,13 +25,20 @@ The script uses out-of-source builds, strict shell error handling, generator-ind | Option | Purpose | |---|---| | `-B, --buildpath ` | Build directory. Default: `./build`. | -| `--clean` | Remove the build directory before configure. Ignored with `--rebuild-only`. | +| `--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`. | | `-r, --rebuild-only` | Skip configure and build the existing cache. | 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. + ## Configure Options | Option | Purpose | diff --git a/tailor_template_cleanup.sh b/tailor_template_cleanup.sh index c542156..dfa1c0f 100755 --- a/tailor_template_cleanup.sh +++ b/tailor_template_cleanup.sh @@ -65,6 +65,7 @@ template_development_paths=( "tests/cmake/AddMatlabWrapperRegressionTests.cmake" "tests/cmake/CheckTcmallocDependency.cmake" "tests/cmake/VerifyTemplateProjectAddTestsProperties.cmake" + "tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake" "tests/cmake/VerifyTemplateProjectBuildTreePackage.cmake" "tests/cmake/VerifyTemplateProjectCrossCompile.cmake" "tests/cmake/VerifyTemplateProjectCudaSources.cmake" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 01e59f5..aba1eef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -109,6 +109,20 @@ set_tests_properties( TIMEOUT 180 ) +add_test( + NAME template_project_build_lib_clean_safety + COMMAND ${CMAKE_COMMAND} + -DTEST_TEMPLATE_SOURCE_DIR=${PROJECT_SOURCE_DIR} + -DTEST_BINARY_ROOT=${PROJECT_BINARY_DIR}/build_lib_clean_safety + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake +) +set_tests_properties( + template_project_build_lib_clean_safety + PROPERTIES + LABELS "build;clean;safety" + TIMEOUT 60 +) + add_test( NAME template_project_build_tree_package_root COMMAND ${CMAKE_COMMAND} diff --git a/tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake b/tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake new file mode 100644 index 0000000..a3bd0bf --- /dev/null +++ b/tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake @@ -0,0 +1,179 @@ +cmake_minimum_required(VERSION 3.15) + +# Exercise every build-helper deletion path inside a disposable miniature +# checkout; this verifier must never clean the template's own build tree. +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() + +set(_source_build_script "${TEST_TEMPLATE_SOURCE_DIR}/build_lib.sh") +if(NOT EXISTS "${_source_build_script}") + message(FATAL_ERROR "Build helper not found: ${_source_build_script}") +endif() + +function(_run_process 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(_expect_clean_rejection case_name build_dir expected_message) + execute_process( + COMMAND + bash "${_fixture_build_script}" + -B "${build_dir}" + --clean + --skip-tests + WORKING_DIRECTORY "${_fixture_source}" + RESULT_VARIABLE _result + OUTPUT_VARIABLE _stdout + ERROR_VARIABLE _stderr) + + if(_result EQUAL 0) + message(FATAL_ERROR + "Unsafe clean case '${case_name}' unexpectedly succeeded.") + endif() + + set(_combined_output "${_stdout}\n${_stderr}") + string(FIND "${_combined_output}" "${expected_message}" _message_index) + if(_message_index EQUAL -1) + message(FATAL_ERROR + "Unsafe clean case '${case_name}' did not report the expected error.\n" + "stdout:\n${_stdout}\n" + "stderr:\n${_stderr}") + endif() +endfunction() + +# Isolate every destructive case inside a disposable miniature checkout so an +# externally configured template build exercises the same safety paths as an +# in-repository build. +file(REMOVE_RECURSE "${TEST_BINARY_ROOT}") +set(_fixture_source "${TEST_BINARY_ROOT}/fixture_source") +set(_foreign_source "${TEST_BINARY_ROOT}/foreign_source") +set(_outside_build "${TEST_BINARY_ROOT}/outside_build") +set(_external_rebuild "${TEST_BINARY_ROOT}/external_rebuild") +file(MAKE_DIRECTORY + "${_fixture_source}/src" + "${_fixture_source}/build_without_cache" + "${_fixture_source}/build_missing_marker" + "${_foreign_source}" + "${_outside_build}") + +configure_file( + "${_source_build_script}" + "${_fixture_source}/build_lib.sh" + COPYONLY) +set(_fixture_build_script "${_fixture_source}/build_lib.sh") +file(WRITE "${_fixture_source}/CMakeLists.txt" +"cmake_minimum_required(VERSION 3.15) +project(clean_safety_fixture LANGUAGES NONE) +") +file(WRITE "${_foreign_source}/CMakeLists.txt" +"cmake_minimum_required(VERSION 3.15) +project(foreign_clean_fixture LANGUAGES NONE) +") +file(WRITE + "${_fixture_source}/build_missing_marker/CMakeCache.txt" + "UNRELATED_CACHE_ENTRY:INTERNAL=value\n") + +# Reject every path or cache that cannot prove it is a conventional build +# owned by the disposable checkout. +_expect_clean_rejection( + outside_repository + "${_outside_build}" + "--clean requires a build directory inside") +_expect_clean_rejection( + source_subdirectory + "${_fixture_source}/src" + "--clean requires a conventional build path") +_expect_clean_rejection( + build_directory_without_cache + "${_fixture_source}/build_without_cache" + "Refusing to clean a directory without a CMake cache") +_expect_clean_rejection( + cache_without_source_marker + "${_fixture_source}/build_missing_marker" + "CMake source marker is missing") + +set(_foreign_build "${_fixture_source}/build_foreign") +_run_process( + "Configure foreign-owned build" + "${CMAKE_COMMAND}" + -S "${_foreign_source}" + -B "${_foreign_build}") +_expect_clean_rejection( + foreign_owned_cache + "${_foreign_build}" + "Refusing to clean a build owned by") + +# Accept an owned cache, remove its sentinel, and recreate a usable build tree. +set(_valid_build "${_fixture_source}/build_valid") +_run_process( + "Configure valid owned build" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_valid_build}") +file(WRITE "${_valid_build}/must_be_removed.txt" "stale build output\n") +execute_process( + COMMAND + bash "${_fixture_build_script}" + -B "${_valid_build}" + --clean + --skip-tests + WORKING_DIRECTORY "${_fixture_source}" + RESULT_VARIABLE _valid_clean_result + OUTPUT_VARIABLE _valid_clean_stdout + ERROR_VARIABLE _valid_clean_stderr) +if(NOT _valid_clean_result EQUAL 0) + message(FATAL_ERROR + "Clean and rebuild valid owned build failed with exit code " + "${_valid_clean_result}.\n" + "stdout:\n${_valid_clean_stdout}\n" + "stderr:\n${_valid_clean_stderr}") +endif() +if(EXISTS "${_valid_build}/must_be_removed.txt") + message(FATAL_ERROR "Owned clean did not remove the stale build sentinel.") +endif() +if(NOT EXISTS "${_valid_build}/CMakeCache.txt") + message(FATAL_ERROR "Owned clean did not recreate a usable CMake build.") +endif() + +# Rebuild-only must ignore --clean even for an external build and preserve all +# existing build-tree contents. +_run_process( + "Configure external rebuild-only tree" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_external_rebuild}") +file(WRITE "${_external_rebuild}/must_be_preserved.txt" "rebuild-only sentinel\n") +execute_process( + COMMAND + bash "${_fixture_build_script}" + -B "${_external_rebuild}" + --rebuild-only + --clean + --skip-tests + WORKING_DIRECTORY "${_fixture_source}" + RESULT_VARIABLE _rebuild_only_result + OUTPUT_VARIABLE _rebuild_only_stdout + ERROR_VARIABLE _rebuild_only_stderr) +if(NOT _rebuild_only_result EQUAL 0) + message(FATAL_ERROR + "Rebuild-only with ignored clean flag failed with exit code " + "${_rebuild_only_result}.\n" + "stdout:\n${_rebuild_only_stdout}\n" + "stderr:\n${_rebuild_only_stderr}") +endif() +if(NOT EXISTS "${_external_rebuild}/must_be_preserved.txt") + message(FATAL_ERROR "--rebuild-only unexpectedly removed the external build.") +endif() diff --git a/tests/cmake/VerifyTemplateProjectTailoringScript.cmake b/tests/cmake/VerifyTemplateProjectTailoringScript.cmake index 64da9b2..a9e7fe4 100644 --- a/tests/cmake/VerifyTemplateProjectTailoringScript.cmake +++ b/tests/cmake/VerifyTemplateProjectTailoringScript.cmake @@ -183,6 +183,7 @@ endif() "doc/reports/implementation_review.md" "tests/cmake/AddMatlabWrapperRegressionTests.cmake" "tests/cmake/CheckTcmallocDependency.cmake" + "tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake" "tests/cmake/VerifyTemplateProjectBuildTreePackage.cmake" "tests/cmake/VerifyTemplateProjectCudaSources.cmake" "tests/cmake/VerifyTemplateProjectDocsWorkflow.cmake" @@ -261,6 +262,7 @@ function(_assert_fake_project_cleaned fake_root expect_profiling) "AGENTS.md" "doc/developments" "doc/reports" + "tests/cmake/VerifyTemplateProjectBuildLibCleanSafety.cmake" "tests/cmake/VerifyTemplateProjectCudaSources.cmake" "tests/cmake/VerifyTemplateProjectDocsWorkflow.cmake" "tests/cmake/VerifyTemplateProjectOptixInstallExport.cmake" From ee09cd873d2ea6fe237043671842a286c4b6f0c5 Mon Sep 17 00:00:00 2001 From: PeterC Date: Mon, 27 Jul 2026 11:21:00 +0200 Subject: [PATCH 2/9] [BUGFIX] Exclude nested generated trees from source archives --- CMakeLists.txt | 9 ++++++--- tests/cmake/VerifySourceReleaseArchive.cmake | 10 ++++++++++ tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake | 5 +++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6b6786a..dbc4056 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -536,14 +536,17 @@ if(DEFINED FULL_VERSION AND NOT "${FULL_VERSION}" STREQUAL "") endif() set(CPACK_GENERATOR "TGZ") set(CPACK_SOURCE_GENERATOR "TGZ") +# Preserve source-ignore regexes verbatim and anchor recursive generated-tree +# exclusions beneath this checkout rather than matching adjacent directories. +set(CPACK_VERBATIM_VARIABLES YES) set(_cpack_source_root_regex "${CMAKE_CURRENT_SOURCE_DIR}") string( REGEX REPLACE "([][+.*^$()|?\\\\])" "\\\\\\1" _cpack_source_root_regex "${_cpack_source_root_regex}") set(CPACK_SOURCE_IGNORE_FILES - "^${_cpack_source_root_regex}/\\.git/" - "^${_cpack_source_root_regex}/build[^/]*/" - "^${_cpack_source_root_regex}/install/" + "^${_cpack_source_root_regex}/(.*/)?\\.git(/|$)" + "^${_cpack_source_root_regex}/(.*/)?build[^/]*/" + "^${_cpack_source_root_regex}/(.*/)?install/" "^${_cpack_source_root_regex}/ros2/(build|install|log)/" "^${_cpack_source_root_regex}/(.*/)?\\.pytest_cache/" "^${_cpack_source_root_regex}/(.*/)?__pycache__/" diff --git a/tests/cmake/VerifySourceReleaseArchive.cmake b/tests/cmake/VerifySourceReleaseArchive.cmake index 4f53437..ec68457 100644 --- a/tests/cmake/VerifySourceReleaseArchive.cmake +++ b/tests/cmake/VerifySourceReleaseArchive.cmake @@ -35,6 +35,16 @@ foreach(_root_build_entry IN LISTS _root_build_entries) message(FATAL_ERROR "Canonical source archive contains build tree: ${_root_build_entry}") endif() endforeach() +file(GLOB_RECURSE _archive_entries LIST_DIRECTORIES TRUE "${TEST_SOURCE_ROOT}/*") +foreach(_archive_entry IN LISTS _archive_entries) + if(IS_DIRECTORY "${_archive_entry}") + get_filename_component(_archive_entry_name "${_archive_entry}" NAME) + if(_archive_entry_name MATCHES "^build[^/]*$") + message(FATAL_ERROR + "Canonical source archive contains nested build tree: ${_archive_entry}") + endif() + endif() +endforeach() foreach(generated_path "ros2/build" "ros2/install" "ros2/log") if(EXISTS "${TEST_SOURCE_ROOT}/${generated_path}") message(FATAL_ERROR "Canonical source archive contains generated ROS output: ${generated_path}") diff --git a/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake b/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake index 6b2da5f..c5909a5 100644 --- a/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake +++ b/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake @@ -230,12 +230,16 @@ set(_archive_output "${TEST_BINARY_ROOT}/archive_output") set(_archive_extract "${TEST_BINARY_ROOT}/archive_extract") file(MAKE_DIRECTORY "${_scratch_root}/build_release_sentinel" + "${_scratch_root}/examples/build_release_sentinel" "${_scratch_root}/ros2/build/generated" "${_scratch_root}/ros2/install/generated" "${_scratch_root}/ros2/log/generated" "${_archive_output}" "${_archive_extract}") file(WRITE "${_scratch_root}/build_release_sentinel/must_not_ship.txt" "generated build output\n") +file(WRITE + "${_scratch_root}/examples/build_release_sentinel/must_not_ship.txt" + "generated nested build output\n") file(WRITE "${_scratch_root}/ros2/build/generated/must_not_ship.txt" "generated ROS build output\n") file(WRITE "${_scratch_root}/ros2/install/generated/must_not_ship.txt" "generated ROS install output\n") file(WRITE "${_scratch_root}/ros2/log/generated/must_not_ship.txt" "generated ROS log output\n") @@ -308,6 +312,7 @@ _run_failure( file(REMOVE_RECURSE "${_scratch_root}/build_release_sentinel" + "${_scratch_root}/examples/build_release_sentinel" "${_scratch_root}/ros2/build" "${_scratch_root}/ros2/install" "${_scratch_root}/ros2/log") From ff6af61b015eee0783926a323096a844a772330e Mon Sep 17 00:00:00 2001 From: PeterC Date: Mon, 27 Jul 2026 11:52:05 +0200 Subject: [PATCH 3/9] [MAJOR] Add relocatable runtime packaging for wrapper - Add a distinct option for direct runtime dependency targets - Automatically package the main shared library beside the Python extension - Stage and install exact target and SONAME artifacts without directory scanning - Use loader-relative runtime paths and prefix-relative CMake destinations - Produce deterministic wheels and reject stale runtime metadata - Add Windows DLL search handles and document the direct-target contract --- CMakeLists.txt | 11 ++ README.md | 6 + cmake/HandleWrapper.cmake | 250 ++++++++++++++++++++++++---- doc/wrappers.md | 9 + python/pyproject.toml.in | 2 +- python/setup.py.in | 115 ++++++++++++- python/template_project/__init__.py | 32 +++- 7 files changed, 384 insertions(+), 41 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index dbc4056..53d1c8e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -185,6 +185,8 @@ set(GTWRAP_MATLAB_OPTION_NAME "${LIB_NAMESPACE}_BUILD_MATLAB_WRAPPER") set(GTWRAP_INTERFACE_OPTION_NAME "${LIB_NAMESPACE}_WRAPPER_INTERFACE_FILES") set(GTWRAP_TOP_NAMESPACE_OPTION_NAME "${LIB_NAMESPACE}_GTWRAP_TOP_NAMESPACE") set(GTWRAP_DEPENDENCIES_OPTION_NAME "${LIB_NAMESPACE}_GTWRAP_DEPENDENCY_TARGETS") +set(GTWRAP_RUNTIME_DEPENDENCIES_OPTION_NAME + "${LIB_NAMESPACE}_GTWRAP_RUNTIME_DEPENDENCY_TARGETS") set(GTWRAP_ROOT_OPTION_NAME "${LIB_NAMESPACE}_GTWRAP_ROOT_DIR") option(${GTWRAP_PYTHON_OPTION_NAME} "Build Python wrapper" ${GTWRAP_BUILD_PYTHON_DEFAULT}) @@ -214,6 +216,14 @@ if (NOT DEFINED ${GTWRAP_DEPENDENCIES_OPTION_NAME}) FORCE) endif() +if (NOT DEFINED ${GTWRAP_RUNTIME_DEPENDENCIES_OPTION_NAME}) + set(${GTWRAP_RUNTIME_DEPENDENCIES_OPTION_NAME} + "" + CACHE STRING + "Direct project-owned shared runtime build targets packaged beside the Python wrapper." + FORCE) +endif() + if (NOT DEFINED ${GTWRAP_ROOT_OPTION_NAME}) set(${GTWRAP_ROOT_OPTION_NAME} "" @@ -505,6 +515,7 @@ if (ANY_WRAPPER_ENABLED) message(STATUS "gtwrap interface option name : ${GTWRAP_INTERFACE_OPTION_NAME}") message(STATUS "gtwrap interface files : ${${GTWRAP_INTERFACE_OPTION_NAME}}") message(STATUS "gtwrap top namespace : ${${GTWRAP_TOP_NAMESPACE_OPTION_NAME}}") + message(STATUS "gtwrap runtime dependency targets : ${${GTWRAP_RUNTIME_DEPENDENCIES_OPTION_NAME}}") message(STATUS "gtwrap root override : ${${GTWRAP_ROOT_OPTION_NAME}}") endif() message(STATUS "===============================================================") diff --git a/README.md b/README.md index 1ed9c5f..0172cc5 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,12 @@ into `python/pyproject.toml` when Python wrapping is requested. The optional `setup.py.in` augments installation behavior without duplicating package name/version metadata. +The wrapper wheel automatically co-locates the main project shared library. +Additional direct project-owned shared runtime build targets can be declared +through +`_GTWRAP_RUNTIME_DEPENDENCY_TARGETS`; the separate +`_GTWRAP_DEPENDENCY_TARGETS` option remains build-order-only. + The checked-in `python//__init__.py` is the public package entrypoint: - `import ` is the supported import path. diff --git a/cmake/HandleWrapper.cmake b/cmake/HandleWrapper.cmake index 67a9852..2d254c2 100644 --- a/cmake/HandleWrapper.cmake +++ b/cmake/HandleWrapper.cmake @@ -1,15 +1,31 @@ -# CMake configuration to handle Python and MATLAB wrapper configuration +# Configure Python and MATLAB wrapper discovery, generation, installation, and +# relocatable Python runtime packaging. Generated build-link metadata remains +# checkout-only; installed packages contain only declared native artifacts. include_guard(GLOBAL) include(ExternalProject) -# Function to set python target properties +# Configure a Python extension to load co-located runtime libraries without +# embedding checkout or install-prefix paths. function(set_python_target_properties PYTHON_TARGET OUTPUT_NAME OUTPUT_DIRECTORY) + if(APPLE) + set(_python_runtime_rpath "@loader_path") + elseif(UNIX) + set(_python_runtime_rpath "$ORIGIN") + else() + set(_python_runtime_rpath "") + endif() + + # The no-op generator expression suppresses automatic configuration + # subdirectories under multi-config generators, keeping one stable package + # directory for metadata, staged runtimes, and the extension. set_target_properties(${PYTHON_TARGET} PROPERTIES - INSTALL_RPATH "${CMAKE_INSTALL_PREFIX}/lib" - INSTALL_RPATH_USE_LINK_PATH TRUE + BUILD_WITH_INSTALL_RPATH TRUE + INSTALL_RPATH "${_python_runtime_rpath}" + INSTALL_RPATH_USE_LINK_PATH FALSE OUTPUT_NAME "${OUTPUT_NAME}" - LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_DIRECTORY}" + LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_DIRECTORY}$<0:>" + RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_DIRECTORY}$<0:>" DEBUG_POSTFIX "" RELWITHDEBINFO_POSTFIX "" TIMING_POSTFIX "" @@ -17,6 +33,147 @@ function(set_python_target_properties PYTHON_TARGET OUTPUT_NAME OUTPUT_DIRECTORY ) endfunction() +# Stage and install exact project-owned runtime targets beside a Python +# extension. The returned string contains formatted Python list entries for +# checkout-only wrapper metadata generated by the caller. +function(configure_python_runtime_artifacts + PYTHON_TARGET + STAGING_DIRECTORY + INSTALL_DESTINATION + OUT_METADATA_ENTRIES) + if(NOT TARGET "${PYTHON_TARGET}") + message(FATAL_ERROR + "Python wrapper target '${PYTHON_TARGET}' does not exist.") + endif() + + if(APPLE) + set(_python_dependency_rpath "@loader_path") + elseif(UNIX) + set(_python_dependency_rpath "$ORIGIN") + else() + set(_python_dependency_rpath "") + endif() + + set(_python_runtime_targets ${ARGN}) + list(REMOVE_DUPLICATES _python_runtime_targets) + set(_python_runtime_metadata_entries "") + + foreach(_python_runtime_target IN LISTS _python_runtime_targets) + if(NOT TARGET "${_python_runtime_target}") + message(FATAL_ERROR + "Python runtime dependency target '${_python_runtime_target}' does not exist.") + endif() + + get_target_property( + _python_runtime_target_imported + "${_python_runtime_target}" + IMPORTED) + if(_python_runtime_target_imported) + message(FATAL_ERROR + "Python runtime dependency target '${_python_runtime_target}' is imported. " + "Only project-owned targets can be packaged.") + endif() + + get_target_property( + _python_runtime_target_type + "${_python_runtime_target}" + TYPE) + if(NOT _python_runtime_target_type STREQUAL "SHARED_LIBRARY" + AND NOT _python_runtime_target_type STREQUAL "MODULE_LIBRARY") + message(FATAL_ERROR + "Python runtime dependency target '${_python_runtime_target}' has " + "unsupported type '${_python_runtime_target_type}'. " + "Expected SHARED_LIBRARY or MODULE_LIBRARY.") + endif() + + # Give each packaged library the same loader-relative dependency contract + # as the extension that loads it. + set_target_properties( + "${_python_runtime_target}" + PROPERTIES + BUILD_WITH_INSTALL_RPATH TRUE + INSTALL_RPATH "${_python_dependency_rpath}" + INSTALL_RPATH_USE_LINK_PATH FALSE) + + # Stage the complete runtime file under its target filename. A versioned + # library also needs a file under its SONAME because that is what dependents + # request from the dynamic loader. + set(_python_staged_target_file + "${STAGING_DIRECTORY}/$") + set( + _python_runtime_stage_commands + COMMAND + "${CMAKE_COMMAND}" -E make_directory + "${STAGING_DIRECTORY}" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different + "$" + "${_python_staged_target_file}") + string(APPEND + _python_runtime_metadata_entries + " r\"${_python_staged_target_file}\",\n") + + get_target_property( + _python_runtime_soversion + "${_python_runtime_target}" + SOVERSION) + get_target_property( + _python_runtime_no_soname + "${_python_runtime_target}" + NO_SONAME) + if(UNIX + AND NOT _python_runtime_soversion STREQUAL "_python_runtime_soversion-NOTFOUND" + AND NOT _python_runtime_no_soname) + set(_python_staged_soname_file + "${STAGING_DIRECTORY}/$") + list( + APPEND + _python_runtime_stage_commands + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different + "$" + "${_python_staged_soname_file}") + string(APPEND + _python_runtime_metadata_entries + " r\"${_python_staged_soname_file}\",\n") + endif() + + # An always-run staging target refreshes copies when an explicitly + # packaged runtime changes without forcing the wrapper itself to relink. + string( + MAKE_C_IDENTIFIER + "${PYTHON_TARGET}_${_python_runtime_target}_python_runtime_stage" + _python_runtime_stage_target) + if(TARGET "${_python_runtime_stage_target}") + message(FATAL_ERROR + "Python runtime staging target '${_python_runtime_stage_target}' " + "already exists.") + endif() + add_custom_target( + "${_python_runtime_stage_target}" + ${_python_runtime_stage_commands} + COMMENT + "Staging Python runtime target ${_python_runtime_target}" + VERBATIM) + add_dependencies( + "${_python_runtime_stage_target}" + "${_python_runtime_target}") + add_dependencies("${PYTHON_TARGET}" "${_python_runtime_stage_target}") + + # Reuse CMake's target-aware install logic so versioned runtime files and + # platform-specific DLL artifacts retain their expected names. + install( + TARGETS "${_python_runtime_target}" + LIBRARY DESTINATION "${INSTALL_DESTINATION}" NAMELINK_SKIP + RUNTIME DESTINATION "${INSTALL_DESTINATION}") + endforeach() + + set( + "${OUT_METADATA_ENTRIES}" + "${_python_runtime_metadata_entries}" + PARENT_SCOPE) +endfunction() + # Function to check validity of interface files list function(check_interface_files_validity VALIDITY_BOOL) set(_interface_files ${ARGN}) @@ -252,6 +409,8 @@ function(configure_gtwrappers_common) set(_gtwrap_interface_var_name "${LIB_NAMESPACE}_WRAPPER_INTERFACE_FILES") set(_gtwrap_top_namespace_var_name "${LIB_NAMESPACE}_GTWRAP_TOP_NAMESPACE") set(_gtwrap_extra_deps_var_name "${LIB_NAMESPACE}_GTWRAP_DEPENDENCY_TARGETS") + set(_gtwrap_runtime_deps_var_name + "${LIB_NAMESPACE}_GTWRAP_RUNTIME_DEPENDENCY_TARGETS") set(_gtwrap_root_var_name "${LIB_NAMESPACE}_GTWRAP_ROOT_DIR") set(_gtwrap_autodiscover_option_name "${LIB_NAMESPACE}_WRAPPER_AUTODISCOVER_INTERFACE_FILES") @@ -328,6 +487,12 @@ function(configure_gtwrappers_common) FORCE) endif() + if(NOT DEFINED ${_gtwrap_runtime_deps_var_name}) + set(${_gtwrap_runtime_deps_var_name} "" CACHE STRING + "Direct project-owned shared runtime build targets packaged beside the Python wrapper." + FORCE) + endif() + set(_gtwrap_interface_files ${${_gtwrap_interface_var_name}}) set(${PROJECT_NAME}_WRAPPER_INTERFACE_FILES_EFFECTIVE "${_gtwrap_interface_files}" CACHE INTERNAL "Effective wrapper interface files configured for the project." FORCE) @@ -521,6 +686,9 @@ function(configure_gtwrappers_common) set(GTWRAP_INTERFACE_FILES "${_gtwrap_interface_files}" PARENT_SCOPE) set(GTWRAP_TOP_NAMESPACE "${${_gtwrap_top_namespace_var_name}}" PARENT_SCOPE) set(GTWRAP_EXTRA_DEPENDENCY_TARGETS "${${_gtwrap_extra_deps_var_name}}" PARENT_SCOPE) + set(GTWRAP_RUNTIME_DEPENDENCY_TARGETS + "${${_gtwrap_runtime_deps_var_name}}" + PARENT_SCOPE) endfunction() # Python wrapper configuration using gtwrap @@ -830,39 +998,54 @@ namespace py = pybind11; "${PROJECT_BINARY_DIR}" "${PROJECT_PYTHON_BUILD_DIRECTORY}") + # Keep CMake installs relocatable beneath CMAKE_INSTALL_PREFIX. Installing + # into an active environment remains the responsibility of the pip target. + set(_python_install_root "python") + if(DEFINED WRAP_PYTHON_VERSION AND NOT "${WRAP_PYTHON_VERSION}" STREQUAL "") + set(_python_install_root + "${CMAKE_INSTALL_LIBDIR}/python${WRAP_PYTHON_VERSION}/site-packages") + elseif(DEFINED PYTHON_VERSION_MAJOR AND DEFINED PYTHON_VERSION_MINOR) + set(_python_install_root + "${CMAKE_INSTALL_LIBDIR}/python${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}/site-packages") + endif() + set(_python_package_install_destination + "${_python_install_root}/${PROJECT_NAME}") + + # A shared main library is always a Python runtime artifact. Static and + # interface targets are already linked into the extension and need no file. + set(_python_runtime_targets) + get_target_property( + _python_main_runtime_target_type + "${LIBNAME_WRAP_TARGET}" + TYPE) + if(_python_main_runtime_target_type STREQUAL "SHARED_LIBRARY" + OR _python_main_runtime_target_type STREQUAL "MODULE_LIBRARY") + list(APPEND _python_runtime_targets "${LIBNAME_WRAP_TARGET}") + endif() + if(GTWRAP_RUNTIME_DEPENDENCY_TARGETS) + list(APPEND + _python_runtime_targets + ${GTWRAP_RUNTIME_DEPENDENCY_TARGETS}) + endif() + + configure_python_runtime_artifacts( + "${PROJECT_PYTHON_TARGET_NAME}" + "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}" + "${_python_package_install_destination}" + _python_runtime_metadata_entries + ${_python_runtime_targets}) + set(_python_wrapper_link_content "\"\"\"Generated by CMake. Tracks the latest requested Python wrapper build.\"\"\" WRAPPER_MODULE_PATH = r\"$\" -WRAPPER_LIBRARY_DIRS = [r\"${PROJECT_BINARY_DIR}/src\"] +WRAPPER_RUNTIME_LIBRARY_PATHS = [ +${_python_runtime_metadata_entries}] ") file(GENERATE OUTPUT "${PROJECT_PYTHON_WRAPPER_LINK_FILE}" CONTENT "${_python_wrapper_link_content}") - # Resolve Python install directories to support CMake installs directly into active env site-packages. - set(_python_install_sitearch "") - set(_python_install_sitelib "") - execute_process( - COMMAND ${PYTHON_EXECUTABLE} -c "import sysconfig; print(sysconfig.get_path('platlib') or '')" - OUTPUT_VARIABLE _python_install_sitearch - OUTPUT_STRIP_TRAILING_WHITESPACE - RESULT_VARIABLE _python_install_sitearch_result - ) - execute_process( - COMMAND ${PYTHON_EXECUTABLE} -c "import sysconfig; print(sysconfig.get_path('purelib') or '')" - OUTPUT_VARIABLE _python_install_sitelib - OUTPUT_STRIP_TRAILING_WHITESPACE - RESULT_VARIABLE _python_install_sitelib_result - ) - - set(_python_install_root "python") - if(_python_install_sitearch_result EQUAL 0 AND NOT "${_python_install_sitearch}" STREQUAL "") - set(_python_install_root "${_python_install_sitearch}") - elseif(_python_install_sitelib_result EQUAL 0 AND NOT "${_python_install_sitelib}" STREQUAL "") - set(_python_install_root "${_python_install_sitelib}") - endif() - # Add import test for python module if enabled if(ENABLE_TESTS AND BUILD_TESTING) set(_python_import_test_name "${LIB_NAMESPACE}_python_import") @@ -873,7 +1056,6 @@ WRAPPER_LIBRARY_DIRS = [r\"${PROJECT_BINARY_DIR}/src\"] COMMAND ${CMAKE_COMMAND} -E env "PYTHONPATH=${PROJECT_PYTHON_SOURCE_DIR}:$ENV{PYTHONPATH}" - "LD_LIBRARY_PATH=${PROJECT_BINARY_DIR}/src:$ENV{LD_LIBRARY_PATH}" ${PYTHON_EXECUTABLE} -c "${_python_import_test_code}") set_tests_properties( ${_python_import_test_name} @@ -883,11 +1065,15 @@ WRAPPER_LIBRARY_DIRS = [r\"${PROJECT_BINARY_DIR}/src\"] install( TARGETS ${PROJECT_PYTHON_TARGET_NAME} - LIBRARY DESTINATION "${_python_install_root}/${PROJECT_NAME}") + LIBRARY DESTINATION "${_python_package_install_destination}" + RUNTIME DESTINATION "${_python_package_install_destination}") install( DIRECTORY "${PROJECT_PYTHON_PACKAGE_DIR}/" - DESTINATION "${_python_install_root}/${PROJECT_NAME}") + DESTINATION "${_python_package_install_destination}" + PATTERN "_wrapper_build.py" EXCLUDE + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE) if(NOT "${_python_metadata_file}" STREQUAL "") install( diff --git a/doc/wrappers.md b/doc/wrappers.md index 4be8dcb..70fd76d 100644 --- a/doc/wrappers.md +++ b/doc/wrappers.md @@ -12,6 +12,8 @@ Wrapper options are namespaced by `LIB_NAMESPACE`, which prevents nested templat | `_BUILD_MATLAB_WRAPPER` | Build the MATLAB MEX wrapper | | `_WRAPPER_INTERFACE_FILES` | Ordered list of `.i` files; first is the top module | | `_GTWRAP_TOP_NAMESPACE` | C++ namespace exposed at the Python/MATLAB module root | +| `_GTWRAP_DEPENDENCY_TARGETS` | Additional build-order dependencies required before wrapper generation | +| `_GTWRAP_RUNTIME_DEPENDENCY_TARGETS` | Direct project-owned shared runtime build targets packaged beside the Python wrapper | | `_GTWRAP_ROOT_DIR` | Local `wrap` checkout override | `build_lib.sh -p` and `build_lib.sh -m` set the Python and MATLAB wrapper options for the main project. @@ -50,6 +52,13 @@ python -c "import template_project; assert template_project.HAS_WRAPPER" The package requires Python 3.12 or newer by default. Adjust `PROJECT_PYTHON_VERSION` in the root `CMakeLists.txt` and `requires-python` in `python/pyproject.toml.in` together. +The main project shared library is packaged automatically. List additional +direct project-owned `SHARED_LIBRARY` or `MODULE_LIBRARY` build targets in +`_GTWRAP_RUNTIME_DEPENDENCY_TARGETS`; CMake rejects imported, +static, interface, or missing targets rather than scanning arbitrary build +directories. CMake alias target names are not accepted. System libraries +remain the responsibility of the target platform. + ## MATLAB Wrapper The MATLAB wrapper needs a MATLAB installation visible to CMake. Use the same local `wrap` checkout as Python when validating both wrapper types. diff --git a/python/pyproject.toml.in b/python/pyproject.toml.in index 01a02a2..35a7361 100644 --- a/python/pyproject.toml.in +++ b/python/pyproject.toml.in @@ -17,4 +17,4 @@ packages = ["@PROJECT_NAME@"] include-package-data = true [tool.setuptools.package-data] -"@PROJECT_NAME@" = ["*.so", "*.pyd", "*.dylib", "*.pyi", "**/*.pyi"] +"@PROJECT_NAME@" = ["*.so", "*.so.*", "*.pyd", "*.dll", "*.dylib", "*.pyi", "**/*.pyi"] diff --git a/python/setup.py.in b/python/setup.py.in index 733d897..660dbc8 100644 --- a/python/setup.py.in +++ b/python/setup.py.in @@ -1,4 +1,15 @@ -"""Setup helpers for the source Python package.""" +"""Build a binary wheel from explicit CMake wrapper metadata. + +The configured source package copies only the wrapper module and runtime +artifacts declared by CMake. It never discovers native libraries by scanning a +build directory. + +Example: + python -m pip wheel . --no-build-isolation --no-deps + +Output: + Successfully built @PROJECT_NAME@ +""" from __future__ import annotations @@ -10,7 +21,21 @@ from setuptools import Distribution, setup from setuptools.command.build_py import build_py -def _discover_package_name() -> str: +def _Discover_package_name() -> str: + """Find the single source package located beside this setup file. + + Returns: + Source package directory name. + + Raises: + RuntimeError: If no Python package directory can be found. + + Example: + print(_Discover_package_name()) + + Output: + @PROJECT_NAME@ + """ project_root_ = Path(__file__).resolve().parent for candidate_ in sorted(project_root_.iterdir()): if candidate_.is_dir() and (candidate_ / "__init__.py").is_file(): @@ -18,20 +43,77 @@ def _discover_package_name() -> str: raise RuntimeError("Could not discover the Python package directory next to setup.py.") -PACKAGE_NAME = _discover_package_name() +PACKAGE_NAME = _Discover_package_name() +NATIVE_LIBRARY_PATTERNS = ("*.so", "*.so.*", "*.dylib", "*.dll", "*.pyd") + + +def _Copy_runtime_libraries(runtime_paths_: list[Path], + destination_dir_: Path, + reserved_names_: set[str]) -> None: + """Copy exact runtime artifacts while rejecting missing files or collisions. + + Args: + runtime_paths_: Exact runtime artifact paths generated by CMake. + destination_dir_: Wheel package directory receiving the artifacts. + reserved_names_: Destination filenames already owned by other package + artifacts, such as the wrapper module. + + Raises: + RuntimeError: If a runtime path is missing or two different artifacts + request the same destination filename. + + Example: + from tempfile import TemporaryDirectory + with TemporaryDirectory() as temporary_dir_: + temporary_path_ = Path(temporary_dir_) + runtime_path_ = temporary_path_ / "libexample.so" + runtime_path_.write_bytes(b"runtime") + destination_dir_ = temporary_path_ / "package" + destination_dir_.mkdir() + _Copy_runtime_libraries( + [runtime_path_], destination_dir_, set()) + print((destination_dir_ / "libexample.so").is_file()) + + Output: + True + """ + copied_sources_: set[Path] = set() + copied_names_ = set(reserved_names_) + + for runtime_path_ in runtime_paths_: + resolved_runtime_path_ = runtime_path_.resolve() + if not runtime_path_.is_file(): + raise RuntimeError( + "The linked wrapper runtime metadata is stale or missing. " + f"Expected '{runtime_path_}'. Rebuild with Python wrapping " + "before installing." + ) + if resolved_runtime_path_ in copied_sources_: + continue + if runtime_path_.name in copied_names_: + raise RuntimeError( + "Python runtime artifacts have a destination filename " + f"collision: '{runtime_path_.name}'." + ) + + shutil.copy2(runtime_path_, destination_dir_ / runtime_path_.name) + copied_sources_.add(resolved_runtime_path_) + copied_names_.add(runtime_path_.name) class BinaryDistribution(Distribution): """Mark wheel as platform-specific when a linked wrapper is present.""" def has_ext_modules(self) -> bool: + """Report that configured wrapper packages contain native artifacts.""" return True class LinkedWrapperBuildPy(build_py): - """Copy the latest linked wrapper build into the installable package, if present.""" + """Copy the latest explicitly linked wrapper build into the wheel package.""" def run(self) -> None: + """Build Python sources and add exact CMake-declared native artifacts.""" super().run() package_dir_ = Path(__file__).resolve().parent / PACKAGE_NAME @@ -55,7 +137,30 @@ class LinkedWrapperBuildPy(build_py): destination_dir_ = Path(self.build_lib) / PACKAGE_NAME destination_dir_.mkdir(parents=True, exist_ok=True) - shutil.copy2(wrapper_module_path_, destination_dir_ / wrapper_module_path_.name) + + # Remove native output left by a previous wheel build so package + # contents are determined only by the current CMake metadata. + for native_pattern_ in NATIVE_LIBRARY_PATTERNS: + for stale_native_path_ in destination_dir_.glob(native_pattern_): + if stale_native_path_.is_file(): + stale_native_path_.unlink() + + destination_wrapper_path_ = destination_dir_ / wrapper_module_path_.name + shutil.copy2(wrapper_module_path_, destination_wrapper_path_) + + runtime_paths_ = [ + Path(path_) + for path_ in wrapper_link_module_.WRAPPER_RUNTIME_LIBRARY_PATHS + ] + _Copy_runtime_libraries( + runtime_paths_, + destination_dir_, + {wrapper_module_path_.name}, + ) + + build_link_file_ = destination_dir_ / "_wrapper_build.py" + if build_link_file_.exists(): + build_link_file_.unlink() setup( diff --git a/python/template_project/__init__.py b/python/template_project/__init__.py index 8aab7b3..483fc81 100644 --- a/python/template_project/__init__.py +++ b/python/template_project/__init__.py @@ -10,9 +10,19 @@ HAS_WRAPPER = False WRAPPER_IMPORT_ERROR: ImportError | None = None +_DLL_DIRECTORY_HANDLES_: list[object] = [] + +# Python 3.8+ requires explicit DLL search directories on Windows. Retain each +# handle for the package lifetime so later delay-loaded runtime dependencies +# remain resolvable. +if os.name == "nt": + _DLL_DIRECTORY_HANDLES_.append( + os.add_dll_directory(str(Path(__file__).resolve().parent)) + ) def _export_wrapper_module(module_: ModuleType) -> None: + """Re-export the compiled wrapper's public names at package scope.""" public_names_ = getattr(module_, "__all__", None) if public_names_ is None: public_names_ = [name_ for name_ in dir(module_) if not name_.startswith("_")] @@ -21,6 +31,15 @@ def _export_wrapper_module(module_: ModuleType) -> None: def _import_build_linked_wrapper() -> ModuleType: + """Import the exact build-tree wrapper recorded by generated metadata. + + Returns: + Loaded build-tree extension module. + + Raises: + ImportError: If metadata, native artifacts, or the module spec are + unavailable. + """ try: from . import _wrapper_build except ImportError as exc: @@ -31,10 +50,17 @@ def _import_build_linked_wrapper() -> ModuleType: raise ImportError(f"Build-linked wrapper module was not found at '{module_path_}'.") if os.name == "nt": - for dll_dir_ in getattr(_wrapper_build, "WRAPPER_LIBRARY_DIRS", []): - dll_path_ = Path(dll_dir_) + runtime_paths_ = getattr( + _wrapper_build, + "WRAPPER_RUNTIME_LIBRARY_PATHS", + [], + ) + for runtime_path_ in runtime_paths_: + dll_path_ = Path(runtime_path_).parent if dll_path_.is_dir(): - os.add_dll_directory(str(dll_path_)) + _DLL_DIRECTORY_HANDLES_.append( + os.add_dll_directory(str(dll_path_)) + ) package_name_ = __name__.split(".")[-1] module_name_ = f"{__name__}.{package_name_}" From cda47a7750ed9e57b4f188fcafdbdee3db08c53e Mon Sep 17 00:00:00 2001 From: PeterC Date: Mon, 27 Jul 2026 12:24:47 +0200 Subject: [PATCH 4/9] Add isolated runtime packaging conformance test --- tailor_template_cleanup.sh | 1 + tests/CMakeLists.txt | 14 + ...VerifyTemplateProjectPythonPackaging.cmake | 495 ++++++++++++++++++ ...VerifyTemplateProjectTailoringScript.cmake | 2 + 4 files changed, 512 insertions(+) create mode 100644 tests/cmake/VerifyTemplateProjectPythonPackaging.cmake diff --git a/tailor_template_cleanup.sh b/tailor_template_cleanup.sh index dfa1c0f..570f6c1 100755 --- a/tailor_template_cleanup.sh +++ b/tailor_template_cleanup.sh @@ -74,6 +74,7 @@ template_development_paths=( "tests/cmake/VerifyTemplateProjectNoOptimization.cmake" "tests/cmake/VerifyTemplateProjectOptixInstallExport.cmake" "tests/cmake/VerifyTemplateProjectOptimizedFlags.cmake" + "tests/cmake/VerifyTemplateProjectPythonPackaging.cmake" "tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake" "tests/cmake/VerifyTemplateProjectRos2Overlay.cmake" "tests/cmake/VerifyTemplateProjectTailoringScript.cmake" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aba1eef..d545f6c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -123,6 +123,20 @@ set_tests_properties( TIMEOUT 60 ) +add_test( + NAME template_project_python_packaging + COMMAND ${CMAKE_COMMAND} + -DTEST_TEMPLATE_SOURCE_DIR=${PROJECT_SOURCE_DIR} + -DTEST_BINARY_ROOT=${PROJECT_BINARY_DIR}/python_packaging + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/VerifyTemplateProjectPythonPackaging.cmake +) +set_tests_properties( + template_project_python_packaging + PROPERTIES + LABELS "install;package;python;wrapper" + TIMEOUT 180 +) + add_test( NAME template_project_build_tree_package_root COMMAND ${CMAKE_COMMAND} diff --git a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake new file mode 100644 index 0000000..345cb23 --- /dev/null +++ b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake @@ -0,0 +1,495 @@ +cmake_minimum_required(VERSION 3.15) + +# Prove exact, relocatable wrapper packaging with a self-contained native +# fixture. No gtwrap checkout or network access is required. +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() + +find_program(_python_executable NAMES python3 REQUIRED) + +# Run fixture commands with captured diagnostics so a failed acceptance step +# identifies both its intent and the underlying tool output. +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() + +file(REMOVE_RECURSE "${TEST_BINARY_ROOT}") +set(_fixture_source "${TEST_BINARY_ROOT}/fixture_source") +set(_fixture_build "${TEST_BINARY_ROOT}/fixture_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") +file(MAKE_DIRECTORY + "${_fixture_source}/python/fixture_package" + "${_wheel_output}") + +# Build a two-library runtime chain plus an unrelated library in the same +# scratch build. The Python module calls through both declared runtime targets. +file(WRITE "${_fixture_source}/dependency.c" +"int FixtureDependencyValue(void) +{ + return 41; +} +") +file(WRITE "${_fixture_source}/runtime.c" +"int FixtureDependencyValue(void); + +int FixtureRuntimeValue(void) +{ + return FixtureDependencyValue() + 1; +} +") +file(WRITE "${_fixture_source}/unrelated.c" +"int UnrelatedRuntimeValue(void) +{ + return -1; +} +") +file(WRITE "${_fixture_source}/packaged.c" +"int PackagedRuntimeValue(void) +{ + return 7; +} +") +file(WRITE "${_fixture_source}/module.c" +"#define PY_SSIZE_T_CLEAN +#include + +int FixtureRuntimeValue(void); + +static PyObject *RuntimeValue(PyObject *self, PyObject *args) +{ + (void)self; + (void)args; + return PyLong_FromLong(FixtureRuntimeValue()); +} + +static PyMethodDef FixtureMethods[] = { + {\"Runtime_value\", RuntimeValue, METH_NOARGS, + \"Return the value produced by the packaged runtime chain.\"}, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef FixtureModule = { + PyModuleDef_HEAD_INIT, + \"fixture_package\", + \"Self-contained wrapper packaging fixture.\", + -1, + FixtureMethods +}; + +PyMODINIT_FUNC PyInit_fixture_package(void) +{ + return PyModule_Create(&FixtureModule); +} +") +file(WRITE "${_fixture_source}/python/fixture_package/__init__.py" +"\"\"\"Self-contained wrapper packaging fixture.\"\"\" + +from .fixture_package import Runtime_value + +__all__ = [\"Runtime_value\"] +") + +set(_fixture_cmake_template [=[ +cmake_minimum_required(VERSION 3.15) +project(python_packaging_fixture VERSION 1.0.0 LANGUAGES C) + +include(GNUInstallDirs) +find_package(Python3 3.12 REQUIRED COMPONENTS Interpreter Development) +include("@TEST_TEMPLATE_SOURCE_DIR@/cmake/HandleWrapper.cmake") + +# Export the small C fixture API on DLL platforms without adding +# platform-specific declarations to every generated source file. +if(WIN32) + set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) +endif() + +add_library(fixture_dependency SHARED dependency.c) +set_target_properties( + fixture_dependency + PROPERTIES + VERSION 2.3.4 + SOVERSION 2) + +add_library(fixture_runtime SHARED runtime.c) +target_link_libraries(fixture_runtime PRIVATE fixture_dependency) +set_target_properties( + fixture_runtime + PROPERTIES + VERSION 1.2.3 + SOVERSION 1) + +add_library(unrelated_runtime SHARED unrelated.c) + +# This declared runtime is intentionally not linked into the extension. Its +# staged copy must still refresh when only this target changes. +add_library(fixture_packaged SHARED packaged.c) +set_target_properties( + fixture_packaged + PROPERTIES + VERSION 3.4.5 + SOVERSION 3) + +add_library(fixture_package MODULE module.c) +target_link_libraries( + fixture_package + PRIVATE + fixture_runtime + Python3::Python) +set_target_properties(fixture_package PROPERTIES PREFIX "") +if(WIN32) + set_target_properties(fixture_package PROPERTIES SUFFIX ".pyd") +endif() + +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_python_target_properties( + fixture_package + "fixture_package" + "${_package_build_dir}") + +set(_python_install_root + "${CMAKE_INSTALL_LIBDIR}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages") +set(_package_install_destination + "${_python_install_root}/fixture_package") +configure_python_runtime_artifacts( + fixture_package + "${_package_build_dir}" + "${_package_install_destination}" + _runtime_metadata_entries + fixture_runtime + fixture_dependency + fixture_packaged) + +set(_wrapper_metadata +"\"\"\"Generated wrapper packaging fixture metadata.\"\"\" + +WRAPPER_MODULE_PATH = r\"$\" +WRAPPER_RUNTIME_LIBRARY_PATHS = [ +${_runtime_metadata_entries}] +") +file(GENERATE + OUTPUT "${_package_dir}/_wrapper_build.py" + CONTENT "${_wrapper_metadata}") + +set(PROJECT_NAME fixture_package) +set(PROJECT_VERSION 1.0.0) +configure_file( + "@TEST_TEMPLATE_SOURCE_DIR@/python/pyproject.toml.in" + "${_package_source}/pyproject.toml" + @ONLY) +configure_file( + "@TEST_TEMPLATE_SOURCE_DIR@/python/setup.py.in" + "${_package_source}/setup.py" + @ONLY) + +install( + TARGETS fixture_package + LIBRARY DESTINATION "${_package_install_destination}" + RUNTIME DESTINATION "${_package_install_destination}") +install( + DIRECTORY "${_package_dir}/" + DESTINATION "${_package_install_destination}" + PATTERN "_wrapper_build.py" EXCLUDE + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE) + +file(WRITE + "${CMAKE_CURRENT_BINARY_DIR}/python_install_root.txt" + "${_python_install_root}") +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/expected_runtime_names.txt" + CONTENT +"$ +$ +$ +$ +$ +$ +") +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/expected_wrapper_name.txt" + CONTENT "$") +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/unrelated_name.txt" + CONTENT "$") +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/packaged_target_path.txt" + CONTENT "$") +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/packaged_staged_path.txt" + CONTENT "${_package_build_dir}/$") +]=]) +string(CONFIGURE + "${_fixture_cmake_template}" + _fixture_cmake + @ONLY) +file(WRITE "${_fixture_source}/CMakeLists.txt" "${_fixture_cmake}") + +# 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 +import sys +from zipfile import ZipFile + +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_runtime_names_ = { + name_.strip() + for name_ in expected_names_path_.read_text().splitlines() + if name_.strip() +} +wrapper_name_ = wrapper_name_path_.read_text().strip() +unrelated_name_ = unrelated_name_path_.read_text().strip() +expected_native_names_ = expected_runtime_names_ | {wrapper_name_} + +with ZipFile(wheel_path_) as wheel_file_: + archive_names_ = set(wheel_file_.namelist()) + +packaged_native_names_ = { + Path(name_).name + for name_ in archive_names_ + if name_.startswith(\"fixture_package/\") + and ( + \".so\" in Path(name_).name + or Path(name_).suffix in {\".dylib\", \".dll\", \".pyd\"} + ) +} + +assert packaged_native_names_ == expected_native_names_, ( + 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_) +print(\"wheel_contents=ok\") +") +file(WRITE "${TEST_BINARY_ROOT}/verify_install.py" +"from pathlib import Path +import sys + +package_dir_ = 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_runtime_names_ = { + name_.strip() + for name_ in expected_names_path_.read_text().splitlines() + if name_.strip() +} +wrapper_name_ = wrapper_name_path_.read_text().strip() +unrelated_name_ = unrelated_name_path_.read_text().strip() + +for expected_name_ in expected_runtime_names_ | {wrapper_name_}: + assert (package_dir_ / expected_name_).is_file(), expected_name_ + +installed_native_names_ = { + path_.name + for path_ in package_dir_.iterdir() + if path_.is_file() + and ( + \".so\" in path_.name + or path_.suffix in {\".dylib\", \".dll\", \".pyd\"} + ) +} +expected_native_names_ = expected_runtime_names_ | {wrapper_name_} +assert installed_native_names_ == expected_native_names_, ( + installed_native_names_, + expected_native_names_, +) +assert not (package_dir_ / unrelated_name_).exists() +assert not (package_dir_ / \"_wrapper_build.py\").exists() +assert not list(package_dir_.glob(\"*.pyc\")) +assert not (package_dir_ / \"__pycache__\").exists() +print(\"cmake_install_contents=ok\") +") + +_run_step( + "Configure self-contained Python packaging fixture" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_fixture_build}" + -DCMAKE_BUILD_TYPE=RelWithDebInfo) +_run_step( + "Build self-contained Python packaging fixture" + "${CMAKE_COMMAND}" + --build "${_fixture_build}" + --parallel 4) + +# 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" +"int PackagedRuntimeValue(void) +{ + return 8; +} +") +_run_step( + "Refresh an incrementally rebuilt declared runtime" + "${CMAKE_COMMAND}" + --build "${_fixture_build}" + --target fixture_package + --parallel 4) +file(READ "${_fixture_build}/packaged_target_path.txt" _packaged_target_path) +file(READ "${_fixture_build}/packaged_staged_path.txt" _packaged_staged_path) +string(STRIP "${_packaged_target_path}" _packaged_target_path) +string(STRIP "${_packaged_staged_path}" _packaged_staged_path) +_run_step( + "Compare refreshed target and staged runtime" + "${CMAKE_COMMAND}" -E compare_files + "${_packaged_target_path}" + "${_packaged_staged_path}") + +_run_step( + "Build isolated fixture wheel" + "${_python_executable}" + -m pip wheel + "${_fixture_source}/python" + --no-build-isolation + --no-deps + --wheel-dir "${_wheel_output}") +file(GLOB _wheel_paths "${_wheel_output}/fixture_package-*.whl") +list(LENGTH _wheel_paths _wheel_count) +if(NOT _wheel_count EQUAL 1) + message(FATAL_ERROR + "Expected one fixture wheel, found ${_wheel_count}: ${_wheel_paths}") +endif() +list(GET _wheel_paths 0 _wheel_path) + +_run_step( + "Verify exact fixture wheel contents" + "${_python_executable}" + "${TEST_BINARY_ROOT}/verify_wheel.py" + "${_wheel_path}" + "${_fixture_build}/expected_runtime_names.txt" + "${_fixture_build}/expected_wrapper_name.txt" + "${_fixture_build}/unrelated_name.txt") +_run_step( + "Install fixture wheel into isolated target" + "${_python_executable}" + -m pip install + --no-deps + --target "${_wheel_install}" + "${_wheel_path}") +_run_step( + "Import isolated fixture wheel" + "${CMAKE_COMMAND}" -E env + "PYTHONPATH=${_wheel_install}" + "LD_LIBRARY_PATH=" + "${_python_executable}" -c + "import fixture_package; assert fixture_package.Runtime_value() == 42") + +_run_step( + "Install fixture through CMake prefix" + "${CMAKE_COMMAND}" + --install "${_fixture_build}" + --prefix "${_cmake_install}") +file(READ "${_fixture_build}/python_install_root.txt" _python_install_root) +string(STRIP "${_python_install_root}" _python_install_root) +set(_cmake_package_dir + "${_cmake_install}/${_python_install_root}/fixture_package") +_run_step( + "Verify exact CMake install contents" + "${_python_executable}" + "${TEST_BINARY_ROOT}/verify_install.py" + "${_cmake_package_dir}" + "${_fixture_build}/expected_runtime_names.txt" + "${_fixture_build}/expected_wrapper_name.txt" + "${_fixture_build}/unrelated_name.txt") +_run_step( + "Import isolated CMake-installed fixture" + "${CMAKE_COMMAND}" -E env + "PYTHONPATH=${_cmake_install}/${_python_install_root}" + "LD_LIBRARY_PATH=" + "${_python_executable}" -c + "import fixture_package; assert fixture_package.Runtime_value() == 42") + +# Import success proves the loader chain is viable. On supported host tools, +# also reject embedded absolute scratch paths directly. +file(READ "${_fixture_build}/expected_wrapper_name.txt" _wrapper_name) +string(STRIP "${_wrapper_name}" _wrapper_name) +set(_installed_wrapper "${_cmake_package_dir}/${_wrapper_name}") +file( + STRINGS + "${_fixture_build}/expected_runtime_names.txt" + _runtime_names) +set(_installed_native_artifacts "${_installed_wrapper}") +foreach(_runtime_name IN LISTS _runtime_names) + if(NOT "${_runtime_name}" STREQUAL "") + list(APPEND + _installed_native_artifacts + "${_cmake_package_dir}/${_runtime_name}") + endif() +endforeach() +list(REMOVE_DUPLICATES _installed_native_artifacts) + +if(UNIX AND NOT APPLE) + find_program(_readelf_executable NAMES readelf REQUIRED) + foreach(_installed_native_artifact IN LISTS _installed_native_artifacts) + execute_process( + COMMAND "${_readelf_executable}" -d "${_installed_native_artifact}" + RESULT_VARIABLE _readelf_result + OUTPUT_VARIABLE _readelf_output + ERROR_VARIABLE _readelf_stderr) + if(NOT _readelf_result EQUAL 0) + message(FATAL_ERROR + "readelf failed for ${_installed_native_artifact}: " + "${_readelf_stderr}") + endif() + if(NOT _readelf_output MATCHES "\\$ORIGIN") + message(FATAL_ERROR + "Installed native artifact has no loader-relative RUNPATH: " + "${_installed_native_artifact}") + endif() + string(FIND "${_readelf_output}" "${TEST_BINARY_ROOT}" _scratch_rpath_index) + if(NOT _scratch_rpath_index EQUAL -1) + message(FATAL_ERROR + "Installed native artifact retains scratch path: " + "${_installed_native_artifact}\n${_readelf_output}") + endif() + endforeach() +elseif(APPLE) + find_program(_otool_executable NAMES otool REQUIRED) + foreach(_installed_native_artifact IN LISTS _installed_native_artifacts) + execute_process( + COMMAND "${_otool_executable}" -l "${_installed_native_artifact}" + RESULT_VARIABLE _otool_result + OUTPUT_VARIABLE _otool_output + ERROR_VARIABLE _otool_stderr) + if(NOT _otool_result EQUAL 0) + message(FATAL_ERROR + "otool failed for ${_installed_native_artifact}: ${_otool_stderr}") + endif() + if(NOT _otool_output MATCHES "@loader_path") + message(FATAL_ERROR + "Installed native artifact has no loader-relative RPATH: " + "${_installed_native_artifact}") + endif() + string(FIND "${_otool_output}" "${TEST_BINARY_ROOT}" _scratch_rpath_index) + if(NOT _scratch_rpath_index EQUAL -1) + message(FATAL_ERROR + "Installed native artifact retains scratch path: " + "${_installed_native_artifact}\n${_otool_output}") + endif() + endforeach() +endif() diff --git a/tests/cmake/VerifyTemplateProjectTailoringScript.cmake b/tests/cmake/VerifyTemplateProjectTailoringScript.cmake index a9e7fe4..5df51a2 100644 --- a/tests/cmake/VerifyTemplateProjectTailoringScript.cmake +++ b/tests/cmake/VerifyTemplateProjectTailoringScript.cmake @@ -188,6 +188,7 @@ endif() "tests/cmake/VerifyTemplateProjectCudaSources.cmake" "tests/cmake/VerifyTemplateProjectDocsWorkflow.cmake" "tests/cmake/VerifyTemplateProjectOptixInstallExport.cmake" + "tests/cmake/VerifyTemplateProjectPythonPackaging.cmake" "tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake" "tests/cmake/VerifyTemplateProjectRos2Overlay.cmake" "tests/cmake/VerifyTemplateProjectTailoringScript.cmake" @@ -266,6 +267,7 @@ function(_assert_fake_project_cleaned fake_root expect_profiling) "tests/cmake/VerifyTemplateProjectCudaSources.cmake" "tests/cmake/VerifyTemplateProjectDocsWorkflow.cmake" "tests/cmake/VerifyTemplateProjectOptixInstallExport.cmake" + "tests/cmake/VerifyTemplateProjectPythonPackaging.cmake" "tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake" "tests/cmake/VerifyTemplateProjectRos2Overlay.cmake" "tests/template_test/testRos2OverlayStatic.py" From 913cedf81ea379d2ae8467c859d9b9526fdc1e76 Mon Sep 17 00:00:00 2001 From: PeterC Date: Mon, 27 Jul 2026 12:25:15 +0200 Subject: [PATCH 5/9] Add plan for clean up and v1.12.0 improvements --- ...d_cleanup_wrapper_packaging_repair_plan.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 doc/developments/build_cleanup_wrapper_packaging_repair_plan.md diff --git a/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md b/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md new file mode 100644 index 0000000..24d2f1f --- /dev/null +++ b/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md @@ -0,0 +1,345 @@ +# Build Cleanup and Wrapper Packaging Repair + +## Objective + +Retain the safe portions of the working-tree changes relative to current +`main` at `b4fb532` (`v1.11.3` ancestor `dbffe4d`), repair the clean-build and +Python packaging regressions, and validate every stage before integrating the +next one. Do not stage or commit while implementing this plan. + +## Stage 1: Remove isolated ROS editor leakage + +- [x] Remove the ROS-specific keys added to `.vscode/settings.json`. +- [x] Preserve the existing C/C++ and CMake editor settings. +- [x] Preserve the optional ROS overlay boundary and run its static verifier. + +### Stage 1 output + +- `python3 -m json.tool .vscode/settings.json`: passed. +- `VerifyTemplateProjectRos2Overlay.cmake`: passed against version `1.11.3`. +- No ROS overlay source, helper, workflow, marker, or documentation file was + changed. + +## Stage 2: Integrate source-archive hygiene + +- [x] Keep `CPACK_VERBATIM_VARIABLES` and recursive generated-directory ignore + expressions. +- [x] Add a nested `examples/build_release_sentinel` fixture. +- [x] Verify root and nested build output are absent from the release archive. +- [x] Run build-tree package and release-tag checks. + +### Stage 2 output + +- `VerifyTemplateProjectReleaseTagSync.cmake`: passed. +- `VerifyTemplateProjectBuildTreePackage.cmake`: passed. +- `git diff --check HEAD`: passed. + +## Stage 3: Integrate clean safety without changing established behavior + +- [x] Skip clean validation when `--rebuild-only` is active. +- [x] Preserve canonical path, conventional name, cache, and cache-owner checks. +- [x] Move all verifier fixtures into a disposable miniature source checkout. +- [x] Cover rejection and success paths, including rebuild-only preservation. +- [x] Update build-script documentation. +- [x] Run in-repository and external-build validation. + +### Stage 3 output + +- `shellcheck build_lib.sh`: passed. +- `bash -n build_lib.sh`: passed. +- Clean-safety verifier with an in-repository binary root: passed. +- Clean-safety verifier with a `/tmp` binary root: passed. +- The verifier confirmed rejection of outside, unconventional, cacheless, + markerless, and foreign-owned targets. +- The verifier confirmed successful owned cleanup and rebuild-only sentinel + preservation. +- `git diff --check HEAD`: passed. + +## Stage 4: Repair explicit main-runtime wrapper packaging + +- [x] Replace native-library directory globs with exact target-derived paths. +- [x] Stage the shared main target and its SONAME beside the wrapper. +- [x] Keep loader-relative RPATH and prefix-relative CMake installation. +- [x] Exclude checkout metadata, caches, and bytecode from distributions. +- [x] Validate isolated wheel and CMake-install imports. + +### Stage 4 output + +- Fresh wrapper configure and `template_project_py` build: passed. +- Build-linked import with an empty `LD_LIBRARY_PATH`: passed. +- Wheel contents contained only `template_project.so`, + `libtemplate_project.so`, package source, and distribution metadata. +- A synthetic `build/src/libunrelated_plugin.so` was excluded from the wheel. +- `_wrapper_build.py` was excluded from the wheel. +- Isolated wheel import with an empty `LD_LIBRARY_PATH`: passed. +- Prefix-relative CMake install placed the wrapper and main runtime library + under `lib/python3.12/site-packages/template_project`. +- Isolated CMake-install import with an empty `LD_LIBRARY_PATH`: passed. +- Installed wrapper RUNPATH was loader-relative: + `$ORIGIN/../lib:$ORIGIN`. +- Generated `python/setup.py` passed `py_compile`. + +## Stage 5: Add optional declared runtime dependencies + +- [x] Add `_GTWRAP_RUNTIME_DEPENDENCY_TARGETS` without changing the + existing build-order dependency option. +- [x] Validate target existence, ownership, and supported library type. +- [x] Stage and install declared targets through the same explicit path. +- [x] Document the additive interface and its limitations. + +### Stage 5 output + +- A wrapper configure using + `template_project_GTWRAP_RUNTIME_DEPENDENCY_TARGETS=template_project` + passed; duplicate automatic/explicit targets were deduplicated. +- The configured wrapper target built successfully. +- A missing declared target failed configure with the expected diagnostic. +- An interface-library declaration failed configure with the expected + supported-type diagnostic. +- The established `_GTWRAP_DEPENDENCY_TARGETS` build-order + contract remains unchanged. + +## Stage 6: Add self-contained packaging conformance coverage + +- [x] Build a network-free Python C-API fixture with versioned and unrelated + shared libraries. +- [x] Verify exact wheel and CMake-install contents. +- [x] Import from isolated locations without build-tree loader paths. +- [x] Ensure template tailoring removes the conformance test. + +### Stage 6 output + +- The fixture built a Python C-API module, a two-library versioned runtime + chain, and an unrelated shared-library sentinel without downloading + dependencies. +- The generated wheel contained exactly the wrapper and declared runtime + artifact names; the unrelated library and `_wrapper_build.py` were absent. +- Wheel and CMake-prefix imports returned the expected value with an empty + `LD_LIBRARY_PATH`. +- The installed wrapper used a loader-relative runtime path and retained no + disposable build-root path. +- An unlinked declared runtime was modified and rebuilt through the wrapper + target; its staged copy refreshed byte-for-byte without requiring the + extension to relink. +- `VerifyTemplateProjectPythonPackaging.cmake`: passed. +- `VerifyTemplateProjectTailoringScript.cmake`: passed after adding explicit + removal coverage for both new template-conformance verifiers. +- `git diff --check HEAD`: passed. + +## Stage 7: Integrated acceptance and review + +- [x] Run the complete external CPU build and CTest suite. +- [x] Run actual gtwrap wheel and CMake-install smoke checks. +- [x] Run source release, install/export, and external C++ consumer checks. +- [x] Run static and syntax checks plus `git diff --check`. +- [x] Review complete documentation and readability without staging. +- [x] Record proposed branch name and dependency-ordered commit split. + +### Stage 7 output + +- Fresh external Ninja configure and full CPU/gtwrap build under + `/tmp/cpp_cuda_template_acceptance`: passed. The compiler reported only + existing warnings from bundled pybind11 headers. +- Complete CTest suite: 29/29 passed. This includes clean safety, exact Python + packaging, canonical source release, tailoring, cross-compile, C++ runtime, + Python, ROS-overlay static, and workflow checks. +- CMake prefix install: passed. +- A fresh external C++ consumer found the installed package, linked + `template_project::template_project`, built, and ran successfully. +- CMake-installed Python import with an empty `LD_LIBRARY_PATH`: passed. +- The real gtwrap wheel contained exactly `template_project.so` and + `libtemplate_project.so` as native package artifacts; `_wrapper_build.py` + was absent. +- Isolated real-wheel import with an empty `LD_LIBRARY_PATH`: passed. +- Wheel native artifacts used loader-relative RUNPATH entries and contained no + acceptance-build path. +- `bash -n`, `shellcheck`, configured Python `py_compile`, JSON parsing, and + `git diff --check HEAD`: passed. +- Final review added destructive-boundary clean revalidation, incremental + runtime-copy refresh, package-lifetime Windows DLL directory handles, exact + CMake-install native-content assertions, and concise verifier/module + documentation. +- `.vscode/settings.json` now matches `HEAD`; no editor-only ROS change remains. +- The Git index is empty. No files were staged, committed, reset, or pushed. + +## Proposed branch and commit split + +Suggested branch: `fix/clean-wrapper-packaging-safety` + +1. `fix(build): constrain cleanup to owned build trees` + - `build_lib.sh`, clean documentation, the clean-safety verifier, its CTest + registration, and its tailoring-removal coverage. +2. `fix(package): exclude nested generated trees from source archives` + - The CPack ignore rules and canonical release/archive regression fixtures. +3. `fix(wrapper): package declared runtimes relocatably` + - Runtime-target options, `HandleWrapper.cmake`, Python package templates + and entrypoint, plus wrapper documentation. +4. `test(wrapper): add isolated runtime packaging conformance` + - The self-contained packaging verifier, its CTest registration, and its + tailoring-removal coverage. +5. `docs(dev): record cleanup and packaging repair` + - This development plan and validation log. + +Files containing more than one concern (`CMakeLists.txt`, `README.md`, +`tests/CMakeLists.txt`, `tailor_template_cleanup.sh`, and the tailoring +verifier) should be split by hunk when forming these commits. + +## Commit staging log + +- [x] Stage and review `fix(build): constrain cleanup to owned build trees`. +- [x] Stage and review `fix(package): exclude nested generated trees from + source archives`. +- [x] Stage and review `fix(wrapper): package declared runtimes relocatably`. +- [x] Stage and review + `test(wrapper): add isolated runtime packaging conformance`. +- [ ] Stage and review `docs(dev): record cleanup and packaging repair`. + +### First staged batch output + +- Staged seven clean-safety files/hunks; wrapper and archive changes remain + unstaged. +- Reviewed the complete `git diff --cached`, including the new verifier as a + reader will receive it. +- Updated the staged `build_lib.sh` module header and documented the new + clean-path validation contract during the staged-code quality pass. +- Materialized the exact Git index at + `/tmp/cpp_cuda_template_index_stage1.xUDNvI`. +- Exact-index `bash -n`, ShellCheck, direct clean-safety verification, direct + tailoring verification, and CMake configure: passed. +- Exact-index focused CTest: 2/2 passed. +- `git diff --cached --check`: passed. + +### Second staged batch output + +- Confirmed the first batch was committed as `7b0429c` on + `bugfix/clean-wrapper-packaging-safety`. +- Staged only the CPack source-ignore rules and the two canonical + release/archive verifier updates; runtime-wrapper CMake options remain + unstaged. +- Reviewed the complete three-file `git diff --cached`. +- Materialized and tagged the final exact Git index at + `/tmp/cpp_cuda_template_index_stage2_final.Iercsg`. +- Exact-index canonical release/source-archive verification: passed. +- Exact-index build-tree package, install, and external-consumer verification: + passed. +- `git diff --cached --check`: passed. + +### Third staged batch output + +- Confirmed the source-archive batch was committed as `ee09cd8` on + `bugfix/clean-wrapper-packaging-safety`. +- Staged only the seven wrapper implementation and documentation files; + packaging-conformance tests, tailoring coverage, and this development plan + remain unstaged. +- Reviewed the complete `git diff --cached` as the commit will be received. + Strengthened the `HandleWrapper.cmake` file header and documented the + multi-config output-path invariant during the staged-code quality pass. +- Materialized and tagged the final exact Git index at + `/tmp/cpp_cuda_template_index_stage3.Eln2e6`. +- Exact-index shared-library configure, complete build, and wrapper import: + passed. +- Exact-index wheel and prefix-relative CMake install contained only + `template_project.so` and `libtemplate_project.so` as native package + artifacts. `_wrapper_build.py` and a synthetic unrelated shared library were + excluded. +- Isolated wheel and CMake-install imports with an empty `LD_LIBRARY_PATH`: + passed. All packaged native artifacts used loader-relative `$ORIGIN` paths + without retaining the disposable checkout path. +- Explicit duplicate runtime declarations were deduplicated. Missing and + interface-library runtime declarations failed configure with the intended + diagnostics. +- Exact-index static-library configure, build, import, and wheel-content + matrix: passed; the wheel contained only the extension module as a native + artifact. +- `git diff --cached --check`: passed. + +### Third staged batch minimality audit + +- Re-reviewed all 388 additions and 41 removals relative to `ee09cd8`. The + staged scope remains limited to seven wrapper implementation, package + metadata, entrypoint, and public documentation files. +- Every functional area maps to a required contract: a separate runtime-target + option, exact target/SONAME staging, relocatable loader paths, + prefix-relative CMake installation, deterministic wheel contents, metadata + exclusion, and Windows DLL-directory lifetime. +- The larger helper is required to support project-owned shared/module targets, + versioned libraries, unlinked declared runtimes, incremental refresh, CMake + install, and Linux/macOS/Windows output without scanning build directories. +- Documentation, comments, type hints, examples, and docstrings add no runtime + behavior but satisfy the repository's staged-code documentation/readability + gate and expose the new public option. +- One caller-side `list(REMOVE_DUPLICATES)` is redundant because + `configure_python_runtime_artifacts()` already deduplicates its input. +- The two `getattr(..., "WRAPPER_RUNTIME_LIBRARY_PATHS", [])` fallbacks are not + required for newly generated metadata. In the wheel builder, the default can + hide stale pre-change metadata and should fail early instead of producing an + extension-only wheel. In the package entrypoint, graceful fallback is part of + the existing pure-Python import behavior, so strict access would need to be + converted into a caught `ImportError` rather than allowed to escape. +- CMake alias targets are not valid operands for `set_target_properties()` or + `install(TARGETS)`. The public option should either be documented as accepting + direct project-owned build targets only or resolve aliases before validation. +- The fallback `pyproject.toml` and `setup.py` generators predate this change + and do not implement binary-wheel assembly. Expanding those legacy fallbacks + would broaden this commit; the checked-in templates remain the supported + relocatable-wheel path. +- No staged source was changed during the initial audit. The user subsequently + approved the three minimal tightening recommendations. + +### Third staged batch minimality tightening output + +- Removed the redundant caller-side runtime-target deduplication; the packaging + helper remains the single owner of that invariant. +- Changed wheel assembly to require + `WRAPPER_RUNTIME_LIBRARY_PATHS`. Stale pre-change metadata now fails the wheel + build instead of silently producing an extension-only wheel. +- Documented the minimal direct-build-target contract rather than adding CMake + alias resolution. Alias target names are explicitly outside the option + contract. +- Reduced the staged delta from 388 to 384 additions while preserving the + seven-file wrapper-only scope. +- Materialized the tightened exact Git index at + `/tmp/cpp_cuda_template_index_stage3_minimal.DWF3j9`; its tree is + `737f0b60a2adf0a8c959b0e568de73d7c04a716e`. +- Exact-index shared configure, full build, focused wrapper CTest, wheel build, + CMake install, and isolated wheel/install imports with an empty + `LD_LIBRARY_PATH`: passed. +- The shared wheel contained exactly `template_project.so` and + `libtemplate_project.so` as native package artifacts; checkout metadata was + absent and packaged artifacts retained loader-relative RUNPATH entries. +- Deliberately removing `WRAPPER_RUNTIME_LIBRARY_PATHS` from generated metadata + caused the wheel build to fail with the expected missing-attribute + diagnostic. +- Exact-index static configure, wrapper build, focused CTest, wheel build, + native-content assertion, and isolated import: passed. Valid static metadata + retained an explicit empty runtime list. +- The compiler reported only the established warnings from bundled pybind11 + headers. + +### Fourth staged batch output + +- Confirmed the wrapper implementation was committed as `ff6af61` + (`[MAJOR] Add relocatable runtime packaging for wrapper`) on + `bugfix/clean-wrapper-packaging-safety`. +- Staged exactly four template-conformance files: the packaging verifier, its + CTest registration, the cleanup manifest entry, and tailoring-removal + assertions. This development plan remains unstaged. +- Reviewed the complete 512-line staged delta as the commit will be received. + The new verifier owns a disposable, network-free fixture and separates + incremental refresh, exact wheel contents, exact CMake-install contents, + isolated imports, and loader-path assertions into documented blocks. +- Added fixture-local Windows symbol export handling during the staged-code + quality pass so the generated runtime chain is linkable on DLL platforms. +- Materialized the exact Git index at + `/tmp/cpp_cuda_template_index_stage4.bHX3Wf`; its tree is + `733ea9b6f9fc96b27e3800ad3a0fa4c564010e3a`. +- Exact-index configure registered the packaging and tailoring tests. +- Exact-index focused CTest: 2/2 passed + (`template_project_python_packaging` and + `template_project_tailoring_cleanup_script`). +- The packaging test built versioned linked runtimes, an unlinked declared + runtime, an unrelated sentinel, and a Python C-API module without gtwrap or + network access. Wheel/install contents, incremental refresh, isolated + imports, and loader-relative paths passed. +- Exact-index ShellCheck, `bash -n`, `git diff --cached --check`, index-tree + equality, and four-file scope assertions: passed. From 2f217097910c585b91a30386b7ec6e63413e9b08 Mon Sep 17 00:00:00 2001 From: PeterC Date: Mon, 27 Jul 2026 12:28:36 +0200 Subject: [PATCH 6/9] Update ros 2 metadata --- ros2/template_project/package.xml | 2 +- ros2/template_project_interfaces/package.xml | 2 +- ros2/template_project_ros/package.xml | 2 +- ros2/template_project_spinup/package.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ros2/template_project/package.xml b/ros2/template_project/package.xml index e47efe1..82eb4a7 100644 --- a/ros2/template_project/package.xml +++ b/ros2/template_project/package.xml @@ -2,7 +2,7 @@ template_project - 1.11.3 + 1.12.0 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 colcon shim package. Pietro Califano MIT diff --git a/ros2/template_project_interfaces/package.xml b/ros2/template_project_interfaces/package.xml index 01cec94..3c7a430 100644 --- a/ros2/template_project_interfaces/package.xml +++ b/ros2/template_project_interfaces/package.xml @@ -2,7 +2,7 @@ template_project_interfaces - 1.11.3 + 1.12.0 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 message and service interfaces. Pietro Califano MIT diff --git a/ros2/template_project_ros/package.xml b/ros2/template_project_ros/package.xml index 4c4392e..8c19775 100644 --- a/ros2/template_project_ros/package.xml +++ b/ros2/template_project_ros/package.xml @@ -2,7 +2,7 @@ template_project_ros - 1.11.3 + 1.12.0 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 bridge package. Pietro Califano MIT diff --git a/ros2/template_project_spinup/package.xml b/ros2/template_project_spinup/package.xml index bbd57af..48cdc1a 100644 --- a/ros2/template_project_spinup/package.xml +++ b/ros2/template_project_spinup/package.xml @@ -2,7 +2,7 @@ template_project_spinup - 1.11.3 + 1.12.0 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 launch and runtime assets. Pietro Califano MIT From b277e4b84e2f1e501d6c2e73370efe0ecd101f23 Mon Sep 17 00:00:00 2001 From: PeterC Date: Mon, 27 Jul 2026 12:50:56 +0200 Subject: [PATCH 7/9] [BUGFIX] Add resolution function for Python ABI install - Derive the prefix-relative site-packages path from resolved major/minor - Reject configurations without a resolved Python ABI version --- cmake/HandleWrapper.cmake | 37 ++++++++--- ...d_cleanup_wrapper_packaging_repair_plan.md | 65 +++++++++++++++++++ ...VerifyTemplateProjectPythonPackaging.cmake | 14 +++- 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/cmake/HandleWrapper.cmake b/cmake/HandleWrapper.cmake index 2d254c2..367b6c3 100644 --- a/cmake/HandleWrapper.cmake +++ b/cmake/HandleWrapper.cmake @@ -33,6 +33,34 @@ function(set_python_target_properties PYTHON_TARGET OUTPUT_NAME OUTPUT_DIRECTORY ) endfunction() +# Derive a prefix-relative install root from the interpreter ABI selected by +# gtwrap. Requested versions may include a patch component and therefore cannot +# identify Python's major.minor site-packages directory. +function(_resolve_python_install_root OUT_VAR) + if(DEFINED Python_VERSION_MAJOR + AND NOT "${Python_VERSION_MAJOR}" STREQUAL "" + AND DEFINED Python_VERSION_MINOR + AND NOT "${Python_VERSION_MINOR}" STREQUAL "") + set(_python_resolved_version + "${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}") + elseif(DEFINED PYTHON_VERSION_MAJOR + AND NOT "${PYTHON_VERSION_MAJOR}" STREQUAL "" + AND DEFINED PYTHON_VERSION_MINOR + AND NOT "${PYTHON_VERSION_MINOR}" STREQUAL "") + set(_python_resolved_version + "${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}") + else() + message(FATAL_ERROR + "Cannot derive the Python install directory because the resolved " + "interpreter major and minor versions are unavailable.") + endif() + + set( + "${OUT_VAR}" + "${CMAKE_INSTALL_LIBDIR}/python${_python_resolved_version}/site-packages" + PARENT_SCOPE) +endfunction() + # Stage and install exact project-owned runtime targets beside a Python # extension. The returned string contains formatted Python list entries for # checkout-only wrapper metadata generated by the caller. @@ -1000,14 +1028,7 @@ namespace py = pybind11; # Keep CMake installs relocatable beneath CMAKE_INSTALL_PREFIX. Installing # into an active environment remains the responsibility of the pip target. - set(_python_install_root "python") - if(DEFINED WRAP_PYTHON_VERSION AND NOT "${WRAP_PYTHON_VERSION}" STREQUAL "") - set(_python_install_root - "${CMAKE_INSTALL_LIBDIR}/python${WRAP_PYTHON_VERSION}/site-packages") - elseif(DEFINED PYTHON_VERSION_MAJOR AND DEFINED PYTHON_VERSION_MINOR) - set(_python_install_root - "${CMAKE_INSTALL_LIBDIR}/python${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}/site-packages") - endif() + _resolve_python_install_root(_python_install_root) set(_python_package_install_destination "${_python_install_root}/${PROJECT_NAME}") diff --git a/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md b/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md index 24d2f1f..eef70fe 100644 --- a/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md +++ b/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md @@ -343,3 +343,68 @@ verifier) should be split by hunk when forming these commits. imports, and loader-relative paths passed. - Exact-index ShellCheck, `bash -n`, `git diff --cached --check`, index-tree equality, and four-file scope assertions: passed. + +## PR review remediation + +PR #28 received two current, unresolved packaging findings after the initial +`v1.12.0` tag was pushed. The release has not been published, so the tag may be +replaced after the accepted fixes are committed and final validation passes. +Each functional fix remains an independently staged, user-committed batch. + +### Stage 8: Install for the resolved Python ABI + +- [x] Derive the prefix-relative Python install root from the resolved + interpreter major and minor versions, never the requested version string. +- [x] Fail configuration clearly when the resolved ABI version is unavailable. +- [x] Add deterministic coverage where the requested version contains a patch + component but the install directory remains `python.`. +- [x] Validate, stage, and review only the implementation and regression test. +- [ ] Wait for the user to commit before starting Stage 9. + +### Stage 9: Reject flat runtime destination collisions + +- [ ] Detect different runtime targets that would stage to the same target-file + or SONAME destination. +- [ ] Report both conflicting targets and the destination name during + configuration. +- [ ] Add a negative fixture with two declared shared targets using the same + output name. +- [ ] Re-run the positive packaging fixture to preserve exact runtime packaging. +- [ ] Validate, stage, and review only the implementation and regression test. +- [ ] Wait for the user to commit before starting final acceptance. + +### Stage 10: Final acceptance and unpublished tag replacement + +- [ ] Run the focused packaging tests and complete repository validation + appropriate to the two fixes. +- [ ] Review the complete branch diff and record final evidence here. +- [ ] Confirm both fix commits and the development log are present at `HEAD`. +- [ ] Replace the unpublished remote `v1.12.0` tag with the validated `HEAD`. + +### Stage 8 output + +- Added `_resolve_python_install_root()` in `cmake/HandleWrapper.cmake`. It + accepts the modern `Python_VERSION_*` result variables and gtwrap's cached + `PYTHON_VERSION_*` compatibility variables, uses only major and minor, and + fails when neither resolved pair is available. +- Removed the requested-version and generic `python` install fallbacks from the + real gtwrap installation path. +- Extended the existing self-contained packaging verifier with a full + major.minor.patch request and an exact major.minor install-root assertion. +- Worktree packaging verification: passed. +- A real local-gtwrap configure with `WRAP_PYTHON_VERSION=3.12.3` generated + `lib/python3.12/site-packages` install rules: passed. +- A configure without resolved major/minor values failed with the intended + diagnostic: passed. +- Staged exactly `cmake/HandleWrapper.cmake` and + `tests/cmake/VerifyTemplateProjectPythonPackaging.cmake`; this development + log remains unstaged. +- Reviewed the complete staged diff. The existing module headers remain + accurate, and the new internal helper and regression block document the ABI + and requested-versus-resolved version invariant. +- Materialized exact index tree + `4ded0e4fac7a3ea9644533dd3f33d164d831dffb` at + `/tmp/cpp_cuda_template_index_stage8.gWmG7y`. +- Exact-index self-contained packaging verification and real local-gtwrap + configure with a full patch request: passed. +- `git diff --cached --check`: passed. diff --git a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake index 345cb23..a176ea6 100644 --- a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake +++ b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake @@ -163,8 +163,20 @@ set_python_target_properties( "fixture_package" "${_package_build_dir}") -set(_python_install_root +# A full requested version must not leak its patch component into the +# major.minor site-packages directory selected for the resolved interpreter. +set(WRAP_PYTHON_VERSION "${Python3_VERSION}") +set(Python_VERSION_MAJOR "${Python3_VERSION_MAJOR}") +set(Python_VERSION_MINOR "${Python3_VERSION_MINOR}") +_resolve_python_install_root(_python_install_root) +set(_expected_python_install_root "${CMAKE_INSTALL_LIBDIR}/python${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}/site-packages") +if(NOT "${_python_install_root}" STREQUAL "${_expected_python_install_root}") + message(FATAL_ERROR + "Resolved Python install root '${_python_install_root}' does not match " + "'${_expected_python_install_root}' for requested version " + "'${WRAP_PYTHON_VERSION}'.") +endif() set(_package_install_destination "${_python_install_root}/fixture_package") configure_python_runtime_artifacts( From d398eed6d28955ab6244da12a53d1da0c8a1a0d0 Mon Sep 17 00:00:00 2001 From: PeterC Date: Tue, 28 Jul 2026 12:49:36 +0200 Subject: [PATCH 8/9] [MAJOR] Simplify wrapper packaging and source release safety - Resolve runtime filenames at build time and reject flat-package collisions. - Split Python, MATLAB, and runtime-staging wrapper responsibilities. - Exclude only active or cache-proven build trees from source archives. - Preserve production wrapper modules through tailoring and update documentation. --- CMakeLists.txt | 117 +- README.md | 3 +- cmake/HandleMatlabWrapper.cmake | 53 + cmake/HandlePythonWrapper.cmake | 658 ++++++++++ cmake/HandleWrapper.cmake | 722 +---------- cmake/StagePythonRuntimeArtifacts.cmake | 148 +++ ...d_cleanup_wrapper_packaging_repair_plan.md | 4 + .../template_v2_test_ownership_plan.md | 1095 ++++++++--------- doc/template_usage.md | 9 +- doc/testing_and_ci.md | 3 + doc/wrappers.md | 30 +- tailor_template_cleanup.sh | 3 +- tests/cmake/VerifySourceReleaseArchive.cmake | 11 +- ...VerifyTemplateProjectPythonPackaging.cmake | 219 +++- .../VerifyTemplateProjectReleaseTagSync.cmake | 61 +- ...VerifyTemplateProjectTailoringScript.cmake | 30 + 16 files changed, 1852 insertions(+), 1314 deletions(-) create mode 100644 cmake/HandleMatlabWrapper.cmake create mode 100644 cmake/HandlePythonWrapper.cmake create mode 100644 cmake/StagePythonRuntimeArtifacts.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 53d1c8e..b4210a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -429,10 +429,9 @@ set(ANY_WRAPPER_ENABLED OFF) if(BUILD_AS_MAIN_PROJECT) set(LIBNAME_WRAP_TARGET ${project_name}) handle_gtwrappers() # You may also pass WRAP_INTERFACE_FILES variable to specify interface files, else autofetching is used - handle_pybind11_wrapper() # Determine if any wrapper is enabled - if (${GTWRAP_PYTHON_OPTION_NAME} OR PYBIND11_WRAPPER_ENABLED OR ${GTWRAP_MATLAB_OPTION_NAME}) + if(${GTWRAP_PYTHON_OPTION_NAME} OR ${GTWRAP_MATLAB_OPTION_NAME}) set(ANY_WRAPPER_ENABLED ON) endif() @@ -506,7 +505,6 @@ print_compiler_flags_summary() # Print info about wrappers if any if (ANY_WRAPPER_ENABLED) message(STATUS "================ Wrappers Configuration ======================") - message(STATUS "Python pybind11 direct wrapper : ${PYBIND11_WRAPPER_ENABLED}") message(STATUS "Python gtwrapper : ${${GTWRAP_PYTHON_OPTION_NAME}}") message(STATUS "MATLAB gtwrapper : ${${GTWRAP_MATLAB_OPTION_NAME}}") message(STATUS "MATLAB default release : ${MATLAB_DEFAULT_RELEASE}") @@ -547,8 +545,8 @@ if(DEFINED FULL_VERSION AND NOT "${FULL_VERSION}" STREQUAL "") endif() set(CPACK_GENERATOR "TGZ") set(CPACK_SOURCE_GENERATOR "TGZ") -# Preserve source-ignore regexes verbatim and anchor recursive generated-tree -# exclusions beneath this checkout rather than matching adjacent directories. +# Preserve source-ignore regexes verbatim and anchor known generated outputs +# beneath this checkout rather than matching adjacent directories. set(CPACK_VERBATIM_VARIABLES YES) set(_cpack_source_root_regex "${CMAKE_CURRENT_SOURCE_DIR}") string( @@ -556,10 +554,115 @@ string( _cpack_source_root_regex "${_cpack_source_root_regex}") set(CPACK_SOURCE_IGNORE_FILES "^${_cpack_source_root_regex}/(.*/)?\\.git(/|$)" - "^${_cpack_source_root_regex}/(.*/)?build[^/]*/" - "^${_cpack_source_root_regex}/(.*/)?install/" + "^${_cpack_source_root_regex}/build[^/]*/" + "^${_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 "^build[^/]*/" + OR "${_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() include(CPack) diff --git a/README.md b/README.md index 0172cc5..1faf6ca 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,8 @@ entrypoint. CMake updates it with: - generated `python/pyproject.toml` - generated `python/setup.py` -- generated `python//_wrapper_build.py` linking the latest wrapper build +- build-time `python//_wrapper_build.py` linking the latest + successfully staged wrapper configuration Install from the source Python package directory: diff --git a/cmake/HandleMatlabWrapper.cmake b/cmake/HandleMatlabWrapper.cmake new file mode 100644 index 0000000..ed8db9d --- /dev/null +++ b/cmake/HandleMatlabWrapper.cmake @@ -0,0 +1,53 @@ +# Configure gtwrap MATLAB bindings and their generated toolbox output. +# +# This module owns MATLAB discovery and wrapper generation; common gtwrap +# checkout and interface resolution remains in HandleWrapper.cmake. +include_guard(GLOBAL) + +# Configure the project's gtwrap-generated MATLAB wrapper. +function(configure_matlab_gtwrapper) + message(STATUS "Configuring MATLAB wrap...") + + if(NOT GTWRAP_INTERFACE_FILES) + message(FATAL_ERROR + "GTWRAP_INTERFACE_FILES is empty. Cannot build MATLAB wrapper.") + endif() + + if(NOT COMMAND wrap_and_install_library) + include(MatlabWrap) + endif() + + message(STATUS "Including MATLAB directories...") + find_package(Matlab REQUIRED) + set(MATLAB_MEX_INCLUDE "${Matlab_ROOT_DIR}/extern/include") + + message(STATUS "MATLAB_MEX_INCLUDE directory: ${MATLAB_MEX_INCLUDE}") + message(STATUS "Matlab_MEX_LIBRARY directory: ${Matlab_MEX_LIBRARY}") + message(STATUS "Matlab_MX_LIBRARY directory: ${Matlab_MX_LIBRARY}") + + include_directories(${Matlab_INCLUDE_DIRS}) + include_directories(${MATLAB_MEX_INCLUDE}) + if(DEFINED GTWRAP_INCLUDE_DIR) + include_directories(${GTWRAP_INCLUDE_DIR}) + endif() + + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/matlab") + file(MAKE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/matlab") + endif() + + set(WRAP_MEX_BUILD_STATIC_MODULE OFF) + set(WRAP_TOOLBOX_INSTALL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/matlab") + set(WRAP_BUILD_TYPE_POSTFIXES OFF) + + if(NOT DEFINED LIBNAME_WRAP_TARGET) + message(FATAL_ERROR + "LIBNAME_WRAP_TARGET is not defined. Cannot configure the MATLAB " + "wrapper.") + endif() + + message(STATUS "Using interface files: ${GTWRAP_INTERFACE_FILES}") + wrap_and_install_library( + "${GTWRAP_INTERFACE_FILES}" + "${LIBNAME_WRAP_TARGET}" + "" "" "" "" OFF) +endfunction() diff --git a/cmake/HandlePythonWrapper.cmake b/cmake/HandlePythonWrapper.cmake new file mode 100644 index 0000000..6a1be90 --- /dev/null +++ b/cmake/HandlePythonWrapper.cmake @@ -0,0 +1,658 @@ +# Configure gtwrap Python bindings and their relocatable native runtime package. +# +# This module owns Python target layout, prefix-relative installation, exact +# project-owned runtime declarations, and generation of the resolved artifact +# manifest consumed by StagePythonRuntimeArtifacts.cmake. +include_guard(GLOBAL) + +set( + _CPP_CUDA_TEMPLATE_PYTHON_STAGE_SCRIPT + "${CMAKE_CURRENT_LIST_DIR}/StagePythonRuntimeArtifacts.cmake") + +# Configure a Python extension to load co-located runtime libraries without +# embedding checkout or installation-prefix paths. +function(set_python_target_properties + PYTHON_TARGET + OUTPUT_NAME + OUTPUT_DIRECTORY) + if(APPLE) + set(_python_runtime_rpath "@loader_path") + elseif(UNIX) + set(_python_runtime_rpath "$ORIGIN") + else() + 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. + set_target_properties( + "${PYTHON_TARGET}" + PROPERTIES + BUILD_WITH_INSTALL_RPATH TRUE + INSTALL_RPATH "${_python_runtime_rpath}" + INSTALL_RPATH_USE_LINK_PATH FALSE + OUTPUT_NAME "${OUTPUT_NAME}" + LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_DIRECTORY}$<0:>" + RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_DIRECTORY}$<0:>" + DEBUG_POSTFIX "" + RELWITHDEBINFO_POSTFIX "" + TIMING_POSTFIX "" + PROFILING_POSTFIX "") +endfunction() + +# Derive a prefix-relative site-packages root from the resolved interpreter ABI. +function(_resolve_python_install_root OUT_VAR) + if(IS_ABSOLUTE "${CMAKE_INSTALL_LIBDIR}") + message(FATAL_ERROR + "CMAKE_INSTALL_LIBDIR must be relative when configuring Python " + "wrappers so CMAKE_INSTALL_PREFIX remains the install root. Got " + "'${CMAKE_INSTALL_LIBDIR}'.") + endif() + + if(DEFINED Python_VERSION_MAJOR + AND NOT "${Python_VERSION_MAJOR}" STREQUAL "" + AND DEFINED Python_VERSION_MINOR + AND NOT "${Python_VERSION_MINOR}" STREQUAL "") + set(_python_resolved_version + "${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}") + elseif(DEFINED PYTHON_VERSION_MAJOR + AND NOT "${PYTHON_VERSION_MAJOR}" STREQUAL "" + AND DEFINED PYTHON_VERSION_MINOR + AND NOT "${PYTHON_VERSION_MINOR}" STREQUAL "") + set(_python_resolved_version + "${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}") + else() + message(FATAL_ERROR + "Cannot derive the Python install directory because the resolved " + "interpreter major and minor versions are unavailable.") + endif() + + set( + "${OUT_VAR}" + "${CMAKE_INSTALL_LIBDIR}/python${_python_resolved_version}/site-packages" + PARENT_SCOPE) +endfunction() + +# Configure one collision-safe staging operation for a Python extension. +# +# PYTHON_TARGET is the extension whose resolved filename reserves the package +# namespace. STAGING_DIRECTORY receives declared runtimes, INSTALL_DESTINATION +# receives the same target-aware CMake installs, and METADATA_FILE links direct +# checkout imports and wheel construction to the latest successful staging run. +function(configure_python_runtime_artifacts + PYTHON_TARGET + STAGING_DIRECTORY + INSTALL_DESTINATION + METADATA_FILE) + if(NOT TARGET "${PYTHON_TARGET}") + message(FATAL_ERROR + "Python wrapper target '${PYTHON_TARGET}' does not exist.") + endif() + if("${METADATA_FILE}" STREQUAL "") + message(FATAL_ERROR + "Python wrapper target '${PYTHON_TARGET}' requires a metadata file.") + endif() + + if(APPLE) + set(_python_dependency_rpath "@loader_path") + elseif(UNIX) + set(_python_dependency_rpath "$ORIGIN") + else() + set(_python_dependency_rpath "") + endif() + + set(_python_runtime_targets ${ARGN}) + list(REMOVE_DUPLICATES _python_runtime_targets) + set(_python_manifest_content +"set(PYTHON_WRAPPER_OWNER [==[${PYTHON_TARGET}]==]) +set(PYTHON_WRAPPER_PATH [==[$]==]) +set(PYTHON_WRAPPER_NAME [==[$]==]) +set(PYTHON_STAGING_DIRECTORY [==[${STAGING_DIRECTORY}]==]) +set(PYTHON_METADATA_FILE [==[${METADATA_FILE}]==]) +set(PYTHON_RUNTIME_OWNERS) +set(PYTHON_RUNTIME_SOURCES) +set(PYTHON_RUNTIME_NAMES) +") + + foreach(_python_runtime_target IN LISTS _python_runtime_targets) + if(NOT TARGET "${_python_runtime_target}") + message(FATAL_ERROR + "Python runtime dependency target '${_python_runtime_target}' does not " + "exist.") + endif() + + get_target_property( + _python_runtime_aliased_target + "${_python_runtime_target}" + ALIASED_TARGET) + if(_python_runtime_aliased_target) + message(FATAL_ERROR + "Python runtime dependency target '${_python_runtime_target}' is an " + "alias. Declare its project-owned target directly.") + endif() + + get_target_property( + _python_runtime_imported + "${_python_runtime_target}" + IMPORTED) + if(_python_runtime_imported) + message(FATAL_ERROR + "Python runtime dependency target '${_python_runtime_target}' is " + "imported. Only project-owned targets can be packaged.") + endif() + + get_target_property( + _python_runtime_type + "${_python_runtime_target}" + TYPE) + if(NOT _python_runtime_type STREQUAL "SHARED_LIBRARY" + AND NOT _python_runtime_type STREQUAL "MODULE_LIBRARY") + message(FATAL_ERROR + "Python runtime dependency target '${_python_runtime_target}' has " + "unsupported type '${_python_runtime_type}'. Expected SHARED_LIBRARY " + "or MODULE_LIBRARY.") + endif() + + # CMake resolves the platform, configuration, prefix, suffix, version, and + # generator-expression rules embedded in these manifest entries. + string(APPEND _python_manifest_content +"list(APPEND PYTHON_RUNTIME_OWNERS [==[${_python_runtime_target}]==]) +list(APPEND PYTHON_RUNTIME_SOURCES [==[$]==]) +list(APPEND PYTHON_RUNTIME_NAMES [==[$]==]) +") + + get_target_property( + _python_runtime_soversion + "${_python_runtime_target}" + SOVERSION) + get_target_property( + _python_runtime_no_soname + "${_python_runtime_target}" + NO_SONAME) + if(UNIX + AND NOT _python_runtime_soversion STREQUAL + "_python_runtime_soversion-NOTFOUND" + AND NOT _python_runtime_no_soname) + string(APPEND _python_manifest_content +"list(APPEND PYTHON_RUNTIME_OWNERS [==[${_python_runtime_target}]==]) +list(APPEND PYTHON_RUNTIME_SOURCES [==[$]==]) +list(APPEND PYTHON_RUNTIME_NAMES [==[$]==]) +") + endif() + endforeach() + + # All packaged runtimes use the same loader-relative search contract and + # target-aware install operation. + if(_python_runtime_targets) + set_target_properties( + ${_python_runtime_targets} + PROPERTIES + BUILD_WITH_INSTALL_RPATH TRUE + INSTALL_RPATH "${_python_dependency_rpath}" + INSTALL_RPATH_USE_LINK_PATH FALSE) + install( + TARGETS ${_python_runtime_targets} + LIBRARY DESTINATION "${INSTALL_DESTINATION}" NAMELINK_SKIP + RUNTIME DESTINATION "${INSTALL_DESTINATION}") + endif() + + string( + MAKE_C_IDENTIFIER + "${PYTHON_TARGET}_python_runtime_stage" + _python_runtime_stage_target) + if(TARGET "${_python_runtime_stage_target}") + message(FATAL_ERROR + "Python runtime staging target '${_python_runtime_stage_target}' already " + "exists.") + endif() + + set(_python_manifest_directory + "${CMAKE_CURRENT_BINARY_DIR}/python_runtime_manifests") + set(_python_manifest_file + "${_python_manifest_directory}/${_python_runtime_stage_target}_$.cmake") + file(GENERATE + OUTPUT "${_python_manifest_file}" + CONTENT "${_python_manifest_content}") + + # Always run staging when the wrapper target is requested so an unlinked, + # explicitly declared runtime refreshes without forcing the extension to + # relink. Runtime target dependencies guarantee the manifest sources exist. + add_custom_target( + "${_python_runtime_stage_target}" + COMMAND + "${CMAKE_COMMAND}" + "-DMANIFEST_FILE=${_python_manifest_file}" + -P "${_CPP_CUDA_TEMPLATE_PYTHON_STAGE_SCRIPT}" + DEPENDS ${_python_runtime_targets} + COMMENT "Validating and staging Python runtime artifacts" + VERBATIM) + add_dependencies("${PYTHON_TARGET}" "${_python_runtime_stage_target}") + + # Prevent a stale link file from surviving a fresh configuration whose + # wrapper target has not yet completed staging. + file(REMOVE "${METADATA_FILE}") +endfunction() + +# Configure the project's generated extension, source package, tests, and +# installation targets after common gtwrap resolution has completed. +function(configure_python_gtwrapper) + message(STATUS "Configuring Python wrap...") + + if(NOT GTWRAP_INTERFACE_FILES) + message(FATAL_ERROR + "GTWRAP_INTERFACE_FILES is empty. Cannot build Python wrapper.") + endif() + + if(NOT COMMAND pybind_wrap) + include(PybindWrap) + endif() + + # Select the package root used by gtwrap's Python custom commands. + set(_gtwrap_package_dir "") + if(DEFINED GTWRAP_PACKAGE_DIR AND NOT "${GTWRAP_PACKAGE_DIR}" STREQUAL "") + set(_gtwrap_package_dir "${GTWRAP_PACKAGE_DIR}") + elseif(DEFINED GTWRAP_PYTHON_PACKAGE_DIR AND + NOT "${GTWRAP_PYTHON_PACKAGE_DIR}" STREQUAL "") + set(_gtwrap_package_dir "${GTWRAP_PYTHON_PACKAGE_DIR}") + elseif(DEFINED GTWRAP_ROOT_DIR AND NOT "${GTWRAP_ROOT_DIR}" STREQUAL "") + set(_gtwrap_package_dir "${GTWRAP_ROOT_DIR}") + endif() + + if(NOT "${_gtwrap_package_dir}" STREQUAL "") + set(GTWRAP_PACKAGE_DIR "${_gtwrap_package_dir}" CACHE INTERNAL + "Path used by gtwrap pybind custom commands for PYTHONPATH." FORCE) + set(GTWRAP_PACKAGE_DIR "${_gtwrap_package_dir}") + endif() + + # Reuse an existing pybind11 target or discover its package configuration. + if(NOT COMMAND pybind11_add_module AND + NOT TARGET pybind11_headers AND + NOT TARGET pybind11::headers AND + NOT TARGET pybind11::module) + find_package(pybind11 CONFIG QUIET) + endif() + + # Provide the narrow target-construction command expected by older gtwrap + # checkouts when their bundled pybind11 does not export it. + if(NOT COMMAND pybind11_add_module) + # Create one extension target and attach whichever pybind11 and Python + # module targets are available in the current dependency layout. + function(pybind11_add_module target_name) + add_library(${target_name} MODULE ${ARGN}) + set_target_properties(${target_name} PROPERTIES PREFIX "") + + if(TARGET pybind11::module) + target_link_libraries(${target_name} PRIVATE pybind11::module) + elseif(TARGET pybind11::pybind11) + target_link_libraries(${target_name} PRIVATE pybind11::pybind11) + elseif(DEFINED GTWRAP_ROOT_DIR + AND EXISTS "${GTWRAP_ROOT_DIR}/pybind11/include") + target_include_directories( + ${target_name} + PRIVATE "${GTWRAP_ROOT_DIR}/pybind11/include") + endif() + + if(TARGET Python::Module) + target_link_libraries(${target_name} PRIVATE Python::Module) + elseif(TARGET Python3::Module) + target_link_libraries(${target_name} PRIVATE Python3::Module) + endif() + endfunction() + endif() + + if(NOT COMMAND pybind11_add_module) + message(FATAL_ERROR + "pybind11_add_module is unavailable. Ensure pybind11 is loaded from the " + "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. + 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_WRAPPER_LINK_FILE + "${PROJECT_PYTHON_PACKAGE_DIR}/_wrapper_build.py") + set(PROJECT_PYTHON_TARGET_NAME "${LIB_NAMESPACE}_py") + set( + ${PROJECT_NAME}_PYTHON_WRAPPER_TARGET + "${PROJECT_PYTHON_TARGET_NAME}" + CACHE INTERNAL + "Resolved Python wrapper target name for the project." FORCE) + + 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}") + endif() + + if(NOT EXISTS "${PROJECT_PYTHON_PACKAGE_DIR}/__init__.py") + string(CONFIGURE [=[ +"""Python package entrypoint for @PROJECT_NAME@ bindings.""" + +from __future__ import annotations + +HAS_WRAPPER = False +WRAPPER_IMPORT_ERROR: ImportError | None = None + +try: + from .@PROJECT_NAME@ import * # noqa: F401,F403 +except ImportError as exc: + WRAPPER_IMPORT_ERROR = exc +else: + HAS_WRAPPER = True +]=] _default_python_package_init @ONLY) + file(WRITE + "${PROJECT_PYTHON_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. + set(_pyproject_template "${PROJECT_PYTHON_SOURCE_DIR}/pyproject.toml.in") + + if(NOT EXISTS "${_pyproject_template}") + message(WARNING + "Missing python/pyproject.toml.in. Generating a minimal fallback " + "template.") + set(_pyproject_template + "${PROJECT_BINARY_DIR}/python/pyproject.toml.in.fallback") + file(WRITE "${_pyproject_template}" [=[ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "@PROJECT_NAME@" +version = "@PROJECT_VERSION@" +description = "Python bindings for @PROJECT_NAME@" +requires-python = ">=3.8" + +[tool.setuptools] +packages = ["@PROJECT_NAME@"] +include-package-data = true + +[tool.setuptools.package-data] +"@PROJECT_NAME@" = ["*.so", "*.pyd", "*.dylib", "*.pyi", "**/*.pyi"] +]=]) + endif() + + configure_file( + "${_pyproject_template}" + "${PROJECT_PYTHON_SOURCE_METADATA_FILE}" + @ONLY) + + # Keep setup.py as a compatibility entrypoint for tooling that has not moved + # fully to pyproject.toml. + set(_setup_py_template "${PROJECT_PYTHON_SOURCE_DIR}/setup.py.in") + if(EXISTS "${_setup_py_template}") + configure_file( + "${_setup_py_template}" + "${PROJECT_PYTHON_SOURCE_SETUP_FILE}" + @ONLY) + else() + set(_generated_setup_py_template + "${PROJECT_BINARY_DIR}/python/setup.py.in.fallback") + file(WRITE "${_generated_setup_py_template}" [=[ +from setuptools import setup + +setup(zip_safe=False) +]=]) + configure_file( + "${_generated_setup_py_template}" + "${PROJECT_PYTHON_SOURCE_SETUP_FILE}" + @ONLY) + endif() + + # Modern pybind11 uses IN_LIST and therefore requires CMP0057's new behavior. + if(POLICY CMP0057) + cmake_policy(SET CMP0057 NEW) + endif() + + set(_top_namespace "${GTWRAP_TOP_NAMESPACE}") + if("${_top_namespace}" STREQUAL "") + set(_top_namespace "${PROJECT_NAME}") + endif() + + set(_link_libs "${LIBNAME_WRAP_TARGET}") + set(_wrapper_dependencies "${LIBNAME_WRAP_TARGET}") + + if(GTWRAP_EXTRA_DEPENDENCY_TARGETS) + list(APPEND _wrapper_dependencies ${GTWRAP_EXTRA_DEPENDENCY_TARGETS}) + endif() + + list(REMOVE_DUPLICATES _wrapper_dependencies) + + # Seed optional customization headers expected by gtwrap without writing + # generated hooks into the source tree. + set(_pywrap_codegen_root "${PROJECT_BINARY_DIR}/${PROJECT_NAME}") + file(MAKE_DIRECTORY "${_pywrap_codegen_root}/specializations") + file(MAKE_DIRECTORY "${_pywrap_codegen_root}/preamble") + + foreach(_interface_file IN LISTS GTWRAP_INTERFACE_FILES) + get_filename_component(_interface_name "${_interface_file}" NAME_WE) + set(_spec_header + "${_pywrap_codegen_root}/specializations/${_interface_name}.h") + set(_preamble_header + "${_pywrap_codegen_root}/preamble/${_interface_name}.h") + if(NOT EXISTS "${_spec_header}") + file(WRITE + "${_spec_header}" + "// Optional pybind specialization hooks for ${_interface_name}.\n") + endif() + if(NOT EXISTS "${_preamble_header}") + file(WRITE + "${_preamble_header}" + "// Optional pybind preamble hooks for ${_interface_name}.\n") + endif() + endforeach() + + # Prefer a project-owned module template and otherwise provide the generic + # gtwrap expansion points. + set(_pybind_module_template "${PROJECT_BINARY_DIR}/${PROJECT_NAME}.tpl") + if(EXISTS "${PROJECT_SOURCE_DIR}/python/${PROJECT_NAME}.tpl") + configure_file( + "${PROJECT_SOURCE_DIR}/python/${PROJECT_NAME}.tpl" + "${_pybind_module_template}" + COPYONLY) + elseif(EXISTS "${PROJECT_SOURCE_DIR}/${PROJECT_NAME}.tpl") + configure_file( + "${PROJECT_SOURCE_DIR}/${PROJECT_NAME}.tpl" + "${_pybind_module_template}" + COPYONLY) + else() + file(WRITE "${_pybind_module_template}" [=[ +#include +#include +#include +#include +#include +#include + +{includes} + +{boost_class_export} + +using namespace std; + +namespace py = pybind11; + +{submodules} + +{module_def} {{ + m_.doc() = "pybind11 wrapper of {module_name}"; + +{submodules_init} + +{wrapped_namespace} + +}} +]=]) + endif() + + set(ENABLE_BOOST_SERIALIZATION OFF) + + # Derive gtwrap's generated translation unit from the leading interface. + list(GET GTWRAP_INTERFACE_FILES 0 _main_interface_file) + get_filename_component( + _main_interface_name + "${_main_interface_file}" + NAME_WE) + set(_main_interface_cpp "${_main_interface_name}.cpp") + set(GTWRAP_PYTHON_GENERATED_CPP_DIR "python") + + # Couple optional generated docstrings to the owning Doxygen XML target. + if(DEFINED GTWRAP_ADD_DOCSTRINGS AND GTWRAP_ADD_DOCSTRINGS) + if(DEFINED BUILD_DOC_XML + AND BUILD_DOC_XML + AND DEFINED ${PROJECT_NAME}_DOXYGEN_XML_DIR) + set( + GTWRAP_PYTHON_DOCS_SOURCE + "${${PROJECT_NAME}_DOXYGEN_XML_DIR}" + CACHE PATH + "Doxygen XML directory used for gtwrap Python docstrings." FORCE) + message(STATUS + "Python wrapper docstrings use project Doxygen XML: " + "${GTWRAP_PYTHON_DOCS_SOURCE}") + else() + message(WARNING + "GTWRAP_ADD_DOCSTRINGS=ON but BUILD_DOC_XML is not enabled or Doxygen " + "XML is unavailable. Generated Python wrappers will not receive " + "project Doxygen docstrings.") + endif() + endif() + + pybind_wrap( + ${PROJECT_PYTHON_TARGET_NAME} + "${GTWRAP_INTERFACE_FILES}" + "${_main_interface_cpp}" + "${PROJECT_NAME}" + "${_top_namespace}" + "" + "${_pybind_module_template}" + "${_link_libs}" + "${_wrapper_dependencies}" + ${ENABLE_BOOST_SERIALIZATION}) + + if(DEFINED GTWRAP_ADD_DOCSTRINGS AND GTWRAP_ADD_DOCSTRINGS AND + TARGET "pybind_wrap_${PROJECT_NAME}" AND TARGET "${LIB_NAMESPACE}_doc") + add_dependencies("pybind_wrap_${PROJECT_NAME}" "${LIB_NAMESPACE}_doc") + endif() + + # Place the extension and declared native runtimes in one relocatable package + # namespace before configuring install and developer targets. + set_python_target_properties( + ${PROJECT_PYTHON_TARGET_NAME} + "${PROJECT_NAME}" + "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}") + target_include_directories(${PROJECT_PYTHON_TARGET_NAME} + PRIVATE + "${PROJECT_BINARY_DIR}" + "${PROJECT_PYTHON_BUILD_DIRECTORY}") + + # Keep CMake installs relocatable beneath CMAKE_INSTALL_PREFIX. Installing + # into an active environment remains the responsibility of the pip target. + _resolve_python_install_root(_python_install_root) + set(_python_package_install_destination + "${_python_install_root}/${PROJECT_NAME}") + + # A shared main library is always a Python runtime artifact. Static and + # interface targets are already linked into the extension and need no file. + set(_python_runtime_targets) + get_target_property( + _python_main_runtime_target_type + "${LIBNAME_WRAP_TARGET}" + TYPE) + if(_python_main_runtime_target_type STREQUAL "SHARED_LIBRARY" + OR _python_main_runtime_target_type STREQUAL "MODULE_LIBRARY") + list(APPEND _python_runtime_targets "${LIBNAME_WRAP_TARGET}") + endif() + if(GTWRAP_RUNTIME_DEPENDENCY_TARGETS) + list(APPEND + _python_runtime_targets + ${GTWRAP_RUNTIME_DEPENDENCY_TARGETS}) + endif() + + configure_python_runtime_artifacts( + "${PROJECT_PYTHON_TARGET_NAME}" + "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}" + "${_python_package_install_destination}" + "${PROJECT_PYTHON_WRAPPER_LINK_FILE}" + ${_python_runtime_targets}) + + # Exercise direct checkout import 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 + "import ${PROJECT_NAME} as module_; assert getattr(module_, 'HAS_WRAPPER', False), 'Expected HAS_WRAPPER=True';") + add_test( + NAME ${_python_import_test_name} + COMMAND + ${CMAKE_COMMAND} -E env + "PYTHONPATH=${PROJECT_PYTHON_SOURCE_DIR}:$ENV{PYTHONPATH}" + ${PYTHON_EXECUTABLE} -c "${_python_import_test_code}") + set_tests_properties( + ${_python_import_test_name} + PROPERTIES + WORKING_DIRECTORY "${PROJECT_PYTHON_SOURCE_DIR}") + endif() + + install( + TARGETS ${PROJECT_PYTHON_TARGET_NAME} + LIBRARY DESTINATION "${_python_package_install_destination}" + RUNTIME DESTINATION "${_python_package_install_destination}") + + install( + DIRECTORY "${PROJECT_PYTHON_PACKAGE_DIR}/" + DESTINATION "${_python_package_install_destination}" + PATTERN "_wrapper_build.py" EXCLUDE + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE) + + install( + FILES "${PROJECT_PYTHON_SOURCE_METADATA_FILE}" + DESTINATION "${_python_install_root}") + + # Install the source 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( + "${_python_pip_install_target}" + COMMAND + ${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}" + VERBATIM) + endif() + + if(BUILD_AS_MAIN_PROJECT AND NOT TARGET python-install) + add_custom_target(python-install DEPENDS ${_python_pip_install_target}) + endif() + + # Generate stubs from the same checkout 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}" + ${PYTHON_EXECUTABLE} -m pybind11_stubgen ${PROJECT_NAME} -o . + DEPENDS "${PROJECT_PYTHON_TARGET_NAME}" + WORKING_DIRECTORY "${PROJECT_PYTHON_SOURCE_DIR}" + VERBATIM) + endif() + + if(BUILD_AS_MAIN_PROJECT AND NOT TARGET python-stubs) + add_custom_target(python-stubs DEPENDS "${_python_stubs_target}") + endif() +endfunction() diff --git a/cmake/HandleWrapper.cmake b/cmake/HandleWrapper.cmake index 367b6c3..769d275 100644 --- a/cmake/HandleWrapper.cmake +++ b/cmake/HandleWrapper.cmake @@ -1,208 +1,15 @@ -# Configure Python and MATLAB wrapper discovery, generation, installation, and -# relocatable Python runtime packaging. Generated build-link metadata remains -# checkout-only; installed packages contain only declared native artifacts. +# Resolve gtwrap interfaces and coordinate optional Python and MATLAB wrappers. +# +# This facade owns wrapper checkout discovery, synchronization, and common +# interface configuration. Language-specific target and packaging behavior lives +# in HandlePythonWrapper.cmake and HandleMatlabWrapper.cmake. include_guard(GLOBAL) include(ExternalProject) +include("${CMAKE_CURRENT_LIST_DIR}/HandlePythonWrapper.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/HandleMatlabWrapper.cmake") -# Configure a Python extension to load co-located runtime libraries without -# embedding checkout or install-prefix paths. -function(set_python_target_properties PYTHON_TARGET OUTPUT_NAME OUTPUT_DIRECTORY) - if(APPLE) - set(_python_runtime_rpath "@loader_path") - elseif(UNIX) - set(_python_runtime_rpath "$ORIGIN") - else() - set(_python_runtime_rpath "") - endif() - - # The no-op generator expression suppresses automatic configuration - # subdirectories under multi-config generators, keeping one stable package - # directory for metadata, staged runtimes, and the extension. - set_target_properties(${PYTHON_TARGET} PROPERTIES - BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "${_python_runtime_rpath}" - INSTALL_RPATH_USE_LINK_PATH FALSE - OUTPUT_NAME "${OUTPUT_NAME}" - LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_DIRECTORY}$<0:>" - RUNTIME_OUTPUT_DIRECTORY "${OUTPUT_DIRECTORY}$<0:>" - DEBUG_POSTFIX "" - RELWITHDEBINFO_POSTFIX "" - TIMING_POSTFIX "" - PROFILING_POSTFIX "" - ) -endfunction() - -# Derive a prefix-relative install root from the interpreter ABI selected by -# gtwrap. Requested versions may include a patch component and therefore cannot -# identify Python's major.minor site-packages directory. -function(_resolve_python_install_root OUT_VAR) - if(DEFINED Python_VERSION_MAJOR - AND NOT "${Python_VERSION_MAJOR}" STREQUAL "" - AND DEFINED Python_VERSION_MINOR - AND NOT "${Python_VERSION_MINOR}" STREQUAL "") - set(_python_resolved_version - "${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}") - elseif(DEFINED PYTHON_VERSION_MAJOR - AND NOT "${PYTHON_VERSION_MAJOR}" STREQUAL "" - AND DEFINED PYTHON_VERSION_MINOR - AND NOT "${PYTHON_VERSION_MINOR}" STREQUAL "") - set(_python_resolved_version - "${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR}") - else() - message(FATAL_ERROR - "Cannot derive the Python install directory because the resolved " - "interpreter major and minor versions are unavailable.") - endif() - - set( - "${OUT_VAR}" - "${CMAKE_INSTALL_LIBDIR}/python${_python_resolved_version}/site-packages" - PARENT_SCOPE) -endfunction() - -# Stage and install exact project-owned runtime targets beside a Python -# extension. The returned string contains formatted Python list entries for -# checkout-only wrapper metadata generated by the caller. -function(configure_python_runtime_artifacts - PYTHON_TARGET - STAGING_DIRECTORY - INSTALL_DESTINATION - OUT_METADATA_ENTRIES) - if(NOT TARGET "${PYTHON_TARGET}") - message(FATAL_ERROR - "Python wrapper target '${PYTHON_TARGET}' does not exist.") - endif() - - if(APPLE) - set(_python_dependency_rpath "@loader_path") - elseif(UNIX) - set(_python_dependency_rpath "$ORIGIN") - else() - set(_python_dependency_rpath "") - endif() - - set(_python_runtime_targets ${ARGN}) - list(REMOVE_DUPLICATES _python_runtime_targets) - set(_python_runtime_metadata_entries "") - - foreach(_python_runtime_target IN LISTS _python_runtime_targets) - if(NOT TARGET "${_python_runtime_target}") - message(FATAL_ERROR - "Python runtime dependency target '${_python_runtime_target}' does not exist.") - endif() - - get_target_property( - _python_runtime_target_imported - "${_python_runtime_target}" - IMPORTED) - if(_python_runtime_target_imported) - message(FATAL_ERROR - "Python runtime dependency target '${_python_runtime_target}' is imported. " - "Only project-owned targets can be packaged.") - endif() - - get_target_property( - _python_runtime_target_type - "${_python_runtime_target}" - TYPE) - if(NOT _python_runtime_target_type STREQUAL "SHARED_LIBRARY" - AND NOT _python_runtime_target_type STREQUAL "MODULE_LIBRARY") - message(FATAL_ERROR - "Python runtime dependency target '${_python_runtime_target}' has " - "unsupported type '${_python_runtime_target_type}'. " - "Expected SHARED_LIBRARY or MODULE_LIBRARY.") - endif() - - # Give each packaged library the same loader-relative dependency contract - # as the extension that loads it. - set_target_properties( - "${_python_runtime_target}" - PROPERTIES - BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "${_python_dependency_rpath}" - INSTALL_RPATH_USE_LINK_PATH FALSE) - - # Stage the complete runtime file under its target filename. A versioned - # library also needs a file under its SONAME because that is what dependents - # request from the dynamic loader. - set(_python_staged_target_file - "${STAGING_DIRECTORY}/$") - set( - _python_runtime_stage_commands - COMMAND - "${CMAKE_COMMAND}" -E make_directory - "${STAGING_DIRECTORY}" - COMMAND - "${CMAKE_COMMAND}" -E copy_if_different - "$" - "${_python_staged_target_file}") - string(APPEND - _python_runtime_metadata_entries - " r\"${_python_staged_target_file}\",\n") - - get_target_property( - _python_runtime_soversion - "${_python_runtime_target}" - SOVERSION) - get_target_property( - _python_runtime_no_soname - "${_python_runtime_target}" - NO_SONAME) - if(UNIX - AND NOT _python_runtime_soversion STREQUAL "_python_runtime_soversion-NOTFOUND" - AND NOT _python_runtime_no_soname) - set(_python_staged_soname_file - "${STAGING_DIRECTORY}/$") - list( - APPEND - _python_runtime_stage_commands - COMMAND - "${CMAKE_COMMAND}" -E copy_if_different - "$" - "${_python_staged_soname_file}") - string(APPEND - _python_runtime_metadata_entries - " r\"${_python_staged_soname_file}\",\n") - endif() - - # An always-run staging target refreshes copies when an explicitly - # packaged runtime changes without forcing the wrapper itself to relink. - string( - MAKE_C_IDENTIFIER - "${PYTHON_TARGET}_${_python_runtime_target}_python_runtime_stage" - _python_runtime_stage_target) - if(TARGET "${_python_runtime_stage_target}") - message(FATAL_ERROR - "Python runtime staging target '${_python_runtime_stage_target}' " - "already exists.") - endif() - add_custom_target( - "${_python_runtime_stage_target}" - ${_python_runtime_stage_commands} - COMMENT - "Staging Python runtime target ${_python_runtime_target}" - VERBATIM) - add_dependencies( - "${_python_runtime_stage_target}" - "${_python_runtime_target}") - add_dependencies("${PYTHON_TARGET}" "${_python_runtime_stage_target}") - - # Reuse CMake's target-aware install logic so versioned runtime files and - # platform-specific DLL artifacts retain their expected names. - install( - TARGETS "${_python_runtime_target}" - LIBRARY DESTINATION "${INSTALL_DESTINATION}" NAMELINK_SKIP - RUNTIME DESTINATION "${INSTALL_DESTINATION}") - endforeach() - - set( - "${OUT_METADATA_ENTRIES}" - "${_python_runtime_metadata_entries}" - PARENT_SCOPE) -endfunction() - -# Function to check validity of interface files list +# Return whether every supplied wrapper interface is an existing `.i` file. function(check_interface_files_validity VALIDITY_BOOL) set(_interface_files ${ARGN}) set(_is_valid TRUE) @@ -226,7 +33,7 @@ function(check_interface_files_validity VALIDITY_BOOL) set(${VALIDITY_BOOL} ${_is_valid} PARENT_SCOPE) endfunction() -# Function to resolve wrap dependency +# Resolve an explicit or conventional project-local gtwrap checkout. function(resolve_local_wrap_root OUT_VAR) set(_preferred_root "") if(ARGC GREATER 1) @@ -254,7 +61,7 @@ function(resolve_local_wrap_root OUT_VAR) set(${OUT_VAR} "" PARENT_SCOPE) endfunction() -# Check if wrap submodule is already in place +# Initialize or add the configured gtwrap submodule when policy permits it. function(maybe_init_wrap_submodule OUT_VAR) set(${OUT_VAR} "" PARENT_SCOPE) @@ -352,7 +159,7 @@ function(maybe_init_wrap_submodule OUT_VAR) endif() endfunction() -# Force repo checkout if found locally +# Fast-forward a Git-backed local gtwrap checkout to one remote branch. function(sync_wrap_checkout WRAP_ROOT BRANCH) if(NOT EXISTS "${WRAP_ROOT}/.git") return() @@ -429,8 +236,7 @@ function(sync_wrap_checkout WRAP_ROOT BRANCH) endif() endfunction() -### Python and MATLAB wrapper configuration using gtwrap -# Function for common wrapper configuration +# Resolve shared interface, checkout, dependency, and namespace configuration. function(configure_gtwrappers_common) set(_gtwrap_python_option_name "${LIB_NAMESPACE}_BUILD_PYTHON_WRAPPER") set(_gtwrap_matlab_option_name "${LIB_NAMESPACE}_BUILD_MATLAB_WRAPPER") @@ -545,6 +351,23 @@ function(configure_gtwrappers_common) set(WRAP_PYTHON_VERSION ${PROJECT_PYTHON_VERSION} CACHE STRING "The Python version to use for wrapping") + # Resolve one executable for both wrapper frontends. A normal variable takes + # precedence over the legacy cache entry populated by some gtwrap versions. + if(DEFINED Python_EXECUTABLE AND NOT "${Python_EXECUTABLE}" STREQUAL "") + set(_gtwrap_python_executable "${Python_EXECUTABLE}") + else() + get_property( + _gtwrap_python_executable + CACHE PYTHON_EXECUTABLE + PROPERTY VALUE) + endif() + if(NOT "${_gtwrap_python_executable}" STREQUAL "") + set( + PYTHON_EXECUTABLE + "${_gtwrap_python_executable}" + PARENT_SCOPE) + endif() + set(_configured_wrap_root "${${_gtwrap_root_var_name}}") if(NOT "${_configured_wrap_root}" STREQUAL "" AND NOT EXISTS "${_configured_wrap_root}/cmake/PybindWrap.cmake") @@ -719,486 +542,7 @@ function(configure_gtwrappers_common) PARENT_SCOPE) endfunction() -# Python wrapper configuration using gtwrap -function(configure_python_gtwrapper) - message(STATUS "Configuring Python wrap...") - - if(NOT GTWRAP_INTERFACE_FILES) - message(FATAL_ERROR "GTWRAP_INTERFACE_FILES is empty. Cannot build Python wrapper.") - endif() - - if(NOT COMMAND pybind_wrap) - include(PybindWrap) - endif() - - # Resolve Python executable for gtwrap custom commands, with preference for user override - set(_resolved_python_executable "") - if(DEFINED Python_EXECUTABLE AND NOT "${Python_EXECUTABLE}" STREQUAL "") - set(_resolved_python_executable "${Python_EXECUTABLE}") - else() - get_property(_cached_python_executable CACHE PYTHON_EXECUTABLE PROPERTY VALUE) - if(NOT "${_cached_python_executable}" STREQUAL "") - set(_resolved_python_executable "${_cached_python_executable}") - endif() - endif() - if(NOT "${_resolved_python_executable}" STREQUAL "") - set(PYTHON_EXECUTABLE "${_resolved_python_executable}") - endif() - - # Set gtwrap directory to use - set(_gtwrap_package_dir "") - if(DEFINED GTWRAP_PACKAGE_DIR AND NOT "${GTWRAP_PACKAGE_DIR}" STREQUAL "") - set(_gtwrap_package_dir "${GTWRAP_PACKAGE_DIR}") - elseif(DEFINED GTWRAP_PYTHON_PACKAGE_DIR AND - NOT "${GTWRAP_PYTHON_PACKAGE_DIR}" STREQUAL "") - set(_gtwrap_package_dir "${GTWRAP_PYTHON_PACKAGE_DIR}") - elseif(DEFINED GTWRAP_ROOT_DIR AND NOT "${GTWRAP_ROOT_DIR}" STREQUAL "") - set(_gtwrap_package_dir "${GTWRAP_ROOT_DIR}") - endif() - - if(NOT "${_gtwrap_package_dir}" STREQUAL "") - set(GTWRAP_PACKAGE_DIR "${_gtwrap_package_dir}" CACHE INTERNAL - "Path used by gtwrap pybind custom commands for PYTHONPATH." FORCE) - set(GTWRAP_PACKAGE_DIR "${_gtwrap_package_dir}") - endif() - - # Ensure pybind11 is available if targets are not found in the current build - if(NOT COMMAND pybind11_add_module AND - NOT TARGET pybind11_headers AND - NOT TARGET pybind11::headers AND - NOT TARGET pybind11::module) - find_package(pybind11 CONFIG QUIET) - endif() - - # Define command to generate Python wrapper using gtwrap's pybind_wrap function if not found - if(NOT COMMAND pybind11_add_module) - - function(pybind11_add_module target_name) - add_library(${target_name} MODULE ${ARGN}) - set_target_properties(${target_name} PROPERTIES PREFIX "") - - if(TARGET pybind11::module) - target_link_libraries(${target_name} PRIVATE pybind11::module) - elseif(TARGET pybind11::pybind11) - target_link_libraries(${target_name} PRIVATE pybind11::pybind11) - elseif(DEFINED GTWRAP_ROOT_DIR AND EXISTS "${GTWRAP_ROOT_DIR}/pybind11/include") - target_include_directories(${target_name} PRIVATE "${GTWRAP_ROOT_DIR}/pybind11/include") - endif() - - if(TARGET Python::Module) - target_link_libraries(${target_name} PRIVATE Python::Module) - elseif(TARGET Python3::Module) - target_link_libraries(${target_name} PRIVATE Python3::Module) - endif() - endfunction() - endif() - - # Throw fatal error if not available - if(NOT COMMAND pybind11_add_module) - message(FATAL_ERROR - "pybind11_add_module is unavailable. Ensure pybind11 is loaded from gtwrap root or installed with CMake config files.") - endif() - - # Set up Python package and build directories, and ensure __init__.py exists for the package - 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_WRAPPER_LINK_FILE "${PROJECT_PYTHON_PACKAGE_DIR}/_wrapper_build.py") - set(PROJECT_PYTHON_TARGET_NAME "${LIB_NAMESPACE}_py") - set(${PROJECT_NAME}_PYTHON_WRAPPER_TARGET "${PROJECT_PYTHON_TARGET_NAME}" CACHE INTERNAL - "Resolved Python wrapper target name for the project." FORCE) - - 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}") - endif() - - if(NOT EXISTS "${PROJECT_PYTHON_PACKAGE_DIR}/__init__.py") - string(CONFIGURE [=[ -"""Python package entrypoint for @PROJECT_NAME@ bindings.""" - -from __future__ import annotations - -HAS_WRAPPER = False -WRAPPER_IMPORT_ERROR: ImportError | None = None - -try: - from .@PROJECT_NAME@ import * # noqa: F401,F403 -except ImportError as exc: - WRAPPER_IMPORT_ERROR = exc -else: - HAS_WRAPPER = True -]=] _default_python_package_init @ONLY) - file(WRITE - "${PROJECT_PYTHON_PACKAGE_DIR}/__init__.py" - "${_default_python_package_init}") - endif() - - file(MAKE_DIRECTORY "${PROJECT_PYTHON_BUILD_DIRECTORY}") - - # Write pyproject.toml for the source Python package so `pip install python/` is the public entrypoint. - set(_pyproject_template "${PROJECT_PYTHON_SOURCE_DIR}/pyproject.toml.in") - - if(NOT EXISTS "${_pyproject_template}") - message(WARNING - "Missing python/pyproject.toml.in. Generating a minimal fallback template.") - set(_pyproject_template "${PROJECT_BINARY_DIR}/python/pyproject.toml.in.fallback") - file(WRITE "${_pyproject_template}" [=[ -[build-system] -requires = ["setuptools>=61"] -build-backend = "setuptools.build_meta" - -[project] -name = "@PROJECT_NAME@" -version = "@PROJECT_VERSION@" -description = "Python bindings for @PROJECT_NAME@" -requires-python = ">=3.8" - -[tool.setuptools] -packages = ["@PROJECT_NAME@"] -include-package-data = true - -[tool.setuptools.package-data] -"@PROJECT_NAME@" = ["*.so", "*.pyd", "*.dylib", "*.pyi", "**/*.pyi"] -]=]) - endif() - - configure_file( - "${_pyproject_template}" - "${PROJECT_PYTHON_SOURCE_METADATA_FILE}" - @ONLY) - - set(_python_metadata_file "${PROJECT_PYTHON_SOURCE_METADATA_FILE}") - - # Write setup.py into the source python directory so the source package remains the install entrypoint. - set(_setup_py_template "${PROJECT_PYTHON_SOURCE_DIR}/setup.py.in") - if(EXISTS "${_setup_py_template}") - configure_file( - "${_setup_py_template}" - "${PROJECT_PYTHON_SOURCE_SETUP_FILE}" - @ONLY) - else() - set(_generated_setup_py_template "${PROJECT_BINARY_DIR}/python/setup.py.in.fallback") - file(WRITE "${_generated_setup_py_template}" [=[ -from setuptools import setup - -setup(zip_safe=False) -]=]) - configure_file( - "${_generated_setup_py_template}" - "${PROJECT_PYTHON_SOURCE_SETUP_FILE}" - @ONLY) - endif() - - # This is required to avoid an error in modern pybind11 cmake scripts. - if(POLICY CMP0057) - cmake_policy(SET CMP0057 NEW) - endif() - - set(_top_namespace "${GTWRAP_TOP_NAMESPACE}") - if("${_top_namespace}" STREQUAL "") - set(_top_namespace "${PROJECT_NAME}") - endif() - - set(_link_libs "${LIBNAME_WRAP_TARGET}") - set(_wrapper_dependencies "${LIBNAME_WRAP_TARGET}") - - if(GTWRAP_EXTRA_DEPENDENCY_TARGETS) - list(APPEND _wrapper_dependencies ${GTWRAP_EXTRA_DEPENDENCY_TARGETS}) - endif() - - list(REMOVE_DUPLICATES _wrapper_dependencies) - - # wrap expects these customization headers for each interface; seed stubs in build tree. - set(_pywrap_codegen_root "${PROJECT_BINARY_DIR}/${PROJECT_NAME}") - file(MAKE_DIRECTORY "${_pywrap_codegen_root}/specializations") - file(MAKE_DIRECTORY "${_pywrap_codegen_root}/preamble") - - # Generate empty specialization and preamble headers for each interface to avoid build errors if not existing - foreach(_interface_file IN LISTS GTWRAP_INTERFACE_FILES) - get_filename_component(_interface_name "${_interface_file}" NAME_WE) - set(_spec_header "${_pywrap_codegen_root}/specializations/${_interface_name}.h") - set(_preamble_header "${_pywrap_codegen_root}/preamble/${_interface_name}.h") - if(NOT EXISTS "${_spec_header}") - file(WRITE "${_spec_header}" "// Optional pybind specialization hooks for ${_interface_name}.\n") - endif() - if(NOT EXISTS "${_preamble_header}") - file(WRITE "${_preamble_header}" "// Optional pybind preamble hooks for ${_interface_name}.\n") - endif() - endforeach() - - # Configure template for pybind module - set(_pybind_module_template "${PROJECT_BINARY_DIR}/${PROJECT_NAME}.tpl") - if(EXISTS "${PROJECT_SOURCE_DIR}/python/${PROJECT_NAME}.tpl") - # Use the template from the source tree if it exists - configure_file( - "${PROJECT_SOURCE_DIR}/python/${PROJECT_NAME}.tpl" - "${_pybind_module_template}" - COPYONLY) - - elseif(EXISTS "${PROJECT_SOURCE_DIR}/${PROJECT_NAME}.tpl") - # Fallback to looking for a template in the project root if not in python/ - configure_file( - "${PROJECT_SOURCE_DIR}/${PROJECT_NAME}.tpl" - "${_pybind_module_template}" - COPYONLY) - - else() - # Else write it - file(WRITE "${_pybind_module_template}" [=[ -#include -#include -#include -#include -#include -#include - -{includes} - -{boost_class_export} - -using namespace std; - -namespace py = pybind11; - -{submodules} - -{module_def} {{ - m_.doc() = "pybind11 wrapper of {module_name}"; - -{submodules_init} - -{wrapped_namespace} - -}} -]=]) - endif() - - set(ENABLE_BOOST_SERIALIZATION OFF) - - # Get the main interface file and deduce names - list(GET GTWRAP_INTERFACE_FILES 0 _main_interface_file) - get_filename_component(_main_interface_name "${_main_interface_file}" NAME_WE) - set(_main_interface_cpp "${_main_interface_name}.cpp") - set(GTWRAP_PYTHON_GENERATED_CPP_DIR "python") - - # Call pybind wrapper generation function from gtwrap - if(DEFINED GTWRAP_ADD_DOCSTRINGS AND GTWRAP_ADD_DOCSTRINGS) - if(DEFINED BUILD_DOC_XML AND BUILD_DOC_XML AND DEFINED ${PROJECT_NAME}_DOXYGEN_XML_DIR) - set(GTWRAP_PYTHON_DOCS_SOURCE "${${PROJECT_NAME}_DOXYGEN_XML_DIR}" CACHE PATH - "Doxygen XML directory used for gtwrap Python docstrings." FORCE) - message(STATUS "Python wrapper docstrings use project Doxygen XML: ${GTWRAP_PYTHON_DOCS_SOURCE}") - else() - message(WARNING - "GTWRAP_ADD_DOCSTRINGS=ON but BUILD_DOC_XML is not enabled or Doxygen XML is unavailable. " - "Generated Python wrappers will not receive project Doxygen docstrings.") - endif() - endif() - - pybind_wrap(${PROJECT_PYTHON_TARGET_NAME} - "${GTWRAP_INTERFACE_FILES}" - "${_main_interface_cpp}" - "${PROJECT_NAME}" - "${_top_namespace}" - "" - "${_pybind_module_template}" - "${_link_libs}" - "${_wrapper_dependencies}" - ${ENABLE_BOOST_SERIALIZATION} - ) - - if(DEFINED GTWRAP_ADD_DOCSTRINGS AND GTWRAP_ADD_DOCSTRINGS AND - TARGET "pybind_wrap_${PROJECT_NAME}" AND TARGET "${LIB_NAMESPACE}_doc") - add_dependencies("pybind_wrap_${PROJECT_NAME}" "${LIB_NAMESPACE}_doc") - endif() - - # Set python target properties, include directories, and installation rules - set_python_target_properties( - ${PROJECT_PYTHON_TARGET_NAME} - "${PROJECT_NAME}" - "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}") - target_include_directories(${PROJECT_PYTHON_TARGET_NAME} - PRIVATE - "${PROJECT_BINARY_DIR}" - "${PROJECT_PYTHON_BUILD_DIRECTORY}") - - # Keep CMake installs relocatable beneath CMAKE_INSTALL_PREFIX. Installing - # into an active environment remains the responsibility of the pip target. - _resolve_python_install_root(_python_install_root) - set(_python_package_install_destination - "${_python_install_root}/${PROJECT_NAME}") - - # A shared main library is always a Python runtime artifact. Static and - # interface targets are already linked into the extension and need no file. - set(_python_runtime_targets) - get_target_property( - _python_main_runtime_target_type - "${LIBNAME_WRAP_TARGET}" - TYPE) - if(_python_main_runtime_target_type STREQUAL "SHARED_LIBRARY" - OR _python_main_runtime_target_type STREQUAL "MODULE_LIBRARY") - list(APPEND _python_runtime_targets "${LIBNAME_WRAP_TARGET}") - endif() - if(GTWRAP_RUNTIME_DEPENDENCY_TARGETS) - list(APPEND - _python_runtime_targets - ${GTWRAP_RUNTIME_DEPENDENCY_TARGETS}) - endif() - - configure_python_runtime_artifacts( - "${PROJECT_PYTHON_TARGET_NAME}" - "${PROJECT_PYTHON_BUILD_PACKAGE_DIR}" - "${_python_package_install_destination}" - _python_runtime_metadata_entries - ${_python_runtime_targets}) - - set(_python_wrapper_link_content -"\"\"\"Generated by CMake. Tracks the latest requested Python wrapper build.\"\"\" - -WRAPPER_MODULE_PATH = r\"$\" -WRAPPER_RUNTIME_LIBRARY_PATHS = [ -${_python_runtime_metadata_entries}] -") - file(GENERATE - OUTPUT "${PROJECT_PYTHON_WRAPPER_LINK_FILE}" - CONTENT "${_python_wrapper_link_content}") - - # Add import test for python module if enabled - if(ENABLE_TESTS AND BUILD_TESTING) - set(_python_import_test_name "${LIB_NAMESPACE}_python_import") - set(_python_import_test_code - "import ${PROJECT_NAME} as module_; assert getattr(module_, 'HAS_WRAPPER', False), 'Expected HAS_WRAPPER=True';") - add_test( - NAME ${_python_import_test_name} - COMMAND - ${CMAKE_COMMAND} -E env - "PYTHONPATH=${PROJECT_PYTHON_SOURCE_DIR}:$ENV{PYTHONPATH}" - ${PYTHON_EXECUTABLE} -c "${_python_import_test_code}") - set_tests_properties( - ${_python_import_test_name} - PROPERTIES - WORKING_DIRECTORY "${PROJECT_PYTHON_SOURCE_DIR}") - endif() - - install( - TARGETS ${PROJECT_PYTHON_TARGET_NAME} - LIBRARY DESTINATION "${_python_package_install_destination}" - RUNTIME DESTINATION "${_python_package_install_destination}") - - install( - DIRECTORY "${PROJECT_PYTHON_PACKAGE_DIR}/" - DESTINATION "${_python_package_install_destination}" - PATTERN "_wrapper_build.py" EXCLUDE - PATTERN "__pycache__" EXCLUDE - PATTERN "*.pyc" EXCLUDE) - - if(NOT "${_python_metadata_file}" STREQUAL "") - install( - FILES "${_python_metadata_file}" - DESTINATION "${_python_install_root}") - endif() - - # Convenience target: install the source Python package after it has been linked to the latest wrapper build. - set(_python_pip_install_target "${LIB_NAMESPACE}_python-install") - if(NOT TARGET ${_python_pip_install_target}) - add_custom_target( - ${_python_pip_install_target} - COMMAND ${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}" - VERBATIM) - endif() - - if(BUILD_AS_MAIN_PROJECT AND NOT TARGET python-install) - add_custom_target(python-install DEPENDS ${_python_pip_install_target}) - endif() - - # Set python stubs generation target using pybind11-stubgen - 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}" - ${PYTHON_EXECUTABLE} -m pybind11_stubgen ${PROJECT_NAME} -o . - DEPENDS ${PROJECT_PYTHON_TARGET_NAME} - WORKING_DIRECTORY "${PROJECT_PYTHON_SOURCE_DIR}" - VERBATIM) - endif() - - # Add python stubs if building as main project - if(BUILD_AS_MAIN_PROJECT) - if(NOT TARGET python-stubs) - add_custom_target(python-stubs DEPENDS ${_python_stubs_target}) - endif() - endif() - -endfunction() - -################################################################################################## -# MATLAB wrapper configuration using gtwrap -function(configure_matlab_gtwrapper) - message(STATUS "Configuring MATLAB wrap...") - - if(NOT GTWRAP_INTERFACE_FILES) - message(FATAL_ERROR "GTWRAP_INTERFACE_FILES is empty. Cannot build MATLAB wrapper.") - endif() - - set(_resolved_python_executable "") - if(DEFINED Python_EXECUTABLE AND NOT "${Python_EXECUTABLE}" STREQUAL "") - set(_resolved_python_executable "${Python_EXECUTABLE}") - else() - get_property(_cached_python_executable CACHE PYTHON_EXECUTABLE PROPERTY VALUE) - if(NOT "${_cached_python_executable}" STREQUAL "") - set(_resolved_python_executable "${_cached_python_executable}") - endif() - endif() - if(NOT "${_resolved_python_executable}" STREQUAL "") - set(PYTHON_EXECUTABLE "${_resolved_python_executable}") - endif() - - if(NOT COMMAND wrap_and_install_library) - include(MatlabWrap) - endif() - - message(STATUS "Including MATLAB directories...") - find_package(Matlab REQUIRED) - set(MATLAB_MEX_INCLUDE "${Matlab_ROOT_DIR}/extern/include") - - message(STATUS "MATLAB_MEX_INCLUDE directory: ${MATLAB_MEX_INCLUDE}") - message(STATUS "Matlab_MEX_LIBRARY directory: ${Matlab_MEX_LIBRARY}") - message(STATUS "Matlab_MX_LIBRARY directory: ${Matlab_MX_LIBRARY}") - - include_directories(${Matlab_INCLUDE_DIRS}) - include_directories(${MATLAB_MEX_INCLUDE}) - if(DEFINED GTWRAP_INCLUDE_DIR) - include_directories(${GTWRAP_INCLUDE_DIR}) - endif() - - if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/matlab") - file(MAKE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/matlab") - endif() - - set(WRAP_MEX_BUILD_STATIC_MODULE OFF) - set(WRAP_TOOLBOX_INSTALL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/matlab") - set(WRAP_BUILD_TYPE_POSTFIXES OFF) - - if(NOT DEFINED LIBNAME_WRAP_TARGET) - message(FATAL_ERROR - "LIBNAME_WRAP_TARGET variable is not defined. Cannot proceed with MATLAB wrapper build.") - endif() - - message(STATUS "Using interface files: ${GTWRAP_INTERFACE_FILES}") - wrap_and_install_library("${GTWRAP_INTERFACE_FILES}" "${LIBNAME_WRAP_TARGET}" "" "" "" "" OFF) -endfunction() - -################################################################################################## -# Entry point function to handle gtwrap wrappers +# Configure the enabled gtwrap language frontends for the current project. function(handle_gtwrappers) set(_gtwrap_python_option_name "${LIB_NAMESPACE}_BUILD_PYTHON_WRAPPER") set(_gtwrap_matlab_option_name "${LIB_NAMESPACE}_BUILD_MATLAB_WRAPPER") @@ -1233,9 +577,3 @@ function(handle_gtwrappers) configure_matlab_gtwrapper() endif() endfunction() - -################################################################################################## -### Python wrapper configuration using pybind11 directly -# TODO -function(handle_pybind11_wrapper) -endfunction() diff --git a/cmake/StagePythonRuntimeArtifacts.cmake b/cmake/StagePythonRuntimeArtifacts.cmake new file mode 100644 index 0000000..1ecd541 --- /dev/null +++ b/cmake/StagePythonRuntimeArtifacts.cmake @@ -0,0 +1,148 @@ +# Stage resolved native runtime artifacts for one Python wrapper configuration. +# +# The generated manifest reserves the wrapper extension filename and lists the +# exact runtime target files that must share its flat package directory. This +# script validates the complete namespace before copying any runtime or writing +# checkout-only metadata. +cmake_minimum_required(VERSION 3.15) + +if(NOT DEFINED MANIFEST_FILE OR "${MANIFEST_FILE}" STREQUAL "") + message(FATAL_ERROR + "MANIFEST_FILE is required to stage Python runtime artifacts.") +endif() +if(NOT EXISTS "${MANIFEST_FILE}") + message(FATAL_ERROR + "Python runtime manifest does not exist: '${MANIFEST_FILE}'.") +endif() + +include("${MANIFEST_FILE}") + +foreach(_required_manifest_value + PYTHON_WRAPPER_OWNER + PYTHON_WRAPPER_PATH + PYTHON_WRAPPER_NAME + PYTHON_STAGING_DIRECTORY + PYTHON_METADATA_FILE) + if(NOT DEFINED ${_required_manifest_value} + OR "${${_required_manifest_value}}" STREQUAL "") + message(FATAL_ERROR + "Python runtime manifest is missing ${_required_manifest_value}.") + endif() +endforeach() + +# Reserve the extension filename before validating runtimes so no runtime copy +# can later be overwritten when the wrapper target links. +get_filename_component( + _resolved_wrapper_name + "${PYTHON_WRAPPER_PATH}" + NAME) +if(NOT "${_resolved_wrapper_name}" STREQUAL "${PYTHON_WRAPPER_NAME}") + message(FATAL_ERROR + "Python wrapper manifest name '${PYTHON_WRAPPER_NAME}' does not match " + "resolved path '${PYTHON_WRAPPER_PATH}'.") +endif() + +set(_destination_names "${PYTHON_WRAPPER_NAME}") +set(_destination_owners "${PYTHON_WRAPPER_OWNER}") +set(_copy_sources) +set(_copy_destinations) +set(_metadata_runtime_paths) + +list(LENGTH PYTHON_RUNTIME_OWNERS _runtime_count) +list(LENGTH PYTHON_RUNTIME_SOURCES _runtime_source_count) +list(LENGTH PYTHON_RUNTIME_NAMES _runtime_name_count) +if(NOT _runtime_count EQUAL _runtime_source_count + OR NOT _runtime_count EQUAL _runtime_name_count) + message(FATAL_ERROR + "Python runtime manifest has inconsistent owner, source, and name counts.") +endif() + +# Validate every source and destination before creating or modifying the flat +# package directory. Duplicate entries from one owner represent a target file +# whose SONAME is identical and are staged only once. +if(_runtime_count GREATER 0) + math(EXPR _runtime_last_index "${_runtime_count} - 1") + foreach(_runtime_index RANGE 0 ${_runtime_last_index}) + list(GET PYTHON_RUNTIME_OWNERS ${_runtime_index} _runtime_owner) + list(GET PYTHON_RUNTIME_SOURCES ${_runtime_index} _runtime_source) + list(GET PYTHON_RUNTIME_NAMES ${_runtime_index} _runtime_name) + + if("${_runtime_owner}" STREQUAL "" + OR "${_runtime_source}" STREQUAL "" + OR "${_runtime_name}" STREQUAL "") + message(FATAL_ERROR + "Python runtime manifest contains an empty owner, source, or filename.") + endif() + if(NOT EXISTS "${_runtime_source}") + message(FATAL_ERROR + "Python runtime source does not exist for '${_runtime_owner}': " + "'${_runtime_source}'.") + endif() + + list(FIND _destination_names "${_runtime_name}" _destination_index) + if(NOT _destination_index EQUAL -1) + list(GET + _destination_owners + ${_destination_index} + _destination_owner) + if(NOT "${_destination_owner}" STREQUAL "${_runtime_owner}") + message(FATAL_ERROR + "Python runtime destination collision: '${_destination_owner}' and " + "'${_runtime_owner}' both resolve to '${_runtime_name}' in the flat " + "package directory.") + endif() + continue() + endif() + + set(_runtime_destination + "${PYTHON_STAGING_DIRECTORY}/${_runtime_name}") + list(APPEND _destination_names "${_runtime_name}") + list(APPEND _destination_owners "${_runtime_owner}") + list(APPEND _copy_sources "${_runtime_source}") + list(APPEND _copy_destinations "${_runtime_destination}") + list(APPEND _metadata_runtime_paths "${_runtime_destination}") + endforeach() +endif() + +# Copy only after the complete manifest is known to be valid. All sources were +# checked above, so a validation failure cannot leave a partial staging result. +file(MAKE_DIRECTORY "${PYTHON_STAGING_DIRECTORY}") +list(LENGTH _copy_sources _copy_count) +if(_copy_count GREATER 0) + math(EXPR _copy_last_index "${_copy_count} - 1") + foreach(_copy_index RANGE 0 ${_copy_last_index}) + list(GET _copy_sources ${_copy_index} _copy_source) + list(GET _copy_destinations ${_copy_index} _copy_destination) + execute_process( + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different + "${_copy_source}" + "${_copy_destination}" + RESULT_VARIABLE _copy_result + ERROR_VARIABLE _copy_error) + if(NOT _copy_result EQUAL 0) + message(FATAL_ERROR + "Failed to stage Python runtime '${_copy_source}' as " + "'${_copy_destination}': ${_copy_error}") + endif() + endforeach() +endif() + +# Publish metadata only after staging succeeds, so pip and direct checkout +# imports never observe a newly generated link to incomplete runtime contents. +set(_metadata_content +"\"\"\"Generated by CMake for the latest staged Python wrapper build.\"\"\" + +WRAPPER_MODULE_PATH = r\"${PYTHON_WRAPPER_PATH}\" +WRAPPER_RUNTIME_LIBRARY_PATHS = [ +") +foreach(_metadata_runtime_path IN LISTS _metadata_runtime_paths) + string(APPEND + _metadata_content + " r\"${_metadata_runtime_path}\",\n") +endforeach() +string(APPEND _metadata_content "]\n") + +get_filename_component(_metadata_directory "${PYTHON_METADATA_FILE}" DIRECTORY) +file(MAKE_DIRECTORY "${_metadata_directory}") +file(WRITE "${PYTHON_METADATA_FILE}" "${_metadata_content}") diff --git a/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md b/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md index eef70fe..3bc14f9 100644 --- a/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md +++ b/doc/developments/build_cleanup_wrapper_packaging_repair_plan.md @@ -1,5 +1,9 @@ # Build Cleanup and Wrapper Packaging Repair +> Historical record. Active v1.12.1 hardening and v2 test-ownership work is +> tracked only in +> `doc/developments/template_v2_test_ownership_plan.md`. + ## Objective Retain the safe portions of the working-tree changes relative to current diff --git a/doc/developments/template_v2_test_ownership_plan.md b/doc/developments/template_v2_test_ownership_plan.md index bcb2c55..63fa4d2 100644 --- a/doc/developments/template_v2_test_ownership_plan.md +++ b/doc/developments/template_v2_test_ownership_plan.md @@ -1,588 +1,513 @@ -# Template v2 Test Ownership Separation and CI Consolidation Plan +# Template v1.12.1 Hardening and v2 Test-Ownership Migration ## Status -- `v1.11.1` consolidation baseline: complete and tagged in both repositories. -- `v2.0.0` ownership-separation implementation: not started. -- Target release: `v2.0.0` for both `cpp_cuda_template_project` and - `cpp_cuda_template_testfield`. -- Baseline template tag: signed `v1.11.1` at - `ed11d837fc9e107f00ca462fefd47e7da73c5d2a`. -- Baseline testfield tag: signed `v1.11.1` at - `909ed165fc0637cbbbcdeccf3bf641677bd3d701`. -- Main repository branch: `main` in both repositories. -- Last rebaselined against repository and GitHub state: 2026-07-19. -- This document is the source of truth for the v2 test-ownership and CI pass. -- Final commits, pushes, and release tags require explicit user approval. - -## Goal - -Move tests that verify the template system itself out of -`cpp_cuda_template_project` and into a standalone harness owned by -`cpp_cuda_template_testfield`. Keep tests that are intended to be tailored into -a derived project in the template repository. Link the two repositories through -deterministic CI without making a tailored project depend on the testfield. - -At the same time, preserve the green `v1.11.1` contracts, close the remaining -source-tree and checkout-layout risks, eliminate build-tree artifact transfers, -bound cache usage, make source-release and version detection independent of -checkout layout, and prepare both repositories for `v2.0.0`. - -`v1.11.1` is the immutable consolidation input, not the target of this plan. -The move of template-conformance ownership into a separate repository is the -large compatibility and maintenance boundary that justifies `v2.0.0`. - -The implementation goal ends at a reviewed, staged, release-ready state. It -does not include committing, pushing, merging, or creating final `v2.0.0` tags. - -## Fixed decisions - -- The signed `v1.11.1` tags are the last consolidated baseline. Do not move, - recreate, or repurpose them during v2 implementation. -- `cpp_cuda_template_testfield` has two roles: a representative derived project - and the owner of the external template-conformance harness. -- Active workflows in `cpp_cuda_template_project` verify the template itself. - Dormant `.yml.tpl` workflows remain generic derived-project workflows. -- A tailored project must contain no testfield checkout, harness path, template - verifier, or template-repository-only assertion. -- Template CI consumes a testfield checkout pinned by full 40-character commit - SHA. Testfield CI uses an explicitly pinned compatible functional template - SHA; the final template commit that only updates the testfield pin does not - need to be mirrored back into the testfield pin. -- The harness uses sibling checkouts and explicit CMake inputs. It must not infer - dependencies from machine-specific absolute paths or accidental directory - adjacency. -- Existing repository-specific tailoring and tests in derived projects are - preserved. Migration guidance must never instruct agents to delete custom - tests merely because the original template harness moved. -- ROS workflows are event-driven only. Neither active template workflows nor - generic derived-project workflow templates gain a scheduled trigger. -- Workflow contracts verify parsed structure and observable behavior. They do - not grep helper implementation text for capability markers. -- The dependency-free `CLogger` is retained project infrastructure. Its runtime - behavior tests remain starter tests, while namespace-tailoring verification - moves to the external harness. -- The optional legacy spdlog adapter remains supported unless a separately - approved compatibility change removes it. Its acquisition must be - source-tree clean and its runtime tests remain with the starter project. -- Work red-green whenever a regression guard is added: record the expected - failure, apply the smallest owning fix, and rerun the same command. -- The final review is a mandatory terminal stage. Passing implementation-stage - tests does not complete the goal. - -## Ownership after migration - -### Tests retained in `cpp_cuda_template_project` - -- C++ placeholder, dependency-free `CLogger`, and optional spdlog adapter - behavior tests. -- Python import smoke test. -- CUDA runtime initialization gate and CUDA placeholder test. -- ROS conversion, node, lifecycle, service, publication, and launch tests. -- Starter fixtures used by project tests. -- MATLAB wrapper placeholder registration, executable smoke behavior, and - tcmalloc dependency check. These remain available after tailoring. - -### Tests owned by `cpp_cuda_template_testfield` - -- Every `VerifyTemplateProject*` CMake verifier. -- Template source-release and release-tag synchronization verification. -- Template workflow, workflow-template, ROS static, and devcontainer checks. -- Template build/install/consume, nested-build, package-export, flag, version, - cross-compilation, CUDA, and OptiX conformance checks. -- External Python and MATLAB wrapper conformance checks. -- Tailoring and generated-workflow materialization verification. -- Logger namespace-tailoring and retained-file conformance checks. - -### Testfield-local tests - -Testfield implementation tests remain independent from candidate-template -conformance. A normal testfield build can run its local tests without requiring -a template checkout. External conformance is enabled explicitly. - -## Public configuration contracts - -- `TEMPLATE_PROJECT_SOURCE_DIR`: required candidate template source path for - external conformance. -- `TEMPLATE_PROJECT_GTWRAP_SOURCE_DIR`: required wrap source path when wrapper - conformance is enabled. CI uses the testfield's pinned `lib/wrap` submodule. -- `TEMPLATE_HARNESS_PROFILE`: cache string with values `cpu`, `docs`, `cuda`, - `ros2`, or `all`; default `cpu`. -- `ENABLE_TEMPLATE_PROJECT_BUILD_TESTS`: retained in testfield as a compatibility - facade, default `OFF`. When enabled, it requires an explicit candidate path - and delegates registration to the standalone harness. -- Primary CTest labels: `template_cpu`, `template_docs`, `template_cuda`, and - `template_ros2`. Secondary labels describe release, tailoring, wrapper, - package, cross-compilation, and other focused contracts. -- Workflow-dispatch input `runner`: `auto`, `github-hosted`, or `self-hosted`; - default `auto`. -- Workflow-dispatch input `run_tests`: boolean, default `true`. Push and pull - request runs always build and test. -- Repository variables: `CI_USE_SELF_HOSTED`, `CI_CPU_RUNNER_LABELS`, and - `CI_CUDA_RUNNER_LABELS`. Runner-label variables contain JSON arrays and have - documented defaults. - -## Goal execution and exit rules - -### Continue conditions - -The goal remains active when any of the following is true: - -- [ ] Any implementation or exit-condition checkbox in Stages 0-6 is unchecked. -- [ ] A required test is failing, unexecuted without an accepted reason, or has - only been inferred from static inspection. -- [ ] Validation evidence has not been appended to the stage-output log. -- [ ] The mandatory final review has not run against the complete integrated - diff in both repositories. -- [ ] A final-review finding requiring intervention remains open. - -### Successful goal exit - -The implementation goal may be marked complete only when all conditions hold: - -- [ ] Every applicable Stage 0-6 checkbox is complete. -- [ ] Every stage exit condition is satisfied in order. -- [ ] Stage 6 reports no unresolved major or worthwhile minor finding. -- [ ] All required clean-build, harness, tailoring, release, workflow, shell, - YAML, metadata, CUDA/OptiX-when-available, and ROS gates pass. -- [ ] Both worktrees contain only understood changes and generated outputs are - excluded from Git. -- [ ] The proposed commit split and release-tag procedure are ready for user - review. -- [ ] No commit, push, merge, or final tag has been performed by the agent. - -Waiting for user review, commit creation, push, merge, or tag authorization is -the successful handoff state, not an implementation failure. - -### Blocked goal exit - -- Do not mark the goal blocked because work is difficult, slow, or awaiting a - normal stage rerun. -- Exhaust independent work and practical local alternatives first. -- A blocker is terminal only when the same external condition prevents - meaningful progress across three consecutive goal turns and no independent - stage work remains. -- Missing optional hardware is recorded as an explicit conditional skip unless - that hardware is required to validate a changed contract. It does not by - itself block CPU, docs, release, tailoring, or ROS work. -- When blocked, append the exact command, output, prerequisite, completed work, - and safe resume point to the stage-output log before returning control. -- Never mark the goal complete because a token, time, or execution budget is - nearly exhausted. - -## Evidence and staging discipline - -- [ ] Create `doc/developments/TEMPLATE_V2_STAGE_OUTPUTS.md` at Stage 0. -- [ ] Seed it with the signed `v1.11.1` tag objects and commits, current local - baselines, and the green GitHub run IDs recorded below. Do not recreate or - depend on the intentionally removed `ROS2_OVERLAY_STAGE_OUTPUTS.md`. -- [ ] Append red and green commands, relevant output, test totals, skips, - warnings, and blockers immediately after each stage. -- [ ] Keep each stage reviewable as one functional batch. Stage only files that - belong to the approved batch and report the exact staged file list. -- [ ] Do not commit or push either repository unless the user gives a new, - explicit instruction for that operation. - ---- - -## Stage 0 - Freeze the v1.11.1 baseline and close residual prerequisites - -### Verified baseline on 2026-07-19 - -- [x] Template `main`, signed tag `v1.11.1`, and `origin/main` resolve to - `ed11d837fc9e107f00ca462fefd47e7da73c5d2a`; tag-resolved CMake/CPack metadata, - committed `VERSION`, and all four ROS manifests report `1.11.1`. -- [x] Testfield `main`, signed tag `v1.11.1`, and `origin/main` resolve to - `909ed165fc0637cbbbcdeccf3bf641677bd3d701`; tag-resolved CMake/CPack metadata, - committed `VERSION`, and all four ROS manifests report `1.11.1`. -- [x] Template branch and tag gates are green at the baseline SHA: CPU - `29687029686` and `29687032003`, docs `29687029677`, and ROS - `29687029683` and `29687031996`. CUDA run `29687031981` is intentionally - skipped because self-hosted execution was not enabled. -- [x] Testfield branch and tag gates are green at the baseline SHA: CPU - `29687287479` and `29687289696`, docs `29687287459`, and ROS - `29687287456` and `29687289744`. CUDA run `29687289718` is intentionally - skipped for the same explicit runner opt-in contract. -- [x] Both repositories use parser-backed semantic workflow checks. ROS metadata - synchronization is executed and its result observed; workflow code no longer - searches `generate_version.sh` implementation text for capability markers. -- [x] ROS workflows have no scheduled trigger. This is an intentional quota and - runner-availability contract, not a missing workflow feature. -- [x] The prior release-snapshot directory-copy failure, nested candidate-path - loss, missing wrap propagation, and CPU registration of OptiX preflight cases - have been repaired and their latest remote CPU gates are green. -- [x] The dependency-free `CLogger`, logger namespace tailoring, nested installed - header fix, project metadata flowdown, and semantic CI contract tests are part - of the baseline and must not regress during ownership migration. - -### Inventory and red guards - -- [ ] Record current status, branches, exact tags, submodule SHAs, toolchain - versions, complete CTest names/labels, conditional skips, and relevant pytest - inventories for both repositories in `TEMPLATE_V2_STAGE_OUTPUTS.md`. -- [ ] Capture a machine-readable before-migration ownership inventory that Stage - 1 can compare against the standalone harness. -- [ ] Add a version regression that places an extracted no-Git source tree below - a differently tagged parent Git repository. The child must use its own - `VERSION` file. -- [ ] Add a spdlog regression proving automatic acquisition creates no path - under the source tree. -- [ ] Add a testfield repository-topology check that reports `.gitmodules` - entries without a matching Git link and rejects candidate-template checkouts - nested inside the testfield repository. - -### Residual fixes - -- [ ] Remove the orphaned `lib/cpp_cuda_template_project` section from the - testfield `.gitmodules`; verify no matching Git link exists. -- [ ] Move automatic spdlog acquisition from `PROJECT_SOURCE_DIR/lib/spdlog` to - a build-local dependency directory while preserving installed-package and - intentional local-source precedence. -- [ ] In both repositories' `HandleGitVersion.cmake`, normalize - `git rev-parse --show-toplevel` and use Git metadata only when that path equals - the owning project root. Otherwise continue to the owned `VERSION` file. -- [ ] Preserve any user changes that appear during implementation. Never reset, - overwrite, or absorb unrelated concurrent work into a stage batch. - -### Exit condition - -- [ ] Record the expected red result for every new Stage 0 guard before its fix, - then record the corresponding green rerun. -- [ ] The remote-equivalent template CPU, docs, and ROS command sequences pass - locally from clean build directories. -- [ ] The remote-equivalent testfield CPU, docs, and ROS sequences pass with no - unexpected skip; MATLAB remains conditionally skipped when unavailable. -- [ ] Source configuration and dependency acquisition leave no generated - dependency checkout in either source tree. -- [ ] `git diff --check` passes in both repositories. - ---- - -## Stage 1 - Establish the standalone testfield harness - -### Harness structure - -- [ ] Create `tests/template_harness/CMakeLists.txt` in testfield as a project - that can be configured independently of the testfield implementation. -- [ ] Validate all explicit source inputs before registering tests and report - actionable errors for missing candidate or wrap roots. -- [ ] Implement profile registration so CPU/docs/ROS profiles do not initialize - CUDA and the CUDA profile fails early when its required toolchain is absent. -- [ ] Keep CPU-only CUDA architecture parsing cases hermetic, but register OptiX - configure/preflight cases only for the CUDA profile when `nvcc` is available. -- [ ] Keep all harness build and scratch paths below its binary root or caller - supplied temporary roots. - -### Ownership migration - -- [ ] Move all template-conformance CMake verifiers from the template repository - into the testfield harness. -- [ ] Move testfield's existing external candidate verifiers into the same - harness and remove duplicate implementations. -- [ ] Move template workflow, ROS static, workflow-template, and devcontainer - Python checks into the harness with explicit candidate-root fixtures. Preserve - the `v1.11.1` parser-backed and executable behavior checks, including - marker-free metadata-helper execution; do not restore source-text probes. -- [ ] Move external Python/MATLAB wrapper, install/consume, release, CUDA - architecture, and OptiX checks into their appropriate profiles. -- [ ] Keep testfield-local implementation tests in the existing local test tree. - -### Parity gate - -- [ ] Before deleting any old registration, capture its test name, labels, - prerequisites, timeout, resource locks, and expected skip behavior. -- [ ] Configure old and new registrations against the same candidate and compare - `ctest -N` inventories by behavior, allowing only documented renames and - profile separation. -- [ ] Run every new profile available on the host and compare results with the - old owning tests. -- [ ] Record every intentionally consolidated duplicate and prove no behavioral - assertion was lost. -- [ ] Separate runtime starter behavior from conformance assertions in mixed - files, notably retaining `CLogger` and optional spdlog runtime tests while - moving logger namespace-tailoring verification into the harness. - -### Exit condition - -- [ ] The standalone CPU, docs, and ROS profiles pass. -- [ ] The CUDA profile passes when the local CUDA/OptiX prerequisites are - available; otherwise its registration and prerequisite diagnostic are tested. -- [ ] Testfield local tests pass with external conformance disabled. -- [ ] No harness test reads a verifier from the candidate's `tests/cmake` tree. -- [ ] The parity inventory contains no unexplained lost test or assertion. - ---- - -## Stage 2 - Reduce template-owned tests and simplify tailoring - -### Template test tree - -- [ ] Remove migrated template-conformance verifiers only after Stage 1 parity is - green. -- [ ] Retain the starter C++, `CLogger`, optional spdlog adapter, Python, CUDA - runtime, ROS, fixture, and MATLAB tests listed in the ownership contract. -- [ ] Rewrite the template `tests/CMakeLists.txt` as a stable project-test file, - not a file that tailoring later replaces. -- [ ] Ensure starter tests continue to use project metadata and target names that - the tailoring/renaming process can adapt. -- [ ] Keep `src/utils/logging/CLogger.*` and `doc/logging.md` as tailored project - infrastructure, with the namespace derived from the requested project - namespace and no remaining template namespace. - -### Tailoring behavior - -- [ ] Remove root- and test-CMake patching from - `tailor_template_cleanup.sh`. -- [ ] Remove migrated verifier paths from its cleanup inventory. -- [ ] Preserve MATLAB wrapper tests and registration after tailoring. -- [ ] Continue removing internal development reports/guidance and materializing - generic workflows. -- [ ] Preserve every pre-existing custom test and repository-specific tailoring - in scratch derived-project fixtures. -- [ ] Update derived-project agent guidance to state that donor harness removal - never authorizes deletion of downstream tests. - -### Exit condition - -- [ ] Default and `--remove-ros2` scratch tailoring runs pass and are idempotent. -- [ ] Starter and injected custom tests are byte-identical before and after - tailoring except for intentional project metadata renaming. -- [ ] Tailoring performs no edit to root `CMakeLists.txt` or - `tests/CMakeLists.txt`. -- [ ] A tailored tree contains no testfield reference, `VerifyTemplateProject*` - file, or template-only static test. -- [ ] The tailored project builds and runs its retained tests independently. -- [ ] Tailored logger runtime tests pass under the derived namespace, while the - external harness owns the assertion that the renaming operation was complete. - ---- - -## Stage 3 - Cross-repository CI and quota controls - -### Active template CI - -- [ ] Check out the current template and pinned testfield harness as sibling - worktrees with full Git history; initialize the testfield wrap submodule. Pin - the harness by a full commit SHA, not a moving branch or release tag. -- [ ] Run candidate starter tests and the relevant external harness profile in - the same job and workspace. -- [ ] Update active CPU, docs, CUDA, and ROS path filters to include the pinned - harness contract and the candidate files each profile owns. - -### Testfield CI - -- [ ] Build and test the testfield implementation independently. -- [ ] Check out a pinned compatible template SHA as a sibling and run the - external harness explicitly. Do not rely on the current default branch. -- [ ] Keep release-snapshot, nested-build, and metadata tests independent of the - Actions checkout directory name. - -### Generic workflow templates - -- [ ] Keep `.yml.tpl` workflows free of testfield references and template-only - verifier names. -- [ ] Add `run_tests` to CPU and CUDA templates. A false manual value configures - with tests disabled and executes no test command; push and pull requests always - test. -- [ ] Keep generic workflows buildable immediately after materialization. -- [ ] Keep active and generic ROS workflows free of scheduled triggers. -- [ ] Have active template CI materialize every `.tpl`, parse the resulting YAML, - and execute the represented project build, docs, CUDA-when-available, and ROS - entry points rather than relying on text checks alone. - -### Artifact, cache, and runner policy - -- [ ] Merge each native build/test pair into one job. Remove build-tree - `upload-artifact` and `download-artifact` steps from active workflows, - testfield workflows, and `.tpl` workflows. -- [ ] On GitHub-hosted CPU jobs, use a bounded 512 MiB ccache through - `actions/cache@v5`. -- [ ] On self-hosted jobs, use local ccache only; cap CUDA caches at 1 GiB and do - not upload them to GitHub. -- [ ] Use `actions/checkout@v6` and Node-24-compatible action versions. -- [ ] Resolve `runner=auto` from `CI_USE_SELF_HOSTED`; retain explicit manual CPU - overrides. -- [ ] Run CUDA only on the configured self-hosted GPU label set and skip the job - unless `CI_USE_SELF_HOSTED=true`. Do not emulate CUDA or probe runner - availability from another job. Preserve and generalize the `v1.11.1` explicit - opt-in guard rather than weakening it during runner selection refactoring. -- [ ] Build and verify docs on ordinary events without uploading. Upload and - deploy a Pages artifact only for an explicit manual deployment request. - -### Exit condition - -- [ ] Static workflow tests parse every active and dormant workflow and assert - the runner, cache, test, action-version, checkout, artifact, and no-schedule - contracts. -- [ ] No native workflow contains build-tree upload/download actions. -- [ ] No ordinary docs event contains a reachable artifact upload or Pages - deployment. -- [ ] Materialized workflows pass their local execution rehearsals. -- [ ] Template workflows contain the pinned full testfield SHA; generic - workflows contain no testfield identifier. - ---- - -## Stage 4 - Documentation and v2 release preparation - -### Documentation - -- [ ] Update testing and CI documentation with the new ownership boundary, - harness profiles, explicit paths, runner variables, test switch, and cache - policy. -- [ ] Update template usage and tailoring documentation to identify retained - starter tests and preserved downstream customization. -- [ ] Document the two-repository compatibility-pin update procedure without - requiring derived repositories to adopt the testfield harness. -- [ ] Update release documentation with the pre-tag synchronization order for - both repositories. -- [ ] Document `v1.11.1` as the immutable consolidation baseline and explain - that `v2.0.0` is major because template-conformance ownership and CI topology - move across repository boundaries, not because starter project APIs are - intentionally redesigned. - -### Metadata preparation - -- [ ] Prepare both repositories' CMake, CPack, ROS manifests, and generated - release metadata for core version `2.0.0` using the documented temporary-tag - synchronization procedure. -- [ ] Delete only temporary v2 preparation tags after synchronization and before - review. Preserve the signed `v1.11.1` baseline tags unchanged. -- [ ] Build canonical CPack source archives and verify them after extraction in - directories both inside and outside an unrelated parent Git repository. -- [ ] Confirm no final `v2.0.0` tag exists or is created during implementation. - -### Proposed functional split - -- [ ] Prepare a testfield CI-stabilization batch. -- [ ] Prepare a testfield standalone-harness batch. -- [ ] Prepare a template test-ownership and tailoring batch. -- [ ] Prepare a cross-repository CI/quota batch. -- [ ] Prepare a documentation and v2 metadata batch. -- [ ] Report exact titles and bullet descriptions in the user's commit style, - but do not create the commits. - -### Exit condition - -- [ ] Documentation names only interfaces and paths that exist in the integrated - implementation. -- [ ] Both source archives report `2.0.0`, contain synchronized ROS metadata, - and configure without Git. -- [ ] No temporary v2 tag or final `v2.0.0` tag remains locally unless the user - explicitly authorized it; both signed `v1.11.1` baseline tags remain intact. -- [ ] The proposed commit split is dependency ordered and independently - reviewable. - ---- - -## Stage 5 - Integrated validation - -### Main template gates - -- [ ] Run a clean native build and full starter CTest suite, including - dependency-free `CLogger` behavior and the optional spdlog adapter when - enabled. -- [ ] Configure and run standalone harness profiles `cpu`, `docs`, and `ros2` - against the main template candidate. -- [ ] Run clean ROS overlay build/test plus installed-header and metadata checks. -- [ ] Run CUDA and OptiX profiles on the available toolchain, including real - CUDA source ownership, PTX generation, install/export, and negative preflight - cases. -- [ ] Run default and remove-ROS tailoring, followed by native build/test and - generated-workflow validation in each materialized tree. - -### Testfield gates - -- [ ] Run a clean native testfield build and all local tests with external - conformance disabled. -- [ ] Run all applicable standalone harness profiles against the candidate. -- [ ] Run clean testfield ROS build/test and docs generation. -- [ ] Verify the testfield's adapted ROS conversion call and pinned `lib/wrap` - remain intentionally unchanged unless an approved stage explicitly owns a - change. - -### Repository-wide gates - -- [ ] Run `bash -n` and shellcheck on all changed shell scripts in both - repositories. -- [ ] Parse every workflow as YAML and every ROS manifest as XML. -- [ ] Run Python compilation, pytest, type checks where configured, metadata sync - idempotence, and generated-bytecode scans. -- [ ] Verify Git-version isolation below an unrelated tagged parent repository - and verify automatic dependency acquisition leaves both source trees clean. -- [ ] Run conflict-marker, whitespace, executable-mode, machine-local-path, - stale-reference, source-side-effect, and ignored-artifact checks. -- [ ] Append exact commands, totals, skips, and artifact evidence to - `TEMPLATE_V2_STAGE_OUTPUTS.md`. - -### Exit condition - -- [ ] Every applicable integrated gate is green from a clean build directory. -- [ ] Every conditional skip has an explicit unavailable prerequisite and is - unrelated to a changed required contract. -- [ ] Both worktrees have no unexplained modification or generated tracked file. -- [ ] The complete diff is ready for the mandatory final review; the goal is not - yet complete. - ---- - -## Stage 6 - Mandatory final review and verification - -This stage must run last, after all implementation, documentation, metadata, -and integrated validation changes are present. It cannot be waived or replaced -by earlier green tests. - -### Independent review pass - -- [ ] Re-read this plan, both repository diffs, the stage-output log, and every - changed workflow and public CMake/shell interface from the final state. -- [ ] Compare both integrated diffs against their signed `v1.11.1` baseline - commits and confirm the baseline tags themselves were not moved. -- [ ] Reconstruct the before/after test-ownership inventory and prove that every - removed template test has either moved to the harness or was an intentionally - consolidated duplicate. -- [ ] Review path ownership, Git/version boundaries, source-release behavior, - dependency acquisition, wrapper provisioning, CUDA/OptiX prerequisites, - ROS isolation, and tailoring preservation for latent regressions. -- [ ] Review CI expressions for push, pull request, schedule, manual build-only, - hosted CPU, self-hosted CPU, disabled CUDA, enabled CUDA, ordinary docs, and - manual Pages deployment events. For schedule, verify intentional absence from - every active and generic workflow. -- [ ] Review the design against the core requirement: template conformance lives - in testfield, starter tests live in the template, and tailored projects remain - standalone. -- [ ] Search both repositories for stale verifier paths, duplicate test owners, - hidden adjacency assumptions, local absolute paths, artifact uploads, tracked - caches, conflict markers, implementation-text capability probes, and - unintended terminology. - -### Mandatory final verification - -- [ ] Re-run the highest-risk clean gates independently of Stage 5: template CPU - plus harness CPU, source release in a parent Git repository, scratch tailoring - plus retained tests, testfield local tests, workflow YAML/semantic checks, and - ROS static/build verification. -- [ ] Re-run CUDA/OptiX validation when its prerequisites are available; otherwise - verify the final conditional registration and record the external requirement. -- [ ] Confirm `git diff --check`, final status, submodule SHAs, file modes, and - the proposed staging split in both repositories. - -### Finding loop - -- [ ] Classify every finding as blocking, worthwhile, or deferred with explicit - rationale. -- [ ] For every blocking or worthwhile finding, reopen its owning stage, add a - regression first, implement the fix, rerun that stage's exit gate and Stage 5, - then restart Stage 6 from the beginning. -- [ ] Deferred findings must be genuinely outside the v2 goal, carry no known - correctness or release risk, and be written to the stage-output log. - -### Final exit condition - -- [ ] No unresolved blocking or worthwhile finding remains. -- [ ] The repeated mandatory gates are green and their fresh evidence is logged. -- [ ] Every checkbox required by the successful goal exit is complete. -- [ ] A concise final report identifies behavior changes, validation evidence, - residual conditional risks, and the proposed commit split. -- [ ] Stop in the reviewed, staged, pre-commit and pre-tag state and ask the user - for the next explicit operation. +- Authoritative execution tracker for the v1.12.1 prerequisite and the + subsequent v2.0.0 test-ownership migration. +- Tracking started: 2026-07-28. +- Current stage: Stage 1 review gate, staged for user review. +- Template release baseline: signed `v1.12.0` tag at + `b277e4b84e2f1e501d6c2e73370efe0ecd101f23`. +- TestField working baseline: + `237eb7e67e709578a0e15e812e2a7568433eb017`. +- Final commits, pushes, PR mutations, and release tags require the review gates + stated below. + +This file is the single source of truth. Do not create a separate design, +execution-plan, stage-output, discrepancy, or final-report document. + +## Design + +### Release sequence + +1. Settle and stage the maintainable template corrections. +2. The user reviews, commits, and tags template `v1.12.1`. +3. Align TestField with that exact tag and prepare its `v1.12.1` release. +4. Move template-conformance ownership into a standalone TestField harness. +5. Remove migrated conformance tests from the template and simplify tailoring. +6. Validate both repositories and prepare, but do not create, final v2.0.0 + release tags without explicit authorization. + +The v1.12.1 tag remains a correctness baseline. The v2 major boundary is the +cross-repository test-ownership change, not an intentional redesign of the +starter C++/CUDA APIs. + +### Wrapper implementation + +- `cmake/HandleWrapper.cmake` remains the public facade and common gtwrap + orchestration module. +- Python-specific configuration moves to `cmake/HandlePythonWrapper.cmake`. +- MATLAB-specific configuration moves to `cmake/HandleMatlabWrapper.cmake`. +- `cmake/StagePythonRuntimeArtifacts.cmake` performs build-time validation, + copying, and checkout-only metadata generation. +- The configuration-specific manifest uses CMake-resolved wrapper, target-file, + and SONAME filenames. It replaces manual output-name prediction. +- One serialized staging operation validates the complete flat destination + namespace before copying any artifact. +- The documented + `_GTWRAP_RUNTIME_DEPENDENCY_TARGETS` option remains public. + Private helper signatures and layout have no compatibility requirement. +- Wheels and CMake installs remain prefix-relative and loader-relative. + `_wrapper_build.py` remains checkout-only. +- CMake 3.15 is the compatibility floor. Do not use `cmake_path`, + `file(COPY_FILE)`, or `CMAKE_CURRENT_FUNCTION_LIST_DIR`. + +### Test ownership after v2 + +The template retains tests that a tailored project should inherit: + +- C++ starter and dependency-free logger behavior; +- Python import smoke behavior; +- CUDA runtime initialization and placeholder behavior; +- ROS 2 runtime, conversion, node, lifecycle, service, publication, and launch + behavior; +- MATLAB wrapper smoke behavior and target-owned dependency checks; +- reusable project fixtures. + +TestField owns template-system conformance: + +- every `VerifyTemplateProject*` verifier; +- source-release and release-tag verification; +- build, install, consumer, nested-build, flags, version, cross-compilation, + CUDA, and OptiX contracts; +- Python and MATLAB wrapper conformance; +- tailoring, generated workflows, static ROS 2, and devcontainer contracts; +- logger namespace-tailoring and retained-file verification. + +Normal TestField builds remain independent of a template checkout. External +conformance uses an explicit standalone harness with: + +- `TEMPLATE_PROJECT_SOURCE_DIR`; +- `TEMPLATE_PROJECT_GTWRAP_SOURCE_DIR` when wrapper tests are enabled; +- `TEMPLATE_HARNESS_PROFILE=cpu|docs|cuda|ros2|all`. + +The current TestField `ENABLE_TEMPLATE_PROJECT_BUILD_TESTS` facade and implicit +sibling-source discovery are removed in v2 rather than preserved through a +compatibility layer. + +### Tailoring after v2 + +- `tests/CMakeLists.txt` is a stable starter-project test file. +- Tailoring does not rewrite root or test CMake files. +- Production wrapper modules, starter tests, MATLAB wrapper tests, and + downstream custom tests survive unchanged except intentional project and + namespace renaming. +- Template-development reports, conformance harnesses, and active + template-validation workflows do not survive tailoring. +- Generic derived-project workflows remain independent of TestField. + +## Execution rules + +- Work red-green-refactor for every behavioral correction. +- Record the red command and expected failure before the production change. +- Append concise commands, exit status, totals, skips, warnings, and blockers + to the owning stage evidence. +- Record every implementation discrepancy before changing the plan or design. +- Preserve user-owned or unrelated work in both repositories. +- Apply the complete staged-code documentation and readability gate before + every review handoff. +- Do not reply to or resolve GitHub review threads during implementation. +- No additional subagents are authorized after the completed Stage 1 + maintainability review. + +## Stage 0 - Baseline and plan rebase + +- [x] Inspect the current template and TestField branches, tags, indexes, and + submodule state. +- [x] Inspect PR 28 review threads and map them to the owning contracts. +- [x] Complete one independent maintainability review with a second agent. +- [x] Rebase the v2 goal on v1.12.1 followed by the ownership migration. +- [x] Remove obsolete spdlog, broad cache-quota, action-version, and unrelated + CI-modernization objectives from the active plan. +- [x] Establish this file as the only active tracker. +- [x] Seed the known discrepancy ledger. + +### Stage 0 evidence + +- Template branch: `bugfix/clean-wrapper-packaging-safety`. +- Template HEAD: `b277e4b84e2f1e501d6c2e73370efe0ecd101f23`, + exactly signed tag `v1.12.0`. +- Template index at baseline: six staged files, `318` insertions and `16` + deletions. +- TestField branch: `main`, one unpushed commit ahead of `origin/main`. +- TestField HEAD: `237eb7e67e709578a0e15e812e2a7568433eb017`. +- TestField index at baseline: ten staged files. +- TestField wrap submodule: + `55f7cf30f47972a7055266bd4308614e8fe8aca2`. +- Toolchain: CMake 3.28.3, Python 3.12.3, GCC/G++ 13.3.0, CUDA 12.9. +- `ros2` was not initially on `PATH`; ROS 2 Jazzy was later located under + `/opt/ros/jazzy` and the clean overlay acceptance completed. +- PR 28: + - resolved/outdated interpreter-directory review is represented by + `b277e4b`; + - resolved runtime-collision review is superseded by the planned simplified + staging design; + - unresolved absolute-install-directory review is covered by the staged + rejection; + - unresolved nested-source review is covered by cache-ownership validation. +- Remote CI validates committed `v1.12.0`, not the staged v1.12.1 candidate. + +## Stage 1 - Template v1.12.1 hardening + +### Tests first + +- [x] Change the collision fixture so configuration succeeds and wrapper + staging fails before copying any colliding artifact. +- [x] Add runtime-versus-wrapper-extension collision coverage. +- [x] Add target-file-versus-SONAME collision coverage where the host platform + produces distinct names. +- [x] Add generator-expression/configuration-specific output-name coverage. +- [x] Add active nested binary-tree and foreign-cache source-archive coverage. +- [x] Record each expected red result. + +### Implementation + +- [x] Split the wrapper module along the approved responsibility boundaries. +- [x] Remove the configure-time output-name analyzer. +- [x] Generate one configuration-specific resolved-artifact manifest. +- [x] Use one staging target that validates, copies, then writes metadata. +- [x] Batch runtime target properties and install rules. +- [x] Preserve exact runtime validation, RPATH, SONAME, wheel, install, and + incremental-refresh contracts. +- [x] Reject absolute CMake Python install destinations. +- [x] Exclude only the active or cache-proven checkout-owned build trees from + source archives. +- [x] Update tailoring fixtures so all production wrapper modules survive. +- [x] Update wrapper, usage, tailoring, testing, and callable/file + documentation. + +### Validation + +- [x] Run focused wrapper packaging verification. +- [x] Run focused release/source-archive verification. +- [x] Run tailoring verification and a scratch tailored-wrapper acceptance. +- [x] Run a clean CPU build and full CTest suite. +- [x] Run documentation validation. +- [x] Run CUDA validation with the available toolchain. +- [x] Run ROS 2 validation when the installed distribution is available. +- [x] Run shell, Python, YAML/XML, conflict-marker, whitespace, and generated + artifact checks. +- [x] Inspect the complete index under the staged-code quality gate. +- [x] Stage one coherent template batch. + +### Stage 1 evidence + +- RED, runtime collision timing: + `cmake -DTEST_TEMPLATE_SOURCE_DIR="$PWD" + -DTEST_BINARY_ROOT=/tmp/cpp_cuda_template_tdd_runtime_collision + -P tests/cmake/VerifyTemplateProjectPythonPackaging.cmake` exited `1`. + The new configure-success assertion caught the old + `_collect_python_runtime_destination_names()` path rejecting + `colliding_runtime` during configuration, before build-time staging. +- RED, wrapper/runtime namespace: the wrapper-collision fixture configured and + built successfully, allowing the runtime copy and extension linker to write + `fixture_package.so` in the same staging directory. +- RED, target-file/SONAME namespace: the Linux fixture configured and built + successfully even though one target file resolved to another target's + `libsoname_collision.so.2` SONAME destination. +- RED, generator-expression output: configuration with + `FIXTURE_GENERATOR_OUTPUT_NAME=ON` exited `1` because the manual analyzer + rejected the generator expression before CMake could resolve the active + configuration filename. +- RED, active nested source build: + `cmake -DTEST_TEMPLATE_SOURCE_DIR="$PWD" + -DTEST_BINARY_ROOT=/tmp/cpp_cuda_template_tdd_source_release + -P tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake` exited `1` because + the canonical archive contained `generated/current_output`. The same fixture + also requires foreign-cache and nested `install` source content to remain. +- GREEN, focused wrapper: + `cmake -DTEST_TEMPLATE_SOURCE_DIR="$PWD" + -DTEST_BINARY_ROOT=/tmp/cpp_cuda_template_focus_wrapper + -P tests/cmake/VerifyTemplateProjectPythonPackaging.cmake` exited `0`. +- GREEN, focused release: + `cmake -DTEST_TEMPLATE_SOURCE_DIR="$PWD" + -DTEST_BINARY_ROOT=/tmp/cpp_cuda_template_focus_release + -P tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake` exited `0`. +- GREEN, tailoring: + `cmake -DTEST_TEMPLATE_SOURCE_DIR="$PWD" + -DTEST_BINARY_ROOT=/tmp/cpp_cuda_template_focus_tailoring + -P tests/cmake/VerifyTemplateProjectTailoringScript.cmake` exited `0`. +- GREEN, tailored wrapper acceptance: a full scratch copy was tailored with + `--project-namespace tailored_project`; the external Python packaging + verifier then exited `0` against the tailored source. +- GREEN after ISSUE-007 repair, clean CPU: + `./build_lib.sh --clean -j 4` exited `0`; CTest passed `28/28`. +- GREEN, documentation: + `cmake --build build --target template_project_doc --parallel 4` exited `0` + with Doxygen 1.9.8. +- GREEN, clean CUDA: + `./build_lib.sh -B build_cuda_v112 --clean -DENABLE_CUDA=ON -j 4` exited + `0`; CUDA 12.9.41 selected `sm_120` and CTest passed `31/31`. +- GREEN, clean ROS 2 Jazzy: + `source /opt/ros/jazzy/setup.bash && ./build_ros2.sh --clean` exited `0`. + Colcon built all four packages and reported `10` tests with `0` errors, + `0` failures, and `0` skips. +- GREEN, static and repository hygiene: + - Bash syntax passed for the root build, ROS, tailoring, version, and + devcontainer helpers. + - ShellCheck passed for `tailor_template_cleanup.sh`. + - Python byte-compilation passed for every tracked Python file. + - all workflow YAML and ROS package XML parsed successfully; + - whitespace and conflict-marker checks passed; + - the wrapper modules contain none of the CMake APIs forbidden by the 3.15 + compatibility floor. +- GREEN, post-review regression: + `ctest --test-dir build --output-on-failure -j 4` exited `0`; CTest passed + `28/28` after the final documentation and common-state simplification. +- CMake-floor audit: the host provides CMake 3.28.3 rather than a 3.15 binary. + The staged wrapper and release constructs were checked against CMake 3.15 + documentation and the repository's forbidden-newer-API scan passed. +- Maintainability: the public facade fell from 1,410 to 579 lines. Python + handling is isolated in 658 lines, MATLAB handling in 53 lines, and the + build-time validator in 148 lines. The no-op direct-pybind entrypoint and its + root-CMake call/status path were removed. Common Python-executable resolution + is centralized in the facade rather than duplicated by both language + frontends. + +### Stage 1 review gate + +Stop with the template staged and uncommitted. The user reviews the batch, +creates the release commit, and creates the annotated `v1.12.1` tag. Do not +push or mutate PR threads. + +Proposed title: + +`[BUGFIX] Simplify wrapper packaging and source release safety` + +Proposed description: + +- Resolve Python runtime filenames at build time and reject every flat-package + collision before copying artifacts. +- Split Python, MATLAB, and runtime-staging responsibilities out of the common + wrapper facade while preserving the documented wrapper options. +- Keep source archives precise by excluding only active or cache-proven build + trees, including parallel-configure race coverage. +- Retain production wrapper modules through tailoring and document the updated + packaging and test-ownership contracts. + +## Stage 2 - TestField v1.12.1 alignment + +- [ ] Verify the reviewed template tag and synchronized release metadata. +- [ ] Reconcile the existing TestField index with the exact v1.12.1 tag. +- [ ] Port production behavior while preserving TestField identity, APIs, ROS + adaptations, and local tests. +- [ ] Keep template conformance external to normal TestField CTest. +- [ ] Maintain one combined v1.12.1 sync document, design first and plan second. +- [ ] Run CPU, docs, CUDA, ROS 2, wrapper, wheel, install/consumer, cleanup, and + release-archive acceptance. +- [ ] Apply the complete staged-code quality gate. +- [ ] Amend the existing unpushed signed documentation commit into one coherent + v1.12.1 alignment commit. +- [ ] Stop without pushing for user review and TestField v1.12.1 tagging. + +Proposed title: + +`[BUGFIX] Align TestField with template v1.12.1 packaging` + +## Stage 3 - TestField-owned v2 harness + +- [ ] Inventory every conformance test, label, timeout, prerequisite, resource + lock, skip, and behavioral assertion. +- [ ] Create the standalone TestField harness and explicit profile inputs. +- [ ] Move template-system conformance implementations into TestField while + their original template counterparts remain available for parity. +- [ ] Keep TestField local tests independent of the candidate template. +- [ ] Remove the main-project external-test facade and implicit sibling path. +- [ ] Compare old and new inventories and results against the same candidate. +- [ ] Resolve every unexplained lost or duplicated assertion. +- [ ] Update only CI wiring required for full-SHA sibling harness execution. + +## Stage 4 - Template reduction and tailoring simplification + +- [ ] Remove migrated template-conformance implementations and registrations + only after Stage 3 parity is green. +- [ ] Retain all starter-project runtime tests and fixtures. +- [ ] Make `tests/CMakeLists.txt` stable for derived projects. +- [ ] Remove root- and test-CMake rewriting from tailoring. +- [ ] Preserve production wrapper modules and downstream custom tests. +- [ ] Run default and `--remove-ros2` tailoring twice for idempotence. +- [ ] Build and test both tailored results. +- [ ] Prove tailored projects contain no TestField or template-conformance + dependency. + +## Stage 5 - Integrated v2 validation and release preparation + +- [ ] Run TestField local tests without a template checkout. +- [ ] Run all applicable standalone harness profiles. +- [ ] Run clean CPU, docs, CUDA/OptiX, ROS 2, wrapper, install/consumer, + release, shell, Python, YAML, XML, and Git-hygiene gates. +- [ ] Verify source releases inside and outside an unrelated parent Git + repository. +- [ ] Inspect both complete indexes under the staged-code quality gate. +- [ ] Re-run the highest-risk wrapper, harness, tailoring, and release gates. +- [ ] Prepare one functional ownership batch per repository and only an + unavoidable later compatibility-pin batch. +- [ ] Stop before pushes or final v2.0.0 tags unless explicitly authorized. + +## Discrepancy and issue ledger + +Entries remain in this ledger after resolution. + +### ISSUE-001 - Stale v2 baseline and bundled scope + +- Stage: 0 +- Severity: worthwhile +- Expected: an executable ownership-migration plan based on the current release. +- Observed: the old plan used v1.11.1, retained removed spdlog behavior, and + bundled unrelated CI/cache modernization. +- Action: rebase on the v1.12.1 prerequisite and retain only work required by + conformance ownership. +- Status: resolved by this plan revision. + +### ISSUE-002 - Configure-time runtime-name approximation + +- Stage: 1 +- Severity: blocking +- Expected: validate the actual filenames staged for the active configuration. +- Observed: `_collect_python_runtime_destination_names()` manually approximates + CMake naming across configurations, rejects generator expressions, adds more + than one hundred lines, and does not cover the wrapper extension namespace. +- Action: replace it with generated resolved-artifact manifests and one + build-time staging operation. +- Status: resolved. Resolved filenames are validated by the generated manifest + during the wrapper build. + +### ISSUE-003 - Multi-config checkout metadata + +- Stage: 1 +- Severity: blocking +- Expected: checkout metadata identifies the artifacts staged for the active + configuration. +- Observed: configuration-time metadata can contain configuration-dependent + target paths while all configurations share one package workspace. +- Action: write metadata from the serialized staging operation and document + that one configuration populates the shared checkout package at a time. +- Status: resolved. The staging operation writes metadata only after the active + configuration validates and copies successfully. + +### ISSUE-004 - Active nested build archive exclusion + +- Stage: 1 +- Severity: worthwhile +- Expected: the active binary tree never enters the source archive. +- Observed: cache discovery during the first configure may not discover the + active binary directory yet. +- Action: exclude an active binary directory below the source root explicitly; + require exact cache ownership for every other nested directory. +- Status: resolved. The active nested binary directory is explicit; all other + exclusions still require exact cache ownership. + +### ISSUE-005 - TestField candidate is superseded + +- Stage: 2 +- Severity: blocking +- Expected: TestField aligns with the reviewed template v1.12.1 tag. +- Observed: its staged wrapper port and combined document still target + v1.12.0 and the superseded configure-time analyzer. +- Action: reconcile, replace, and retest only after the Stage 1 release gate. +- Status: open. + +### ISSUE-006 - Remote evidence does not cover the index + +- Stage: 1 +- Severity: environment +- Expected: validation evidence covers the exact candidate delivered for review. +- Observed: current successful GitHub checks cover only committed v1.12.0. +- Action: run fresh local acceptance against the complete staged candidate and + report remote CI as pending until a push is authorized. +- Status: open. + +### ISSUE-007 - Archive cache scan races parallel configure tests + +- Stage: 1 +- Severity: blocking +- Expected: source-package ownership discovery ignores build trees already + excluded by stable top-level policy and remains safe during parallel CTest. +- Observed: the clean CPU run passed 27 of 28 tests. The version-side-effect + configure failed when `file(STRINGS)` tried to read another test's transient + `build/.../CMakeScratch/.../CMakeCache.txt` after that file disappeared. +- Evidence: `./build_lib.sh --clean -j 4` exited `8`; CTest reported + `96% tests passed, 1 tests failed out of 28`. +- Root cause: recursive cache discovery descends into the conventional + top-level `build*` tree even though that entire tree is already excluded from + source archives. +- Action: add a deterministic dangling transient-cache regression under a + top-level build tree, then filter already-ignored and already-owned trees + before reading candidate caches. +- Verification: + - the dangling-cache source-release regression exited `0`; + - `ctest --test-dir build --output-on-failure + -R '^template_project_version_no_source_side_effect$'` passed `1/1`. +- Status: resolved. Candidate caches already covered by stable top-level ignore + rules or a known active build are filtered before any cache read. + +### ISSUE-008 - CMake 3.15 runtime is unavailable locally + +- Stage: 1 +- Severity: environment +- Expected: execute the focused packaging and release verifiers with the + declared minimum CMake version. +- Observed: this host provides CMake 3.28.3 and no separate 3.15 executable. +- Action: audit the staged constructs against the CMake 3.15 documentation, + reject known newer APIs statically, and rely on authorized remote + minimum-version CI for an exact-runtime check. +- Status: open as a conditional environment limitation; no staged construct + was found outside the documented 3.15 API surface. + +## Final Status Review and Report + +### Stage 1 interim release gate - 2026-07-28 + +- State: + - branch `bugfix/clean-wrapper-packaging-safety`; + - unchanged HEAD `b277e4b84e2f1e501d6c2e73370efe0ecd101f23`, + exactly tagged `v1.12.0`; + - `16` related files staged with `1,852` insertions and `1,314` deletions, + with no unstaged tracked files; + - no template commit, new tag, push, PR mutation, or TestField change was + made. +- Implemented behavior: + - runtime names are resolved for the active configuration and validated as + one flat namespace before any copy; + - checkout metadata is written only by the serialized staging operation; + - absolute CMake Python library destinations are now rejected; + - active and cache-proven nested build trees are excluded from source + archives without dropping legitimate similarly named sources; + - public wrapper cache options remain intact. Private helper signatures and + the no-op direct-pybind entrypoint are not retained. +- Review disposition: + - the interpreter-directory and runtime-collision reviews are represented by + the corrected implementation; + - the absolute-install and nested-source review findings are implemented and + locally verified; + - GitHub review threads were intentionally not mutated. +- Validation: + - first clean CPU attempt exposed ISSUE-007 at `27/28`; its deterministic + regression, focused rerun, and clean rerun passed; + - clean CPU passed `28/28`, clean CUDA passed `31/31`, and the final reviewed + CPU rerun passed `28/28`; + - ROS 2 Jazzy built four packages and reported `10` tests, `0` errors, + `0` failures, and `0` skips; + - documentation, focused packaging, source release, tailoring, tailored-copy + packaging, shell, Python, YAML, XML, whitespace, conflict-marker, and + generated-artifact checks passed. +- Maintainability: + - the 1,410-line facade is now a 579-line common coordinator; + - Python, MATLAB, and build-time staging have explicit module ownership; + - common executable resolution is deduplicated; + - new and substantially modified modules have file-level, callable, and + non-obvious-block documentation; + - wrapper, usage, testing, tailoring, and historical-plan documentation is + aligned. +- Open or deferred: + - ISSUE-005 remains blocked on the reviewed `v1.12.1` tag; + - ISSUE-006 remains open because remote CI cannot cover an unpushed index; + - ISSUE-008 records the unavailable local CMake 3.15 runtime; + - Stages 2 through 5 intentionally remain pending. +- Readiness: the template Stage 1 batch is ready for user review. The remaining + user actions are to review the index, create the release commit with the + proposed title and bullets, create the annotated `v1.12.1` tag, and then + authorize Stage 2 TestField alignment. + +The final integrated v2.0.0 report remains pending until Stages 2 through 5 +complete. diff --git a/doc/template_usage.md b/doc/template_usage.md index b70f042..37220de 100644 --- a/doc/template_usage.md +++ b/doc/template_usage.md @@ -212,7 +212,14 @@ By default this also removes `profiling/`. Keep those scripts only when the new ./tailor_template_cleanup.sh --apply --yes --project-namespace my_project --keep-profiling ``` -The script replaces `template_project::logging` with the required project namespace, then removes agent/context notes, internal development notes, workflow snapshot files, template-specific validation CTest scripts, optional profiling scripts, and the workspace file tied to this template checkout. It keeps reusable project infrastructure such as `cmake/`, `build_lib.sh`, docs workflow files, issue forms, examples, toolchains, starter unit tests, `.devcontainer/`, and `.vscode/`. +The script replaces `template_project::logging` with the required project +namespace, then removes agent/context notes, internal development notes, +workflow snapshot files, template-specific validation CTest scripts, optional +profiling scripts, and the workspace file tied to this template checkout. It +keeps reusable project infrastructure such as `cmake/` (including the +Python/MATLAB wrapper and runtime-staging modules), `build_lib.sh`, docs +workflow files, issue forms, examples, toolchains, starter unit tests, +`.devcontainer/`, and `.vscode/`. It also removes the root CMake hook for the template MATLAB regression helper and rewrites `tests/CMakeLists.txt` so only starter project unit tests remain registered. diff --git a/doc/testing_and_ci.md b/doc/testing_and_ci.md index afefbef..a1dab9e 100644 --- a/doc/testing_and_ci.md +++ b/doc/testing_and_ci.md @@ -21,6 +21,9 @@ normal CTest entries that execute `python -m pytest -q `. The template's default CTest suite includes conformance checks for generic tailoring, generated files, workflows, and package behavior. Those checks are template-maintainer infrastructure and are removed by normal tailoring. +Production modules exercised by those checks remain in the tailored project; +for example, wrapper packaging verification is removed while the +`Handle*Wrapper.cmake` and runtime-staging modules it validates are retained. Do not reproduce them as recursive CMake tests in a derived project. In particular, ordinary derived-project CTest must not configure and rebuild the diff --git a/doc/wrappers.md b/doc/wrappers.md index 70fd76d..a354a65 100644 --- a/doc/wrappers.md +++ b/doc/wrappers.md @@ -41,7 +41,12 @@ Disable submodule initialization fallback with: ## Python Package -The source package under `python//` is the supported import and install entrypoint. CMake configures `python/pyproject.toml`, `python/setup.py`, and `_wrapper_build.py` when Python wrapping is enabled. +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. ```bash ./build_lib.sh -p @@ -56,8 +61,27 @@ The main project shared library is packaged automatically. List additional direct project-owned `SHARED_LIBRARY` or `MODULE_LIBRARY` build targets in `_GTWRAP_RUNTIME_DEPENDENCY_TARGETS`; CMake rejects imported, static, interface, or missing targets rather than scanning arbitrary build -directories. CMake alias target names are not accepted. System libraries -remain the responsibility of the target platform. +directories. CMake resolves configuration- and platform-specific target and +SONAME filenames, including generator-expression output names, before one +serialized staging operation validates the complete flat package namespace. +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`. + +Wrapper install destinations remain relative to `CMAKE_INSTALL_PREFIX`. +In particular, keep `CMAKE_INSTALL_LIBDIR` relative when Python wrapping is +enabled; an absolute value is rejected rather than allowing CMake installation +to escape a user-selected prefix. + +Production responsibilities are separated across `HandleWrapper.cmake` +(gtwrap discovery and orchestration), `HandlePythonWrapper.cmake`, +`HandleMatlabWrapper.cmake`, and `StagePythonRuntimeArtifacts.cmake`. All four +modules are retained by project tailoring. ## MATLAB Wrapper diff --git a/tailor_template_cleanup.sh b/tailor_template_cleanup.sh index 570f6c1..19a5e94 100755 --- a/tailor_template_cleanup.sh +++ b/tailor_template_cleanup.sh @@ -167,7 +167,8 @@ Workflow edits made by --apply: - With --remove-ros2, omit the runnable and dormant ROS 2 workflow. Not removed: - - cmake/, build_lib.sh, generate_version.sh, docs workflow files, issue forms, and docs guides. + - cmake/ production modules, including Python/MATLAB wrapper staging support. + - build_lib.sh, generate_version.sh, docs workflow files, issue forms, and docs guides. - src/utils/logging/ and doc/logging.md, because the logger is reusable project infrastructure. - tests/template_test and tests/template_fixtures, because they are starter project tests. - .devcontainer, .vscode, examples/, and toolchains, because they are reusable project infrastructure. diff --git a/tests/cmake/VerifySourceReleaseArchive.cmake b/tests/cmake/VerifySourceReleaseArchive.cmake index ec68457..aa0f3a8 100644 --- a/tests/cmake/VerifySourceReleaseArchive.cmake +++ b/tests/cmake/VerifySourceReleaseArchive.cmake @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.15) +# Validate one extracted canonical source archive independently of Git metadata. foreach(required_var TEST_SOURCE_ROOT TEST_BINARY_ROOT EXPECTED_VERSION EXPECTED_FULL_VERSION) if(NOT DEFINED ${required_var}) message(FATAL_ERROR "Missing required variable: ${required_var}") @@ -35,16 +36,6 @@ foreach(_root_build_entry IN LISTS _root_build_entries) message(FATAL_ERROR "Canonical source archive contains build tree: ${_root_build_entry}") endif() endforeach() -file(GLOB_RECURSE _archive_entries LIST_DIRECTORIES TRUE "${TEST_SOURCE_ROOT}/*") -foreach(_archive_entry IN LISTS _archive_entries) - if(IS_DIRECTORY "${_archive_entry}") - get_filename_component(_archive_entry_name "${_archive_entry}" NAME) - if(_archive_entry_name MATCHES "^build[^/]*$") - message(FATAL_ERROR - "Canonical source archive contains nested build tree: ${_archive_entry}") - endif() - endif() -endforeach() foreach(generated_path "ros2/build" "ros2/install" "ros2/log") if(EXISTS "${TEST_SOURCE_ROOT}/${generated_path}") message(FATAL_ERROR "Canonical source archive contains generated ROS output: ${generated_path}") diff --git a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake index a176ea6..e20f71e 100644 --- a/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake +++ b/tests/cmake/VerifyTemplateProjectPythonPackaging.cmake @@ -26,9 +26,40 @@ function(_run_step step_name) endif() endfunction() +# Require a fixture command to fail for the intended contract violation rather +# than accepting an unrelated configure error as proof of rejection. +function(_run_failure step_name expected_message) + execute_process( + COMMAND ${ARGN} + RESULT_VARIABLE _result + OUTPUT_VARIABLE _stdout + ERROR_VARIABLE _stderr) + if(_result EQUAL 0) + message(FATAL_ERROR + "${step_name} unexpectedly succeeded.\n" + "stdout:\n${_stdout}\n" + "stderr:\n${_stderr}") + endif() + + set(_combined_output "${_stdout}\n${_stderr}") + string(FIND "${_combined_output}" "${expected_message}" _message_index) + if(_message_index LESS 0) + message(FATAL_ERROR + "${step_name} failed without the expected diagnostic " + "'${expected_message}'.\n" + "stdout:\n${_stdout}\n" + "stderr:\n${_stderr}") + endif() +endfunction() + file(REMOVE_RECURSE "${TEST_BINARY_ROOT}") set(_fixture_source "${TEST_BINARY_ROOT}/fixture_source") set(_fixture_build "${TEST_BINARY_ROOT}/fixture_build") +set(_absolute_libdir_build "${TEST_BINARY_ROOT}/absolute_libdir_build") +set(_runtime_collision_build "${TEST_BINARY_ROOT}/runtime_collision_build") +set(_wrapper_collision_build "${TEST_BINARY_ROOT}/wrapper_collision_build") +set(_soname_collision_build "${TEST_BINARY_ROOT}/soname_collision_build") +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") @@ -144,6 +175,33 @@ set_target_properties( VERSION 3.4.5 SOVERSION 3) +# Exercise the flat package-directory collision contract without requiring a +# build that would already have overwritten one of the native artifacts. +option(FIXTURE_RUNTIME_COLLISION "Give two runtime targets identical filenames" OFF) +if(FIXTURE_RUNTIME_COLLISION) + set_target_properties( + fixture_dependency + fixture_packaged + PROPERTIES + OUTPUT_NAME colliding_runtime + VERSION 1.2.3 + SOVERSION 1) + set_target_properties( + fixture_dependency + PROPERTIES + LIBRARY_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/dependency" + RUNTIME_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/dependency") + set_target_properties( + fixture_packaged + PROPERTIES + LIBRARY_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/packaged" + RUNTIME_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/packaged") +endif() + add_library(fixture_package MODULE module.c) target_link_libraries( fixture_package @@ -155,6 +213,67 @@ if(WIN32) set_target_properties(fixture_package PROPERTIES SUFFIX ".pyd") endif() +# Additional collision fixtures build into separate directories so only the +# wrapper's flat staging contract can reject their resolved filenames. +set(_additional_runtime_targets) +option(FIXTURE_WRAPPER_COLLISION + "Give a runtime target the wrapper extension filename" OFF) +if(FIXTURE_WRAPPER_COLLISION) + add_library(fixture_wrapper_collision MODULE packaged.c) + set_target_properties( + fixture_wrapper_collision + PROPERTIES + PREFIX "" + OUTPUT_NAME fixture_package + LIBRARY_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/wrapper" + RUNTIME_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/wrapper") + if(WIN32) + set_target_properties(fixture_wrapper_collision PROPERTIES SUFFIX ".pyd") + endif() + list(APPEND _additional_runtime_targets fixture_wrapper_collision) +endif() + +option(FIXTURE_SONAME_COLLISION + "Give one runtime the resolved SONAME filename of another" OFF) +if(FIXTURE_SONAME_COLLISION AND UNIX AND NOT APPLE) + add_library(fixture_soname_owner SHARED packaged.c) + set_target_properties( + fixture_soname_owner + PROPERTIES + OUTPUT_NAME soname_collision + VERSION 2.0.0 + SOVERSION 2 + LIBRARY_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/soname_owner") + + add_library(fixture_target_owner SHARED packaged.c) + set_target_properties( + fixture_target_owner + PROPERTIES + PREFIX "" + OUTPUT_NAME "libsoname_collision.so.2" + SUFFIX "" + NO_SONAME TRUE + LIBRARY_OUTPUT_DIRECTORY + "${CMAKE_CURRENT_BINARY_DIR}/collision_outputs/target_owner") + list(APPEND + _additional_runtime_targets + fixture_soname_owner + fixture_target_owner) +endif() + +option(FIXTURE_GENERATOR_OUTPUT_NAME + "Use a configuration-dependent runtime output name" OFF) +if(FIXTURE_GENERATOR_OUTPUT_NAME) + set_target_properties( + fixture_packaged + PROPERTIES + OUTPUT_NAME + "$,fixture_packaged_debug,fixture_packaged_release>") +endif() + 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") @@ -183,21 +302,11 @@ configure_python_runtime_artifacts( fixture_package "${_package_build_dir}" "${_package_install_destination}" - _runtime_metadata_entries + "${_package_dir}/_wrapper_build.py" fixture_runtime fixture_dependency - fixture_packaged) - -set(_wrapper_metadata -"\"\"\"Generated wrapper packaging fixture metadata.\"\"\" - -WRAPPER_MODULE_PATH = r\"$\" -WRAPPER_RUNTIME_LIBRARY_PATHS = [ -${_runtime_metadata_entries}] -") -file(GENERATE - OUTPUT "${_package_dir}/_wrapper_build.py" - CONTENT "${_wrapper_metadata}") + fixture_packaged + ${_additional_runtime_targets}) set(PROJECT_NAME fixture_package) set(PROJECT_VERSION 1.0.0) @@ -253,6 +362,90 @@ string(CONFIGURE @ONLY) file(WRITE "${_fixture_source}/CMakeLists.txt" "${_fixture_cmake}") +# A CMake install destination must remain below the user-selected prefix. +_run_failure( + "Reject an absolute CMake Python library destination" + "CMAKE_INSTALL_LIBDIR must be relative" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_absolute_libdir_build}" + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DCMAKE_INSTALL_LIBDIR=${TEST_BINARY_ROOT}/absolute_lib) + +# Runtime collision validation uses the filenames resolved for the active +# configuration and must complete before the flat staging directory is changed. +_run_step( + "Configure colliding Python runtime destinations" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_runtime_collision_build}" + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DFIXTURE_RUNTIME_COLLISION=ON) +_run_failure( + "Reject colliding Python runtime destinations before staging" + "Python runtime destination collision" + "${CMAKE_COMMAND}" + --build "${_runtime_collision_build}" + --target fixture_package + --parallel 4) +file(GLOB + _collision_staged_files + "${_runtime_collision_build}/python/fixture_package/*colliding_runtime*") +if(_collision_staged_files) + message(FATAL_ERROR + "Runtime collision staging left partial artifacts: " + "${_collision_staged_files}") +endif() + +# The wrapper module and declared runtimes also share the same flat namespace. +_run_step( + "Configure a runtime that collides with the wrapper extension" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_wrapper_collision_build}" + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DFIXTURE_WRAPPER_COLLISION=ON) +_run_failure( + "Reject a runtime that collides with the wrapper extension" + "Python runtime destination collision" + "${CMAKE_COMMAND}" + --build "${_wrapper_collision_build}" + --target fixture_package + --parallel 4) + +if(UNIX AND NOT APPLE) + _run_step( + "Configure a target-file versus SONAME collision" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_soname_collision_build}" + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DFIXTURE_SONAME_COLLISION=ON) + _run_failure( + "Reject a target-file versus SONAME collision" + "Python runtime destination collision" + "${CMAKE_COMMAND}" + --build "${_soname_collision_build}" + --target fixture_package + --parallel 4) +endif() + +# Actual active-configuration filenames make generator-expression output names +# safe to validate without approximating CMake's naming rules. +_run_step( + "Configure generator-expression runtime output names" + "${CMAKE_COMMAND}" + -S "${_fixture_source}" + -B "${_generator_output_build}" + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DFIXTURE_GENERATOR_OUTPUT_NAME=ON) +_run_step( + "Build generator-expression runtime output names" + "${CMAKE_COMMAND}" + --build "${_generator_output_build}" + --target fixture_package + --parallel 4) + # 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" diff --git a/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake b/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake index c5909a5..bee5da4 100644 --- a/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake +++ b/tests/cmake/VerifyTemplateProjectReleaseTagSync.cmake @@ -1,5 +1,7 @@ cmake_minimum_required(VERSION 3.15) +# Exercise tag-safe metadata synchronization and canonical source packaging in +# a disposable clone without mutating the repository under test. foreach(required_var TEST_TEMPLATE_SOURCE_DIR TEST_BINARY_ROOT) if(NOT DEFINED ${required_var}) message(FATAL_ERROR "Missing required variable: ${required_var}") @@ -225,21 +227,50 @@ _run_success( -DEXPECTED_VERSION=${_synthetic_version} -P "${_scratch_verifier}") -set(_release_build "${TEST_BINARY_ROOT}/release_build") +set(_release_build "${_scratch_root}/generated/current_output") set(_archive_output "${TEST_BINARY_ROOT}/archive_output") set(_archive_extract "${TEST_BINARY_ROOT}/archive_extract") file(MAKE_DIRECTORY "${_scratch_root}/build_release_sentinel" + "${_scratch_root}/build_transient" "${_scratch_root}/examples/build_release_sentinel" + "${_scratch_root}/examples/foreign_build" + "${_scratch_root}/examples/install" + "${_scratch_root}/tools/build_helpers" "${_scratch_root}/ros2/build/generated" "${_scratch_root}/ros2/install/generated" "${_scratch_root}/ros2/log/generated" "${_archive_output}" "${_archive_extract}") file(WRITE "${_scratch_root}/build_release_sentinel/must_not_ship.txt" "generated build output\n") +file(CREATE_LINK + "${_scratch_root}/missing-transient-cache" + "${_scratch_root}/build_transient/CMakeCache.txt" + SYMBOLIC + RESULT _transient_cache_link_result) +if(NOT _transient_cache_link_result STREQUAL "0") + message(FATAL_ERROR + "Could not create transient-cache regression fixture: " + "${_transient_cache_link_result}") +endif() file(WRITE "${_scratch_root}/examples/build_release_sentinel/must_not_ship.txt" "generated nested build output\n") +file(WRITE + "${_scratch_root}/examples/build_release_sentinel/CMakeCache.txt" + "CMAKE_HOME_DIRECTORY:INTERNAL=${_scratch_root}\n") +file(WRITE + "${_scratch_root}/examples/foreign_build/CMakeCache.txt" + "CMAKE_HOME_DIRECTORY:INTERNAL=${TEST_BINARY_ROOT}/foreign_source\n") +file(WRITE + "${_scratch_root}/examples/foreign_build/must_ship.txt" + "foreign child-project cache\n") +file(WRITE + "${_scratch_root}/examples/install/must_ship.txt" + "legitimate nested install source\n") +file(WRITE + "${_scratch_root}/tools/build_helpers/must_ship.txt" + "legitimate nested source\n") file(WRITE "${_scratch_root}/ros2/build/generated/must_not_ship.txt" "generated ROS build output\n") file(WRITE "${_scratch_root}/ros2/install/generated/must_not_ship.txt" "generated ROS install output\n") file(WRITE "${_scratch_root}/ros2/log/generated/must_not_ship.txt" "generated ROS log output\n") @@ -287,6 +318,29 @@ if(NOT _extracted_root_count EQUAL 1) endif() list(GET _extracted_roots 0 _extracted_root) +# Generated trees are identified by ownership evidence, not by broad directory +# names that can also describe legitimate project sources. +if(EXISTS "${_extracted_root}/examples/build_release_sentinel") + message(FATAL_ERROR + "Canonical source archive contains a nested generated build tree") +endif() +if(EXISTS "${_extracted_root}/generated/current_output") + message(FATAL_ERROR + "Canonical source archive contains the active nested binary tree") +endif() +if(NOT EXISTS "${_extracted_root}/examples/foreign_build/must_ship.txt") + message(FATAL_ERROR + "Canonical source archive omitted a foreign child-project cache") +endif() +if(NOT EXISTS "${_extracted_root}/examples/install/must_ship.txt") + message(FATAL_ERROR + "Canonical source archive omitted legitimate nested install content") +endif() +if(NOT EXISTS "${_extracted_root}/tools/build_helpers/must_ship.txt") + message(FATAL_ERROR + "Canonical source archive omitted legitimate nested source content") +endif() + _run_success( "Validate extracted no-Git canonical source" "${CMAKE_COMMAND}" @@ -312,7 +366,12 @@ _run_failure( file(REMOVE_RECURSE "${_scratch_root}/build_release_sentinel" + "${_scratch_root}/build_transient" "${_scratch_root}/examples/build_release_sentinel" + "${_scratch_root}/examples/foreign_build" + "${_scratch_root}/examples/install" + "${_scratch_root}/generated" + "${_scratch_root}/tools" "${_scratch_root}/ros2/build" "${_scratch_root}/ros2/install" "${_scratch_root}/ros2/log") diff --git a/tests/cmake/VerifyTemplateProjectTailoringScript.cmake b/tests/cmake/VerifyTemplateProjectTailoringScript.cmake index 5df51a2..6c9affd 100644 --- a/tests/cmake/VerifyTemplateProjectTailoringScript.cmake +++ b/tests/cmake/VerifyTemplateProjectTailoringScript.cmake @@ -108,6 +108,7 @@ endif() function(_create_fake_project fake_root) file(MAKE_DIRECTORY "${fake_root}/.github/workflows") + file(MAKE_DIRECTORY "${fake_root}/cmake") file(MAKE_DIRECTORY "${fake_root}/doc/developments") file(MAKE_DIRECTORY "${fake_root}/doc/reports") file(MAKE_DIRECTORY "${fake_root}/tests/cmake") @@ -126,6 +127,16 @@ function(_create_fake_project fake_root) file(WRITE "${fake_root}/build_ros2.sh" "#!/usr/bin/env bash\n") file(WRITE "${fake_root}/add_ros2_support.sh" "#!/usr/bin/env bash\n") file(WRITE "${fake_root}/generate_version.sh" "#!/usr/bin/env bash\n") + foreach(_production_wrapper_module + "HandleWrapper.cmake" + "HandlePythonWrapper.cmake" + "HandleMatlabWrapper.cmake" + "StagePythonRuntimeArtifacts.cmake") + configure_file( + "${TEST_TEMPLATE_SOURCE_DIR}/cmake/${_production_wrapper_module}" + "${fake_root}/cmake/${_production_wrapper_module}" + COPYONLY) + endforeach() file(WRITE "${fake_root}/src/utils/logging/CLogger.h" "namespace template_project::logging { class CLogger; }\n") file(WRITE "${fake_root}/src/utils/logging/CLogger.cpp" @@ -335,6 +346,25 @@ function(_assert_fake_project_cleaned fake_root expect_profiling) _assert_mode("${fake_root}/src/utils/logging/CLogger.h" "640") _assert_mode("${fake_root}/doc/logging.md" "444") + foreach(_production_wrapper_module + "HandleWrapper.cmake" + "HandlePythonWrapper.cmake" + "HandleMatlabWrapper.cmake" + "StagePythonRuntimeArtifacts.cmake") + set(_retained_wrapper_module + "${fake_root}/cmake/${_production_wrapper_module}") + if(NOT EXISTS "${_retained_wrapper_module}") + message(FATAL_ERROR + "Cleanup removed production wrapper module " + "'cmake/${_production_wrapper_module}'") + endif() + _run_step( + "Compare retained production wrapper module ${_production_wrapper_module}" + "${CMAKE_COMMAND}" -E compare_files + "${TEST_TEMPLATE_SOURCE_DIR}/cmake/${_production_wrapper_module}" + "${_retained_wrapper_module}") + endforeach() + foreach(_workflow_name build_linux.yml build_linux_cuda.yml docs_pages.yml) set(_materialized_workflow "${fake_root}/.github/workflows/${_workflow_name}") set(_workflow_template "${fake_root}/.github/workflows/${_workflow_name}.tpl") From 480d10a692836040bcae2023e763c553acfcc64d Mon Sep 17 00:00:00 2001 From: PeterC Date: Tue, 28 Jul 2026 12:55:04 +0200 Subject: [PATCH 9/9] Add missing ros 2 manifets update --- ros2/template_project/package.xml | 2 +- ros2/template_project_interfaces/package.xml | 2 +- ros2/template_project_ros/package.xml | 2 +- ros2/template_project_spinup/package.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ros2/template_project/package.xml b/ros2/template_project/package.xml index 82eb4a7..d201ac5 100644 --- a/ros2/template_project/package.xml +++ b/ros2/template_project/package.xml @@ -2,7 +2,7 @@ template_project - 1.12.0 + 1.12.1 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 colcon shim package. Pietro Califano MIT diff --git a/ros2/template_project_interfaces/package.xml b/ros2/template_project_interfaces/package.xml index 3c7a430..d9328da 100644 --- a/ros2/template_project_interfaces/package.xml +++ b/ros2/template_project_interfaces/package.xml @@ -2,7 +2,7 @@ template_project_interfaces - 1.12.0 + 1.12.1 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 message and service interfaces. Pietro Califano MIT diff --git a/ros2/template_project_ros/package.xml b/ros2/template_project_ros/package.xml index 8c19775..ef818bc 100644 --- a/ros2/template_project_ros/package.xml +++ b/ros2/template_project_ros/package.xml @@ -2,7 +2,7 @@ template_project_ros - 1.12.0 + 1.12.1 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 bridge package. Pietro Califano MIT diff --git a/ros2/template_project_spinup/package.xml b/ros2/template_project_spinup/package.xml index 48cdc1a..2fd907a 100644 --- a/ros2/template_project_spinup/package.xml +++ b/ros2/template_project_spinup/package.xml @@ -2,7 +2,7 @@ template_project_spinup - 1.12.0 + 1.12.1 Reusable C++/CUDA library template with optional CUDA, OptiX, Python, MATLAB, and ROS 2 support: ROS 2 launch and runtime assets. Pietro Califano MIT