diff --git a/.github/scripts/expected-versions.sh b/.github/scripts/expected-versions.sh new file mode 100755 index 0000000..74f194f --- /dev/null +++ b/.github/scripts/expected-versions.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# CI-only: print the version strings the build is expected to embed, as `key=value` lines suitable for +# appending to $GITHUB_OUTPUT. Used to assert requirement 4 (dual-version reporting). +# +# mrtrix=<...> replicates MRtrix3's cmake/FindVersion.cmake exactly: `git describe --abbrev=8 +# --dirty --always` when a tag is reachable, else the MRtrix3 base version. +# project=<...> the project version recorded by splice-project.sh (git describe of the project src); +# this is exactly what the build embeds via cmake/project_version.cpp.in. +# +# Usage: expected-versions.sh +set -euo pipefail + +MR_SRC="$1" + +tag="$(git -C "$MR_SRC" describe --abbrev=0 2>/dev/null || true)" +commit="$(git -C "$MR_SRC" describe --abbrev=8 --dirty --always 2>/dev/null || true)" +if [ -n "$tag" ] && [ -n "$commit" ]; then + mrtrix="$commit" +else + # Fall back to the MRtrix3 base version declared in its top-level project(). + mrtrix="$(grep -m1 -oE 'VERSION [0-9]+\.[0-9]+(\.[0-9]+)?' "$MR_SRC/CMakeLists.txt" | awk '{print $2}')" +fi + +project="" +if [ -f cmake/spliced_project_version.txt ]; then + project="$(head -n1 cmake/spliced_project_version.txt)" +fi + +echo "mrtrix=$mrtrix" +echo "project=$project" diff --git a/.github/scripts/shape-scenario.sh b/.github/scripts/shape-scenario.sh new file mode 100755 index 0000000..c6d6104 --- /dev/null +++ b/.github/scripts/shape-scenario.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# CI-only: shape the spliced demo tree into one of the test scenarios. All demo-specific knowledge +# (command names, the demo's MRtrix3 dependencies) lives here, inside .github/, so the template proper +# stays project-agnostic. +# +# Environment: +# CMDFILE none | subset | all | pydep -> what to write into cmake/mrtrix3_commands.txt +# (pydep lists a single MRtrix3 *Python* command, named by PYDEP_CMD; this exercises +# the "Python dependency -> build the full C++ set" behaviour) +# PYDEP_CMD name of the MRtrix3 Python command to request when CMDFILE=pydep (default dwishellmath) +# DROP_PYTHON "true" to remove the demo's Python command (yields a no-Python, single-binary project) +# MULTI "true" to add a second C++ command. The demo's own core is split across a header and a +# COMPILED source (cpp/core/algo/geometric_mean.cpp), shared by both C++ commands; with +# more than one command sharing it the build collects that core into a branded +# lib.so (over and above the MRtrix3 core's libmrtrix-core.so). A single command +# instead compiles the same core straight into the binary (no project library). +set -euo pipefail + +: "${CMDFILE:=none}" + +: "${PYDEP_CMD:=dwishellmath}" + +case "$CMDFILE" in + none) : > cmake/mrtrix3_commands.txt ;; + subset) printf 'mrconvert\nmrcalc\nmrmath\n' > cmake/mrtrix3_commands.txt ;; + all) printf '*\n' > cmake/mrtrix3_commands.txt ;; + pydep) printf '%s\n' "$PYDEP_CMD" > cmake/mrtrix3_commands.txt ;; + *) echo "shape-scenario: unknown CMDFILE='$CMDFILE'" >&2; exit 2 ;; +esac +echo "shape-scenario: MRtrix3 command file ($CMDFILE):"; sed 's/^/ /' cmake/mrtrix3_commands.txt + +if [ "${DROP_PYTHON:-}" = "true" ]; then + rm -rf python/demo_code/commands/demo_python python/demo_code/commands/demo_python.py + echo "shape-scenario: removed the demo Python command (no-Python project)" +fi + +if [ "${MULTI:-}" = "true" ]; then + # A second C++ command that shares the demo's compiled core (geometric_mean.cpp, spliced from the + # demo source). Two commands sharing that real core source is what makes the build collect it into a + # branded lib.so - no CI-synthesised core source is needed any more. + cp cpp/cmd/demo_cpp.cpp cpp/cmd/demo_cpp2.cpp + echo "shape-scenario: added demo_cpp2 (second C++ command sharing the demo's compiled core)" + if [ -f cpp/core/algo/geometric_mean.cpp ]; then + echo "shape-scenario: project compiled core present: cpp/core/algo/geometric_mean.cpp" + else + echo "shape-scenario: ERROR: expected demo compiled core cpp/core/algo/geometric_mean.cpp not found" >&2 + echo "shape-scenario: (is DEMO_CODE_REF pinned to a revision that splits geometric_mean.h/.cpp?)" >&2 + exit 1 + fi +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d6962a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,468 @@ +name: CI + +# This repository is a *scaffold-only* template: it provides the external-project build system but no +# command source, and it names no specific project (the project name comes from a project.cmake +# metadata file supplied by the project repo). Each job therefore: +# 1. SPLICES an example project's source onto this checkout (the demo_code repository's dev branch: +# cpp/cmd/demo_cpp.cpp, cpp/core/algo/geometric_mean.{h,cpp}, python/demo_code/commands/demo_python.py). +# Note the demo core is split across a header (the GeometricMean declaration) and a COMPILED +# source (geometric_mean.cpp). With more than one project C++ command sharing that core, the build +# therefore emits a project-specific shared library lib.so over and above the MRtrix3 +# core's libmrtrix-core.so in the nested MRtrix3 subtree (asserted in the multi-* scenarios); +# 2. writes the demo's metadata (project.cmake naming "demo_code", and the MRtrix3 command list) - +# these live ONLY here, keeping the template itself free of demo references; +# 3. configures a particular scenario and asserts its contracts via testing/verify.sh: +# - linkage: single total C++ binary -> static self-contained; more -> shared libmrtrix-core.so +# - command set: a subset / none / all of MRtrix3's commands are built, exactly as requested +# - no-shadow: the project's bin never exposes an MRtrix3 command; run.command() stays version-matched +# - versions: a command reports both the project version and the MRtrix3 version it was built against +# +# Scenarios (cmd config): +# single-none : 1 C++ command, no Python command, NO MRtrix3 commands -> static, self-contained +# single-subset : 1 C++ command + 1 Python command + 3 MRtrix3 commands -> shared (total = 4) +# multi-subset : 2 C++ commands (+ a synthesised project core) + Python + 3 MRtrix3 commands +# -> shared + lib.so +# all : every MRtrix3 command (heavy; manual dispatch only) +# +# NOTE: depends on MRtrix3's external-project support, so MRTRIX3_GIT_TAG must point at an MRtrix3 +# revision that includes it (>= 3.1.0). Override any of the variables below via repo/org variables. + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +env: + MRTRIX3_GIT_TAG: ${{ vars.MRTRIX3_GIT_TAG || '2563046d4d564df6244996f0277ae38838bba759' }} + DEMO_CODE_REPO: ${{ vars.DEMO_CODE_REPO || 'MRtrix3/MRtrix3_demo_code' }} + DEMO_CODE_REF: ${{ vars.DEMO_CODE_REF || '82bee9e00245077bffeb4e49f5217476cc27fc90' }} + +jobs: + # ================================================================================================ + # Vendored MRtrix3 (fetch + build a pinned MRtrix3 as part of the project), across run modes and + # command configurations. This is where the static-vs-shared and command-set contracts are exercised. + # ================================================================================================ + vendored: + name: vendored (${{ matrix.config }}, ${{ matrix.run_mode }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + run_mode: [ build-dir, installed ] + config: [ single-none, single-subset, multi-subset ] + include: + - config: single-none + cmdfile: none + drop_python: "true" + linkage: static + mrtrix_cmds: none + cpp_cmds: demo_cpp + py_cmds: "" + expect_project_lib: "no" + - config: single-subset + cmdfile: subset + linkage: shared + mrtrix_cmds: mrconvert mrcalc mrmath + cpp_cmds: demo_cpp + py_cmds: demo_python + expect_project_lib: "no" + - config: multi-subset + cmdfile: subset + multi: "true" + extra_flags: -DCMAKE_EXE_LINKER_FLAGS=-Wl,--no-as-needed + linkage: shared + mrtrix_cmds: mrconvert mrcalc mrmath + cpp_cmds: demo_cpp demo_cpp2 + py_cmds: demo_python + expect_project_lib: "yes" + # The demo core symbol split out of geometric_mean.h into geometric_mean.cpp: it must end + # up DEFINED in libdemo_code.so (and not in libmrtrix-core.so). See testing/verify.sh. + project_lib_symbol: GeometricMean + steps: + - name: Check out the template + uses: actions/checkout@v4 + - name: Check out the example project source + uses: actions/checkout@v4 + with: + repository: ${{ env.DEMO_CODE_REPO }} + ref: ${{ env.DEMO_CODE_REF }} + path: _project_src + - name: Splice project source into the template + run: bash testing/splice-project.sh _project_src + - name: Write demo project metadata (CI-only; the template names no project) + run: printf 'set(MRTRIX_PROJECT_NAME demo_code)\n' > project.cmake + - name: Shape the scenario (command list + synthesised sources) + run: bash .github/scripts/shape-scenario.sh + env: + CMDFILE: ${{ matrix.cmdfile }} + DROP_PYTHON: ${{ matrix.drop_python }} + MULTI: ${{ matrix.multi }} + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git binutils \ + zlib1g-dev libfftw3-dev libpng-dev libeigen3-dev python3 + - name: Configure and build + run: | + cmake -S . -B build -G Ninja \ + -DPROJECT_MRTRIX3_SOURCE=VENDORED \ + -DMRTRIX3_GIT_TAG="${MRTRIX3_GIT_TAG}" \ + -DMRTRIX_USE_SYSTEM_EIGEN=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PWD}/stage" \ + ${{ matrix.extra_flags }} + cmake --build build -j "$(nproc)" + - name: Select runtime location + id: rt + run: | + if [ "${{ matrix.run_mode }}" = installed ]; then + cmake --install build + echo "bindir=${PWD}/stage/bin" >> "$GITHUB_OUTPUT" + else + echo "bindir=${PWD}/build/bin" >> "$GITHUB_OUTPUT" + fi + - name: Compute expected versions + id: ver + run: bash .github/scripts/expected-versions.sh build/_deps/mrtrix3-src >> "$GITHUB_OUTPUT" + - name: Verify + run: | + bash testing/verify.sh \ + --bindir "${{ steps.rt.outputs.bindir }}" \ + --linkage "${{ matrix.linkage }}" \ + --mrtrix-cmds "${{ matrix.mrtrix_cmds }}" \ + --cpp-cmds "${{ matrix.cpp_cmds }}" \ + --py-cmds "${{ matrix.py_cmds }}" \ + --project-name demo_code \ + --expect-project-lib "${{ matrix.expect_project_lib }}" \ + --project-lib-symbol "${{ matrix.project_lib_symbol }}" \ + --project-version "${{ steps.ver.outputs.project }}" \ + --mrtrix-version "${{ steps.ver.outputs.mrtrix }}" \ + --mode "${{ matrix.run_mode }}" + + # ================================================================================================ + # Link against the build tree of an EXISTING MRtrix3 checkout (built once with a fixed subset). + # The MRtrix3 core's static/shared form is fixed by that external build (default: shared), so the + # static-single scenario is not meaningful here; we exercise the shared scenarios. + # ================================================================================================ + existing: + name: existing build tree (${{ matrix.config }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + config: [ single-subset, multi-subset ] + include: + - config: single-subset + cmdfile: subset + linkage: shared + mrtrix_cmds: mrconvert mrcalc mrmath + cpp_cmds: demo_cpp + py_cmds: demo_python + expect_project_lib: "no" + - config: multi-subset + cmdfile: subset + multi: "true" + extra_flags: -DCMAKE_EXE_LINKER_FLAGS=-Wl,--no-as-needed + linkage: shared + mrtrix_cmds: mrconvert mrcalc mrmath + cpp_cmds: demo_cpp demo_cpp2 + py_cmds: demo_python + expect_project_lib: "yes" + project_lib_symbol: GeometricMean + steps: + - name: Check out the template + uses: actions/checkout@v4 + - name: Check out the example project source + uses: actions/checkout@v4 + with: + repository: ${{ env.DEMO_CODE_REPO }} + ref: ${{ env.DEMO_CODE_REF }} + path: _project_src + - name: Splice project source into the template + run: bash testing/splice-project.sh _project_src + - name: Write demo project metadata (CI-only) + run: printf 'set(MRTRIX_PROJECT_NAME demo_code)\n' > project.cmake + - name: Shape the scenario + run: bash .github/scripts/shape-scenario.sh + env: + CMDFILE: ${{ matrix.cmdfile }} + MULTI: ${{ matrix.multi }} + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git binutils \ + zlib1g-dev libfftw3-dev libpng-dev libeigen3-dev python3 + - name: Build a standalone MRtrix3 (generates the build-tree package export) + run: | + git clone https://github.com/MRtrix3/mrtrix3.git mrtrix3-src + git -C mrtrix3-src checkout "${MRTRIX3_GIT_TAG}" + cmake -S mrtrix3-src -B mrtrix3-build -G Ninja \ + -DMRTRIX_BUILD_GUI=OFF -DMRTRIX_ENABLE_GPU=OFF -DMRTRIX_BUILD_TESTS=OFF \ + -DMRTRIX_USE_SYSTEM_EIGEN=ON -DCMAKE_BUILD_TYPE=Release \ + -DMRTRIX_IGNORE_VERSION_MISMATCH=ON \ + -DMRTRIX_CMD_SUBSET="mrconvert;mrcalc;mrmath" + cmake --build mrtrix3-build -j "$(nproc)" + - name: Configure and build the project against the existing MRtrix3 + run: | + cmake -S . -B build-existing -G Ninja \ + -DPROJECT_MRTRIX3_SOURCE=EXISTING \ + -DMRTRIX3_SOURCE_DIR="${PWD}/mrtrix3-src" \ + -DMRTRIX3_BUILD_DIR="${PWD}/mrtrix3-build" \ + -DCMAKE_BUILD_TYPE=Release \ + ${{ matrix.extra_flags }} + cmake --build build-existing -j "$(nproc)" + - name: Compute expected versions + id: ver + run: bash .github/scripts/expected-versions.sh mrtrix3-src >> "$GITHUB_OUTPUT" + - name: Verify + run: | + bash testing/verify.sh \ + --bindir "${PWD}/build-existing/bin" \ + --linkage "${{ matrix.linkage }}" \ + --mrtrix-cmds "${{ matrix.mrtrix_cmds }}" \ + --cpp-cmds "${{ matrix.cpp_cmds }}" \ + --py-cmds "${{ matrix.py_cmds }}" \ + --project-name demo_code \ + --expect-project-lib "${{ matrix.expect_project_lib }}" \ + --project-lib-symbol "${{ matrix.project_lib_symbol }}" \ + --project-version "${{ steps.ver.outputs.project }}" \ + --mrtrix-version "${{ steps.ver.outputs.mrtrix }}" \ + --mode build-dir + + # ================================================================================================ + # Build the ENTIRE MRtrix3 command set ("no subset specified"). This is heavy (builds all commands), + # so it runs only on manual dispatch. + # ================================================================================================ + all-commands: + name: vendored (all MRtrix3 commands) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + steps: + - name: Check out the template + uses: actions/checkout@v4 + - name: Check out the example project source + uses: actions/checkout@v4 + with: + repository: ${{ env.DEMO_CODE_REPO }} + ref: ${{ env.DEMO_CODE_REF }} + path: _project_src + - name: Splice project source into the template + run: bash testing/splice-project.sh _project_src + - name: Write demo project metadata (CI-only) + run: printf 'set(MRTRIX_PROJECT_NAME demo_code)\n' > project.cmake + - name: Shape the scenario (request the full command set) + run: bash .github/scripts/shape-scenario.sh + env: + CMDFILE: all + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git binutils \ + zlib1g-dev libfftw3-dev libpng-dev libeigen3-dev python3 + - name: Configure and build + run: | + cmake -S . -B build -G Ninja \ + -DPROJECT_MRTRIX3_SOURCE=VENDORED \ + -DMRTRIX3_GIT_TAG="${MRTRIX3_GIT_TAG}" \ + -DMRTRIX_USE_SYSTEM_EIGEN=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PWD}/stage" + cmake --build build -j "$(nproc)" + - name: Compute expected versions + id: ver + run: bash .github/scripts/expected-versions.sh build/_deps/mrtrix3-src >> "$GITHUB_OUTPUT" + - name: Verify + run: | + bash testing/verify.sh \ + --bindir "${PWD}/build/bin" \ + --linkage shared \ + --mrtrix-cmds all \ + --cpp-cmds demo_cpp \ + --py-cmds demo_python \ + --project-name demo_code \ + --expect-project-lib no \ + --project-version "${{ steps.ver.outputs.project }}" \ + --mrtrix-version "${{ steps.ver.outputs.mrtrix }}" \ + --mode build-dir + + # ================================================================================================ + # MRtrix3 Python command requested as a dependency. The project lists a single MRtrix3 *Python* + # command (dwishellmath) in cmake/mrtrix3_commands.txt. Because that Python command may invoke any + # MRtrix3 C++ command at run time and cmake cannot see those run.command() calls, requesting it must + # build the ENTIRE MRtrix3 C++ command set (MRtrix3's cmake detects a Python command in the subset + # and clears the restriction). This job asserts that the full C++ set IS present in the MRtrix3 + # subtree bin/, and that the requested Python command is itself built and version-matched. + # + # Heavy (builds all C++ commands), so manual dispatch only - like all-commands. + # ================================================================================================ + python-dep: + name: vendored (MRtrix3 Python-command dependency -> full C++ set) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + env: + PYDEP_CMD: dwishellmath + steps: + - name: Check out the template + uses: actions/checkout@v4 + - name: Check out the example project source + uses: actions/checkout@v4 + with: + repository: ${{ env.DEMO_CODE_REPO }} + ref: ${{ env.DEMO_CODE_REF }} + path: _project_src + - name: Splice project source into the template + run: bash testing/splice-project.sh _project_src + - name: Write demo project metadata (CI-only) + run: printf 'set(MRTRIX_PROJECT_NAME demo_code)\n' > project.cmake + - name: Shape the scenario (request a single MRtrix3 Python command) + run: bash .github/scripts/shape-scenario.sh + env: + CMDFILE: pydep + PYDEP_CMD: ${{ env.PYDEP_CMD }} + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git binutils \ + zlib1g-dev libfftw3-dev libpng-dev libeigen3-dev python3 + - name: Configure and build + run: | + cmake -S . -B build -G Ninja \ + -DPROJECT_MRTRIX3_SOURCE=VENDORED \ + -DMRTRIX3_GIT_TAG="${MRTRIX3_GIT_TAG}" \ + -DMRTRIX_USE_SYSTEM_EIGEN=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="${PWD}/stage" + cmake --build build -j "$(nproc)" + - name: Compute expected versions + id: ver + run: bash .github/scripts/expected-versions.sh build/_deps/mrtrix3-src >> "$GITHUB_OUTPUT" + - name: Verify (full C++ set present; Python dependency built + version-matched) + run: | + bash testing/verify.sh \ + --bindir "${PWD}/build/bin" \ + --linkage shared \ + --mrtrix-cmds all \ + --mrtrix-py-cmds "${PYDEP_CMD}" \ + --cpp-cmds demo_cpp \ + --py-cmds demo_python \ + --project-name demo_code \ + --expect-project-lib no \ + --project-version "${{ steps.ver.outputs.project }}" \ + --mrtrix-version "${{ steps.ver.outputs.mrtrix }}" \ + --mode build-dir + + # ================================================================================================ + # Containerised build + run (Docker). The image tag / install prefix are CI labels; the project name + # and commands come from the spliced source and the metadata written above. + # ================================================================================================ + container: + name: container (docker) + runs-on: ubuntu-24.04 + steps: + - name: Check out the template + uses: actions/checkout@v4 + - name: Check out the example project source + uses: actions/checkout@v4 + with: + repository: ${{ env.DEMO_CODE_REPO }} + ref: ${{ env.DEMO_CODE_REF }} + path: _project_src + - name: Splice project source into the build context + run: bash testing/splice-project.sh _project_src + - name: Write demo project metadata + command list (CI-only) + run: | + printf 'set(MRTRIX_PROJECT_NAME demo_code)\n' > project.cmake + printf 'mrconvert\nmrcalc\nmrmath\n' > cmake/mrtrix3_commands.txt + - name: Build image + run: docker build -t demo_code --build-arg MRTRIX3_GIT_TAG="${MRTRIX3_GIT_TAG}" -f containers/Dockerfile . + - name: Run commands in the container + run: | + docker run --rm demo_code demo_cpp --version | grep -qi "against MRtrix" + docker run --rm -v "${PWD}/testing:/t:ro" demo_code bash -lc ' + python3 /t/make_test_image.py /tmp/in.nii + demo_cpp /tmp/in.nii /tmp/out_cpp.mif && test -s /tmp/out_cpp.mif + demo_python /tmp/in.nii /tmp/out_py.mif && test -s /tmp/out_py.mif + test ! -e /opt/project/bin/mrconvert + echo CONTAINER-OK' + + # ================================================================================================ + # Containerised build + run (Apptainer/Singularity). Mirrors the Docker container job but, instead + # of a bespoke smoke test, runs the project's FULL verification suite (testing/verify.sh) INSIDE the + # container - the same script, and the same contracts, as the vendored/existing jobs - against the + # installed tree at /opt/project. The scenario is single-subset: one C++ (demo_cpp) and one Python + # (demo_python) command over the mrconvert/mrcalc/mrmath MRtrix3 subset, shared linkage, no project + # library; verify.sh asserts dual-version reporting, the exact command set, and the no-shadow / + # resolution rules from within the container. + # + # verify.sh introspects ELF files (readelf/nm), which the deliberately-lean runtime image omits, so a + # thin CI-only image layers binutils onto the project image (Bootstrap: localimage) - the published + # apptainer.def runtime stays minimal. Apptainer is installed from its official PPA, and the image is + # built and executed as root via sudo, sidestepping Ubuntu 24.04's restriction on unprivileged user + # namespaces. The MRtrix3 revision is overridden to match the rest of CI via the recipe's build-arg. + # ================================================================================================ + apptainer: + name: container (apptainer) + runs-on: ubuntu-24.04 + steps: + - name: Check out the template + uses: actions/checkout@v4 + - name: Check out the example project source + uses: actions/checkout@v4 + with: + repository: ${{ env.DEMO_CODE_REPO }} + ref: ${{ env.DEMO_CODE_REF }} + path: _project_src + - name: Splice project source into the build context + run: bash testing/splice-project.sh _project_src + - name: Write demo project metadata + command list (CI-only) + run: | + printf 'set(MRTRIX_PROJECT_NAME demo_code)\n' > project.cmake + printf 'mrconvert\nmrcalc\nmrmath\n' > cmake/mrtrix3_commands.txt + - name: Record the expected project version (embedded by the build) + id: ver + run: | + project="" + [ -f cmake/spliced_project_version.txt ] && project="$(head -n1 cmake/spliced_project_version.txt)" + echo "project=$project" >> "$GITHUB_OUTPUT" + - name: Install Apptainer (official PPA) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends software-properties-common + sudo add-apt-repository -y ppa:apptainer/ppa + sudo apt-get update + sudo apt-get install -y apptainer + - name: Build the Apptainer image from containers/apptainer.def + run: | + sudo apptainer build --build-arg MRTRIX3_GIT_TAG="${MRTRIX3_GIT_TAG}" \ + project.sif containers/apptainer.def + - name: Layer the verification toolchain (binutils) onto the image (CI-only) + run: | + cat > "${RUNNER_TEMP}/verify.def" </commands/ against +# the MRtrix3 API. MRtrix3 itself is obtained either by VENDORING a pinned version (fetched and built +# as part of this project) or by linking against the build tree of an EXISTING MRtrix3 checkout. +# +# The project's identity is NOT hardcoded here: it is read from a small metadata file (project.cmake) +# that the project repository supplies and splice-project.sh overlays alongside the source. This keeps +# the scaffold project-agnostic. For standalone template use (no splice) a neutral default is used. +# =================================================================================================== + +# --- Project identity (must be resolved BEFORE project()) ----------------------------------------- +# project.cmake (supplied by the project repo) is expected to set: +# set(MRTRIX_PROJECT_NAME ) # required; names the C++ project and the Python package +# set(MRTRIX_PROJECT_VERSION ) # optional; only a fallback - the reported version still +# # prefers git provenance (see PROJECT_VERSION_STRING below) +set(MRTRIX_PROJECT_NAME "mrtrix3_project" CACHE STRING "External project name (overridden by project.cmake)") +set(MRTRIX_PROJECT_VERSION "0.0.0" CACHE STRING "External project fallback version (overridden by project.cmake)") +include("${CMAKE_CURRENT_SOURCE_DIR}/project.cmake" OPTIONAL) + +project(${MRTRIX_PROJECT_NAME} LANGUAGES CXX VERSION ${MRTRIX_PROJECT_VERSION}) + +include(GNUInstallDirs) + +# --- How to obtain MRtrix3 ------------------------------------------------------------------------ +set(PROJECT_MRTRIX3_SOURCE "VENDORED" CACHE STRING "How to obtain MRtrix3: VENDORED or EXISTING") +set_property(CACHE PROJECT_MRTRIX3_SOURCE PROPERTY STRINGS VENDORED EXISTING) + +set(MRTRIX3_GIT_REPOSITORY "https://github.com/MRtrix3/mrtrix3.git" + CACHE STRING "MRtrix3 git repository (VENDORED, when MRTRIX3_SOURCE_DIR is unset)") +set(MRTRIX3_GIT_TAG "dev" CACHE STRING "MRtrix3 git tag/commit to pin (VENDORED)") +set(MRTRIX3_SOURCE_DIR "" CACHE PATH + "Local MRtrix3 source directory; VENDORED builds from it directly, EXISTING reads its headers") +set(MRTRIX3_BUILD_DIR "" CACHE PATH "Existing MRtrix3 build directory (EXISTING mode)") + +# --- Which MRtrix3 commands to build (three-state) ------------------------------------------------- +# cmake/mrtrix3_commands.txt (supplied by the project repo) selects the MRtrix3 commands this project's +# Python commands invoke. Three states: +# * one or more command names -> build exactly those (only these are version-matched) +# * empty / absent -> build NONE (a project with no Python commands; enables a single, +# self-contained static binary) +# * a single line "*" (or "ALL")-> build the ENTIRE MRtrix3 command set +set(PROJECT_MRTRIX3_COMMANDS_FILE "${CMAKE_CURRENT_SOURCE_DIR}/cmake/mrtrix3_commands.txt" + CACHE FILEPATH "File listing the MRtrix3 commands this project requires (one per line)") +set(PROJECT_MRTRIX3_CMD_MODE "NONE") # NONE | LIST | ALL +set(PROJECT_MRTRIX3_CMD_SUBSET "") +if(EXISTS "${PROJECT_MRTRIX3_COMMANDS_FILE}") + file(STRINGS "${PROJECT_MRTRIX3_COMMANDS_FILE}" _mrtrix_cmd_lines) + list(FILTER _mrtrix_cmd_lines EXCLUDE REGEX "^[ \t]*#") + list(FILTER _mrtrix_cmd_lines EXCLUDE REGEX "^[ \t]*$") + string(STRIP "${_mrtrix_cmd_lines}" _mrtrix_cmd_stripped) + if("*" IN_LIST _mrtrix_cmd_lines OR "ALL" IN_LIST _mrtrix_cmd_lines) + set(PROJECT_MRTRIX3_CMD_MODE "ALL") + elseif(_mrtrix_cmd_stripped) + set(PROJECT_MRTRIX3_CMD_MODE "LIST") + set(PROJECT_MRTRIX3_CMD_SUBSET "${_mrtrix_cmd_lines}") + endif() +endif() + +# --- Static vs shared MRtrix3 core: decided by the TOTAL number of C++ binaries --------------------- +# total = this project's C++ commands (cpp/cmd/*.cpp) + the MRtrix3 commands that will be built. +# Exactly one total binary -> static, self-contained (no shared library at all). +# More than one -> a single shared libmrtrix-core.so is built and linked by every binary +# (this project's commands AND the built MRtrix3 commands). +# +# NOTE: if any *MRtrix3 Python* command is requested (in mrtrix3_commands.txt), MRtrix3 itself builds +# its ENTIRE command set (a Python command's C++ dependencies are opaque to cmake) and forces a shared +# core - see docs/python-command-dependencies.md. The subset count below treats each listed name as one +# binary, which is only a lower bound in that case; MRtrix3's own force-shared makes the linkage +# consistent, so the heuristic here never wrongly produces a static single-binary build. +file(GLOB _project_cmd_sources "${CMAKE_CURRENT_SOURCE_DIR}/cpp/cmd/*.cpp") +list(LENGTH _project_cmd_sources PROJECT_NUM_CMDS) +if(PROJECT_MRTRIX3_CMD_MODE STREQUAL "ALL") + set(_num_mrtrix_cmds 9999) # "all" is unconditionally > 1 +elseif(PROJECT_MRTRIX3_CMD_MODE STREQUAL "LIST") + list(LENGTH PROJECT_MRTRIX3_CMD_SUBSET _num_mrtrix_cmds) +else() + set(_num_mrtrix_cmds 0) +endif() +math(EXPR PROJECT_TOTAL_CMDS "${PROJECT_NUM_CMDS} + ${_num_mrtrix_cmds}") +if(PROJECT_TOTAL_CMDS EQUAL 1) + set(PROJECT_LINKAGE "static") +else() + set(PROJECT_LINKAGE "shared") +endif() +message(STATUS "[${PROJECT_NAME}] MRtrix3 command selection: ${PROJECT_MRTRIX3_CMD_MODE} " + "(${PROJECT_MRTRIX3_CMD_SUBSET}); project C++ commands: ${PROJECT_NUM_CMDS}; " + "total C++ binaries -> ${PROJECT_LINKAGE} linkage") + +# --- Install layout ------------------------------------------------------------------------------- +# MRtrix3 is installed as a self-contained subtree at //{bin,lib,share} via +# MRTRIX_SUBPROJECT_INSTALL_PREFIX (see the VENDORED block). This keeps MRtrix3's commands out of +# /bin (so they never shadow a separate MRtrix3 on the user's PATH) while preserving +# MRtrix3's own internal bin<->lib<->share relative paths. This project's own commands install to +# /bin. MRtrix3 assumes its Python library resides in a directory literally named 'lib', so +# pin CMAKE_INSTALL_LIBDIR to keep the build-tree and install-tree layouts identical. +set(PROJECT_MRTRIX3_SUBDIR "mrtrix3" CACHE STRING "Subdirectory under the prefix that holds MRtrix3") +set(CMAKE_INSTALL_LIBDIR "lib" CACHE PATH "" FORCE) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# Per-function/data sections so that static linking of the MRtrix3 core (and --gc-sections at link +# time) pulls in only the objects this project actually references. +string(APPEND CMAKE_CXX_FLAGS " -ffunction-sections -fdata-sections") + +# --- This project's own version (independent of the MRtrix3 version it is built against) ----------- +# Provenance: when source is spliced onto this scaffold, splice-project.sh records the PROJECT's git +# version in cmake/spliced_project_version.txt; prefer it, because `git describe` in this (spliced) +# tree would otherwise report the TEMPLATE's version. Fall back to git-describe (self-contained use), +# then to the CMake project() version. +set(PROJECT_VERSION_STRING "${PROJECT_VERSION}") +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/spliced_project_version.txt") + file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/spliced_project_version.txt" PROJECT_VERSION_STRING LIMIT_COUNT 1) +else() + find_package(Git QUIET) + if(Git_FOUND AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/.git") + execute_process( + COMMAND ${GIT_EXECUTABLE} describe --always --dirty --tags + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE _git_describe OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _git_result ERROR_QUIET) + if(_git_result EQUAL 0 AND _git_describe) + set(PROJECT_VERSION_STRING "${_git_describe}") + endif() + endif() +endif() +string(TIMESTAMP PROJECT_BUILD_DATE "%Y-%m-%d") +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/project_version.cpp.in + ${PROJECT_BINARY_DIR}/project_version.cpp + @ONLY) +set(PROJECT_VERSION_CPP ${PROJECT_BINARY_DIR}/project_version.cpp) + +# --- Obtain MRtrix3 ------------------------------------------------------------------------------- +if(PROJECT_MRTRIX3_SOURCE STREQUAL "VENDORED") + # Configuration appropriate for vendoring MRtrix3 inside an external project. + set(MRTRIX_BUILD_GUI OFF CACHE BOOL "" FORCE) + set(MRTRIX_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(MRTRIX_ENABLE_GPU OFF CACHE BOOL "" FORCE) + set(MRTRIX_IGNORE_VERSION_MISMATCH ON CACHE BOOL "" FORCE) + set(MRTRIX_SUBPROJECT_INSTALL_PREFIX "${PROJECT_MRTRIX3_SUBDIR}" CACHE STRING "" FORCE) + + # Static iff exactly one C++ binary in total; otherwise build a shared libmrtrix-core.so that all + # binaries (this project's commands and the built MRtrix3 commands) link against. + if(PROJECT_LINKAGE STREQUAL "static") + set(MRTRIX_BUILD_STATIC ON CACHE BOOL "" FORCE) + else() + set(MRTRIX_BUILD_STATIC OFF CACHE BOOL "" FORCE) + endif() + + # Translate the three-state command selection into MRtrix3's MRTRIX_CMD_SUBSET. MRtrix3 treats an + # empty subset as "build everything", so "build none" is expressed with a sentinel that matches no + # command (yields zero MRtrix3 commands, plus one harmless MRtrix3 warning). + if(PROJECT_MRTRIX3_CMD_MODE STREQUAL "ALL") + set(MRTRIX_CMD_SUBSET "" CACHE STRING "" FORCE) + elseif(PROJECT_MRTRIX3_CMD_MODE STREQUAL "LIST") + set(MRTRIX_CMD_SUBSET "${PROJECT_MRTRIX3_CMD_SUBSET}" CACHE STRING "" FORCE) + else() # NONE + set(MRTRIX_CMD_SUBSET "__mrtrix_no_commands__" CACHE STRING "" FORCE) + endif() + + include(FetchContent) + if(MRTRIX3_SOURCE_DIR) + FetchContent_Declare(mrtrix3 SOURCE_DIR "${MRTRIX3_SOURCE_DIR}") + else() + FetchContent_Declare(mrtrix3 + GIT_REPOSITORY "${MRTRIX3_GIT_REPOSITORY}" + GIT_TAG "${MRTRIX3_GIT_TAG}") + endif() + FetchContent_MakeAvailable(mrtrix3) + set(MRTRIX3_CMAKE_DIR "${mrtrix3_SOURCE_DIR}/cmake") + # MRtrix3's self-contained subtree in the build tree (bin/, lib/mrtrix3, share/mrtrix3 siblings). + set(MRTRIX3_SUBTREE_DIR "${mrtrix3_BINARY_DIR}") +else() # EXISTING + if(NOT MRTRIX3_BUILD_DIR OR NOT MRTRIX3_SOURCE_DIR) + message(FATAL_ERROR "EXISTING mode requires both MRTRIX3_SOURCE_DIR and MRTRIX3_BUILD_DIR to be set") + endif() + find_package(MRtrix3 CONFIG REQUIRED PATHS "${MRTRIX3_BUILD_DIR}" NO_DEFAULT_PATH) + set(MRTRIX3_CMAKE_DIR "${MRTRIX3_SOURCE_DIR}/cmake") + set(MRTRIX3_SUBTREE_DIR "${MRTRIX3_BUILD_DIR}") +endif() + +# Reuse MRtrix3's own cmake helpers (MakePythonExecutable.cmake, etc.). +list(APPEND CMAKE_MODULE_PATH "${MRTRIX3_CMAKE_DIR}") + +add_subdirectory(cpp/core) +add_subdirectory(cpp/cmd) +add_subdirectory(python) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..014d793 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,41 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 22, "patch": 0 }, + "configurePresets": [ + { + "name": "vendored", + "displayName": "Vendored MRtrix3 (fetch + pin a version)", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "PROJECT_MRTRIX3_SOURCE": "VENDORED", + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "vendored-local", + "inherits": "vendored", + "displayName": "Vendored from a local MRtrix3 checkout ($MRTRIX3_SOURCE_DIR)", + "cacheVariables": { + "MRTRIX3_SOURCE_DIR": "$env{MRTRIX3_SOURCE_DIR}" + } + }, + { + "name": "existing", + "displayName": "Link against an existing MRtrix3 build+source tree (experimental)", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build-existing", + "cacheVariables": { + "PROJECT_MRTRIX3_SOURCE": "EXISTING", + "MRTRIX3_SOURCE_DIR": "$env{MRTRIX3_SOURCE_DIR}", + "MRTRIX3_BUILD_DIR": "$env{MRTRIX3_BUILD_DIR}", + "CMAKE_BUILD_TYPE": "Release" + } + } + ], + "buildPresets": [ + { "name": "vendored", "configurePreset": "vendored" }, + { "name": "vendored-local", "configurePreset": "vendored-local" }, + { "name": "existing", "configurePreset": "existing" } + ] +} diff --git a/README.md b/README.md index 8b13789..db3e35b 100644 --- a/README.md +++ b/README.md @@ -1 +1,148 @@ +# MRtrix3 external-project template +The build system for building, running and distributing C++ and Python commands written against the +[MRtrix3](https://www.mrtrix.org/) API, while remaining a separate project from MRtrix3 itself +(the modern, CMake-based replacement for the old "external modules" workflow). + +This repository is **scaffold-only**: it contains the build system but no command source, and it +names no specific project. A project repository contributes its source files plus a tiny metadata +file, laid out as: + +- `project.cmake` — names the project: `set(MRTRIX_PROJECT_NAME )` (and optionally + `set(MRTRIX_PROJECT_VERSION )`). This is what makes the scaffold project-agnostic — the + template itself contains no project name. +- C++ commands in `cpp/cmd/*.cpp` — which may `#include` headers from the project's `cpp/core/` + (and `cpp/gui/`) and/or from MRtrix3's `cpp/core/` (and `cpp/gui/`); +- Python commands in `python//commands/` — either a standalone `.py`, or a + `/.py` inside a `/` sub-directory, exactly as in MRtrix3. The package boilerplate + (the package and `commands/` `__init__.py` markers) is **generated** from the project name, so the + project repo provides only its command sources. No project-local `app.py` shim is needed: each + generated launcher imports MRtrix3's application machinery straight from the nested `mrtrix3.app`. +- `cmake/mrtrix3_commands.txt` — the MRtrix3 commands this project requires (see *Build variants*). + +At build time the project's source is **spliced** onto this scaffold (the directory structures align, +so the source files simply overlay the build files) and the combined tree is configured and built. +`testing/splice-project.sh ` performs the overlay; CI does this automatically +(see `.github/workflows/ci.yml`, which splices the example +[`MRtrix3_demo_code`](https://github.com/MRtrix3/MRtrix3_demo_code) `dev` branch — providing +`demo_cpp`, `demo_python` and `cpp/core/algo/geometric_mean.h`). + +> **Reproducibility note.** Because the build system floats relative to the project, a project that +> needs reproducible builds should pin the template version it splices (alongside `MRTRIX3_GIT_TAG`). +> For a more ergonomic, self-contained alternative, a project can instead carry a small bootstrap +> `CMakeLists.txt` that `FetchContent`s a pinned tag of this template — keeping the repository +> `cmake`-buildable while still centralising the build system here. + +## Requirements + +- CMake ≥ 3.22, Ninja, a C++17 compiler, Git +- MRtrix3's usual build dependencies (zlib, FFTW3, libpng, Eigen3, Python 3) +- **MRtrix3 ≥ 3.1.0** (the first release including external-project support) + +## Quick start (vendored MRtrix3) + +Splice a project's source onto this scaffold, then build. By default MRtrix3 is *vendored*: the +pinned version is fetched and built as part of the project. Set the version with `MRTRIX3_GIT_TAG`. + +``` +# obtain the example project source (or use your own, same layout) +git clone -b dev https://github.com/MRtrix3/MRtrix3_demo_code.git /tmp/project_src +# splice it onto this scaffold +bash testing/splice-project.sh /tmp/project_src +# name the project (a project repo normally ships this as project.cmake; the demo does not yet) +echo 'set(MRTRIX_PROJECT_NAME demo_code)' > project.cmake + +cmake -S . -B build -G Ninja -DMRTRIX3_GIT_TAG=3.1.0 +cmake --build build -j +# run straight from the build tree: +build/bin/demo_cpp --version +build/bin/demo_cpp in.mif out.mif +build/bin/demo_python in.mif out.mif +``` + +Install to a prefix and put only this project's commands on your `PATH`: + +``` +cmake --install build --prefix /opt/demo_code +export PATH=/opt/demo_code/bin:$PATH +demo_cpp in.mif out.mif +demo_python in.mif out.mif +``` + +CMake presets are provided (`cmake --preset vendored`, `--preset existing`). + +## How it relates to your MRtrix3 installation + +A core design goal is that **installing this project never changes which command runs when you type +an MRtrix3 command name in your shell.** This is achieved as follows: + +- Only this project's *own* commands (`demo_cpp`, `demo_python`) are placed on your `PATH` + (in `/bin`). +- The MRtrix3 it was built against (only the commands listed in `cmake/mrtrix3_commands.txt`) is + installed as a **self-contained subtree** at `/mrtrix3/{bin,lib,share}` — which is **not** + on your `PATH`. Nesting it this way also preserves MRtrix3's own internal relative paths. +- When one of this project's Python commands invokes an MRtrix3 (or project) command, MRtrix3's + `run.command()` resolves it to the version-matched executable in that subtree (or this project's + `/bin` for the project's own commands) — not via `PATH`. So the project always uses the + commands it was built with, and your interactive `mrconvert` (etc.) still comes from your own + MRtrix3 installation. + +C++ command help/`--version` reports this project's version *in addition to* the MRtrix3 version it +was built against. + +## Build variants + +| Setting | Effect | +|---|---| +| `-DMRTRIX3_GIT_TAG=` | MRtrix3 version to vendor | +| `-DMRTRIX3_SOURCE_DIR=` | Vendor from a local MRtrix3 checkout instead of fetching | +| `-DPROJECT_MRTRIX3_SOURCE=EXISTING -DMRTRIX3_SOURCE_DIR= -DMRTRIX3_BUILD_DIR=` | Link against the build tree of an existing MRtrix3 checkout instead of vendoring (experimental) | + +`cmake/mrtrix3_commands.txt` selects the MRtrix3 commands to build and bundle (only these are +resolved as version-matched when your Python commands invoke them). It is three-state: + +- one or more command names → build exactly those; +- empty/absent → build **none** (for a project with no Python commands); +- a single `*` (or `ALL`) line → build the entire MRtrix3 command set. + +> **MRtrix3 Python commands as dependencies.** If a listed command is itself an MRtrix3 *Python* +> command, MRtrix3 cannot know which C++ commands it invokes at run time, so it builds its **entire** +> C++ command set (and uses a shared core). This is the safe default; finer-grained, per-command +> dependency declaration is assessed in [`docs/python-command-dependencies.md`](docs/python-command-dependencies.md). + +**Linkage follows the total number of C++ binaries** — your C++ commands *plus* the MRtrix3 commands +being built: + +- Exactly **one** binary in total (a single C++ command and no MRtrix3 commands) → it links the + statically-built MRtrix3 core directly into a self-contained binary; **no shared library** is built. +- **More than one** binary → the MRtrix3 core is built **shared** (`libmrtrix-core.so`, in the nested + MRtrix3 subtree) and linked by every binary — your commands *and* the built MRtrix3 commands — so + the core is never duplicated. If your project additionally ships compiled core sources shared across + more than one of *its own* commands, those are collected into a branded `lib.so` layered on + top. (`libmrtrix-core.so` lives in the off-PATH subtree reached only via RPATH, so it cannot collide + with a separate MRtrix3 installation.) + +## Containers + +``` +docker build -t demo_code --build-arg MRTRIX3_GIT_TAG=3.1.0 -f containers/Dockerfile . +docker run --rm demo_code demo_cpp --version + +apptainer build demo_code.sif containers/apptainer.def +apptainer run demo_code.sif demo_cpp --version +``` + +## Making it your own + +The scaffold is project-agnostic; it carries no project name (the examples above use `demo_code` +purely for illustration). To target your project, your project repository provides: + +1. `project.cmake` at its root — `set(MRTRIX_PROJECT_NAME )`. This names the C++ project + and the Python package; the package boilerplate is generated from it. +2. Command source laid out as `cpp/cmd/*.cpp`, `cpp/core/**`, and + `python//commands/**` (command sources only — no package `__init__.py`, no `app.py`). +3. `cmake/mrtrix3_commands.txt` listing the MRtrix3 commands your Python commands call (see + *Build variants* for the three-state semantics). + +Splice it on with `testing/splice-project.sh `, then configure and build. Nothing +in the template needs editing. diff --git a/cmake/mrtrix3_commands.txt b/cmake/mrtrix3_commands.txt new file mode 100644 index 0000000..b780cc0 --- /dev/null +++ b/cmake/mrtrix3_commands.txt @@ -0,0 +1,18 @@ +# Which MRtrix3 commands this project requires. +# +# This file is normally SUPPLIED BY THE PROJECT REPOSITORY and overlaid by testing/splice-project.sh; +# the copy shipped with the template lists no commands (the safe default). Lines beginning with '#' +# and blank lines are ignored. Three states are recognised: +# +# * one or more command names -> build exactly those MRtrix3 commands; only these are bundled and +# only these are resolved as version-matched when this project's +# Python commands invoke them. (Most projects want this.) +# * empty (this file as shipped)-> build NO MRtrix3 commands. Appropriate for a project that has no +# Python commands; combined with a single C++ command it yields a +# single, self-contained statically linked binary. +# * a single line "*" (or ALL) -> build the ENTIRE MRtrix3 command set (rarely needed; slow). +# +# Example (uncomment / replace with your project's required commands): +# mrconvert +# mrcalc +# mrmath diff --git a/cmake/project_version.cpp.in b/cmake/project_version.cpp.in new file mode 100644 index 0000000..478c0c1 --- /dev/null +++ b/cmake/project_version.cpp.in @@ -0,0 +1,18 @@ +/* Generated by CMake from project_version.cpp.in. + * + * Defines MR::App::set_project_version(), which command.h invokes (under -DMRTRIX_PROJECT) at the + * start of every command. It populates the MRtrix3 globals that drive the "external project" version + * reporting in help text, --version, the command-history image keyval, etc. — displayed in addition + * to (not instead of) the MRtrix3 version this project was built against. + */ + +#include "app.h" + +namespace MR::App { + +void set_project_version() { + project_version = "@PROJECT_VERSION_STRING@"; + project_build_date = "@PROJECT_BUILD_DATE@"; +} + +} // namespace MR::App diff --git a/cmake/templates/commands__init__.py.in b/cmake/templates/commands__init__.py.in new file mode 100644 index 0000000..7a95dbc --- /dev/null +++ b/cmake/templates/commands__init__.py.in @@ -0,0 +1,5 @@ +# Auto-generated commands sub-package init for the "@PROJECT_NAME@" project. +# (Generated from cmake/templates/commands__init__.py.in; do not edit in the build/install tree.) +# +# Each command is either a standalone .py file here, or a / sub-directory containing +# .py (plus any supporting modules), mirroring the two command layouts permitted by MRtrix3. diff --git a/cmake/templates/pkg__init__.py.in b/cmake/templates/pkg__init__.py.in new file mode 100644 index 0000000..82e948b --- /dev/null +++ b/cmake/templates/pkg__init__.py.in @@ -0,0 +1,6 @@ +# Auto-generated package init for the "@PROJECT_NAME@" project (MRtrix3 external-project template). +# (Generated from cmake/templates/pkg__init__.py.in; do not edit in the build/install tree.) +# +# This project's commands live in the commands/ sub-package; the heavy lifting (argument parsing, +# command execution, scratch-directory handling, ...) is reused directly from MRtrix3's Python API +# (mrtrix3.app), which each generated command launcher imports from the nested MRtrix3 subtree. diff --git a/cmake/templates/version.py.in b/cmake/templates/version.py.in new file mode 100644 index 0000000..dbfeb11 --- /dev/null +++ b/cmake/templates/version.py.in @@ -0,0 +1,11 @@ +# Auto-generated project version file for the "@PROJECT_NAME@" project (MRtrix3 external project). +# (Generated from cmake/templates/version.py.in; do not edit in the build/install tree.) +# +# Mirrors MRtrix3's own mrtrix3/version.py: it carries the build-time-resolved identity of THIS +# project (not MRtrix3), baked from the single source of truth computed in the top-level CMakeLists +# (PROJECT_VERSION_STRING / PROJECT_BUILD_DATE) - the same values the C++ commands compile in via +# project_version.cpp. Each generated Python command launcher imports this module and hands VERSION / +# BUILD_DATE to mrtrix3.app, so a Python command reports the project version exactly as its C++ +# siblings do (rather than shelling out to git at run time). +VERSION = "@PROJECT_VERSION_STRING@" +BUILD_DATE = "@PROJECT_BUILD_DATE@" diff --git a/containers/Dockerfile b/containers/Dockerfile new file mode 100644 index 0000000..1708d83 --- /dev/null +++ b/containers/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1 +# +# Multi-stage build of an external MRtrix3 project (vendored MRtrix3, headless). +# +# The build context must already have a project's source spliced onto this scaffold +# (testing/splice-project.sh) so that project.cmake names the project and the C++/Python command +# sources are present. The project name, command names and install prefix below are not baked into the +# build - the prefix is an arbitrary location and the commands come from the spliced source. +# +# Build: docker build -t --build-arg MRTRIX3_GIT_TAG=3.1.0 -f containers/Dockerfile . +# Run: docker run --rm --version # e.g. demo_cpp --version + +# ---- Build stage -------------------------------------------------------------------------------- +FROM ubuntu:24.04 AS build + +# Pin the MRtrix3 version this project is built against (requires a version that includes the +# external-project support; i.e. MRtrix3 >= 3.1.0). +ARG MRTRIX3_GIT_TAG=3.1.0 +ARG CMAKE_BUILD_TYPE=Release +# Install prefix (arbitrary; only this project's own commands land on PATH, see the runtime stage). +ARG INSTALL_PREFIX=/opt/project + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git ca-certificates \ + zlib1g-dev libfftw3-dev libpng-dev libeigen3-dev python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY . /opt/src +RUN cmake -S /opt/src -B /opt/build -G Ninja \ + -DPROJECT_MRTRIX3_SOURCE=VENDORED \ + -DMRTRIX3_GIT_TAG=${MRTRIX3_GIT_TAG} \ + -DMRTRIX_USE_SYSTEM_EIGEN=ON \ + -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} \ + -DCMAKE_INSTALL_PREFIX=${INSTALL_PREFIX} \ + && cmake --build /opt/build -j "$(nproc)" \ + && cmake --install /opt/build + +# ---- Runtime stage ------------------------------------------------------------------------------ +FROM ubuntu:24.04 AS runtime + +ARG INSTALL_PREFIX=/opt/project + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + libfftw3-double3 libpng16-16 zlib1g libgomp1 python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build ${INSTALL_PREFIX} ${INSTALL_PREFIX} + +# Only this project's own commands are placed on PATH (in /bin). The bundled MRtrix3 lives as a +# self-contained subtree under /mrtrix3 (bin/lib/share) and is resolved internally by this +# project's Python commands, so it never shadows a separate MRtrix3 installation. +ENV PATH=${INSTALL_PREFIX}/bin:${PATH} + +CMD ["/bin/bash"] diff --git a/containers/apptainer.def b/containers/apptainer.def new file mode 100644 index 0000000..5f74207 --- /dev/null +++ b/containers/apptainer.def @@ -0,0 +1,60 @@ +# Apptainer/Singularity recipe for an external MRtrix3 project (vendored MRtrix3, headless). +# +# The build context must already have a project's source spliced onto this scaffold +# (testing/splice-project.sh): project.cmake names the project and the command sources are present. +# /opt/project below is an arbitrary install prefix; the project name and command names come from the +# spliced source, not from this recipe. +# +# Build: apptainer build project.sif containers/apptainer.def # run from the repository root +# # to build against a specific MRtrix3 revision (requires apptainer >= 1.3 for the default): +# apptainer build --build-arg MRTRIX3_GIT_TAG= project.sif containers/apptainer.def +# Run: apptainer run project.sif --version # e.g. demo_cpp --version + +Bootstrap: docker +From: ubuntu:24.04 +Stage: build + +# Default MRtrix3 revision to build against (requires MRtrix3 >= 3.1.0). Override at build time with +# --build-arg MRTRIX3_GIT_TAG= (mirrors the Dockerfile's ARG of the same name). +%arguments + MRTRIX3_GIT_TAG=3.1.0 + +%files + . /opt/src + +%post + export DEBIAN_FRONTEND=noninteractive + apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git ca-certificates \ + zlib1g-dev libfftw3-dev libpng-dev libeigen3-dev python3 + # MRtrix3 revision to build against (see the %arguments default / --build-arg above). + cmake -S /opt/src -B /opt/build -G Ninja \ + -DPROJECT_MRTRIX3_SOURCE=VENDORED \ + -DMRTRIX3_GIT_TAG={{ MRTRIX3_GIT_TAG }} \ + -DMRTRIX_USE_SYSTEM_EIGEN=ON \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/opt/project + cmake --build /opt/build -j "$(nproc)" + cmake --install /opt/build + + +Bootstrap: docker +From: ubuntu:24.04 +Stage: final + +%files from build + /opt/project /opt/project + +%post + export DEBIAN_FRONTEND=noninteractive + apt-get update && apt-get install -y --no-install-recommends \ + libfftw3-double3 libpng16-16 zlib1g libgomp1 python3 + rm -rf /var/lib/apt/lists/* + +%environment + # Only this project's own commands are exposed here; the bundled MRtrix3 lives as a self-contained + # subtree under /opt/project/mrtrix3 and is resolved internally by this project's Python commands. + export PATH=/opt/project/bin:$PATH + +%runscript + exec "$@" diff --git a/cpp/cmd/CMakeLists.txt b/cpp/cmd/CMakeLists.txt new file mode 100644 index 0000000..9a7b38b --- /dev/null +++ b/cpp/cmd/CMakeLists.txt @@ -0,0 +1,94 @@ +# Build each C++ command in this directory against the MRtrix3 API. +# +# Linkage is decided at the top level (PROJECT_LINKAGE), driven by the TOTAL number of C++ binaries +# (this project's commands + the MRtrix3 commands being built): +# +# * "static" (exactly one binary in total): the single command links the statically-built MRtrix3 +# core directly into a self-contained binary; NO shared library is created. +# * "shared" (more than one binary): the MRtrix3 core is a shared libmrtrix-core.so (built upstream, +# in the nested MRtrix3 subtree) that every binary links against - this project's commands AND the +# built MRtrix3 commands (mrconvert, ...). If this project additionally ships compiled core sources +# that are shared across MORE THAN ONE of its own commands, those are collected into a branded +# lib.so layered on top (which itself links the shared MRtrix3 core); otherwise the project +# core is compiled directly into each command. +# +# So at most two shared libraries can exist: libmrtrix-core.so (the MRtrix3 core, always when shared) +# and lib.so (this project's own shared core, only when it pays off). The historical "never +# libmrtrix-core.so" goal is obsolete: the MRtrix3 subtree is installed off the user's PATH and reached +# only via RPATH, so it cannot collide with a separate MRtrix3 installation. + +file(GLOB PROJECT_CMD_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +list(LENGTH PROJECT_CMD_SOURCES PROJECT_NUM_CMDS) + +file(GLOB_RECURSE PROJECT_CORE_SOURCES ${PROJECT_SOURCE_DIR}/cpp/core/*.cpp) + +# A branded shared library for THIS project's own core is worthwhile only when that core is shared +# across more than one of this project's commands. (When it is built, PROJECT_LINKAGE is necessarily +# "shared", so the MRtrix3 core it links is itself the shared libmrtrix-core.so.) +set(PROJECT_BUILD_CORE_LIB FALSE) +if(PROJECT_NUM_CMDS GREATER 1 AND PROJECT_CORE_SOURCES) + set(PROJECT_BUILD_CORE_LIB TRUE) +endif() + +# RPATHs for an installed command in /bin: its own dir, /lib (lib.so) and +# //lib (libmrtrix-core.so in the nested MRtrix3 subtree). +set(PROJECT_CMD_INSTALL_RPATH + "$ORIGIN" + "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}" + "$ORIGIN/../${PROJECT_MRTRIX3_SUBDIR}/${CMAKE_INSTALL_LIBDIR}") + +if(PROJECT_BUILD_CORE_LIB) + add_library(${PROJECT_NAME}_core SHARED ${PROJECT_CORE_SOURCES}) + add_library(${PROJECT_NAME}::core ALIAS ${PROJECT_NAME}_core) + # Branded library name: lib.so (contains this project's core only; the MRtrix3 core stays + # in libmrtrix-core.so, linked transitively via mrtrix::core). + set_target_properties(${PROJECT_NAME}_core PROPERTIES OUTPUT_NAME ${PROJECT_NAME}) + target_link_libraries(${PROJECT_NAME}_core + PUBLIC ${PROJECT_NAME}::core_headers # project headers + mrtrix::core (the shared MRtrix3 core) + PRIVATE mrtrix::core) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_options(${PROJECT_NAME}_core PRIVATE -Wl,--gc-sections) + endif() + # From /lib, reach libmrtrix-core.so in the nested MRtrix3 subtree. + set_target_properties(${PROJECT_NAME}_core PROPERTIES + INSTALL_RPATH "$ORIGIN;$ORIGIN/../${PROJECT_MRTRIX3_SUBDIR}/${CMAKE_INSTALL_LIBDIR}") + install(TARGETS ${PROJECT_NAME}_core LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}) +endif() + +function(project_add_cmd CMD_SRC) + get_filename_component(CMD_NAME ${CMD_SRC} NAME_WE) + if(PROJECT_BUILD_CORE_LIB) + add_executable(${CMD_NAME} ${CMD_SRC} ${PROJECT_VERSION_CPP}) + target_link_libraries(${CMD_NAME} PRIVATE ${PROJECT_NAME}::core mrtrix::executable-version) + else() + # Compile the project core (if any) straight into the command; with -ffunction-sections and + # --gc-sections only referenced objects (including from the static MRtrix3 core) survive. + add_executable(${CMD_NAME} ${CMD_SRC} ${PROJECT_CORE_SOURCES} ${PROJECT_VERSION_CPP}) + target_link_libraries(${CMD_NAME} PRIVATE + ${PROJECT_NAME}::core_headers mrtrix::core mrtrix::executable-version) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_options(${CMD_NAME} PRIVATE -Wl,--gc-sections) + endif() + endif() + # Activates the call to set_project_version() in command.h, enabling dual-version reporting. + target_compile_definitions(${CMD_NAME} PRIVATE MRTRIX_PROJECT) + # This project's headers take precedence over MRtrix3's. + target_include_directories(${CMD_NAME} BEFORE PRIVATE + ${PROJECT_SOURCE_DIR}/cpp/core ${PROJECT_SOURCE_DIR}/cpp/gui) + # Installed to /bin; shared libraries (if any) at /lib and //lib. + set_target_properties(${CMD_NAME} PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/bin + INSTALL_RPATH "${PROJECT_CMD_INSTALL_RPATH}") + install(TARGETS ${CMD_NAME} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +endfunction() + +set(PROJECT_CPP_COMMANDS "") +foreach(CMD_SRC ${PROJECT_CMD_SOURCES}) + project_add_cmd(${CMD_SRC}) + get_filename_component(CMD_NAME ${CMD_SRC} NAME_WE) + list(APPEND PROJECT_CPP_COMMANDS ${CMD_NAME}) +endforeach() + +# Expose this project's C++ command names to the Python packaging step (for $PATH symlinks and for +# the command-resolution allow-list passed to the Python launchers). +set(PROJECT_CPP_COMMANDS "${PROJECT_CPP_COMMANDS}" CACHE INTERNAL "") diff --git a/cpp/core/CMakeLists.txt b/cpp/core/CMakeLists.txt new file mode 100644 index 0000000..5b0f25b --- /dev/null +++ b/cpp/core/CMakeLists.txt @@ -0,0 +1,11 @@ +# This project's C++ API (headers under cpp/core/, plus any compiled .cpp sources). +# +# Exposes an interface target that puts this project's cpp/core on the include path *ahead* of +# MRtrix3's, so a header here can extend or shadow an MRtrix3 one, while MRtrix3's headers (and the +# third-party dependency headers) remain available transitively via mrtrix::core. + +add_library(${PROJECT_NAME}_core_headers INTERFACE) +add_library(${PROJECT_NAME}::core_headers ALIAS ${PROJECT_NAME}_core_headers) + +target_include_directories(${PROJECT_NAME}_core_headers BEFORE INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(${PROJECT_NAME}_core_headers INTERFACE mrtrix::core) diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt new file mode 100644 index 0000000..6f8e999 --- /dev/null +++ b/python/CMakeLists.txt @@ -0,0 +1,145 @@ +# Package this project's Python commands. +# +# The project repository supplies only its command SOURCES at python//commands/ (a standalone +# .py, or a /.py sub-directory). The minimal package boilerplate - the package +# __init__.py and commands/__init__.py - is GENERATED here from the project name +# (cmake/templates/*.in), so the scaffold ships no project-named Python files. +# +# Note there is deliberately NO generated app.py: MRtrix3's launcher generator (MakePythonExecutable +# .cmake) imports the application machinery straight from the nested 'mrtrix3.app' (on sys.path via +# the launcher's MRTRIX_LIB_RELPATH), so a project-local app.py re-export shim is unnecessary. +# +# Layout (identical in the build tree and the install tree, so one launcher works in both): +# /bin/ this project's commands (on the user's PATH) +# /lib// this project's Python package (generated boilerplate + sources) +# //{bin,lib,share} MRtrix3 as a self-contained subtree (NOT on PATH) +# +# A launcher resolves invoked MRtrix3 commands via the nested mrtrix3 package's own bin/ (the versions +# built with this project, never shadowing a separate MRtrix3 on the user's PATH), and this project's +# own commands via MRTRIX_EXTRA_* (version-matched too). + +set(PROJECT_PKG_NAME ${PROJECT_NAME}) +set(PROJECT_PKG_SRCDIR ${PROJECT_SOURCE_DIR}/python/${PROJECT_PKG_NAME}) +set(PROJECT_CMD_SRCDIR ${PROJECT_PKG_SRCDIR}/commands) + +# --- Generate the package boilerplate from the project name (into the build tree) ------------------ +set(PROJECT_PKG_GENDIR ${PROJECT_BINARY_DIR}/python_pkg/${PROJECT_PKG_NAME}) +configure_file(${PROJECT_SOURCE_DIR}/cmake/templates/pkg__init__.py.in + ${PROJECT_PKG_GENDIR}/__init__.py @ONLY) +configure_file(${PROJECT_SOURCE_DIR}/cmake/templates/commands__init__.py.in + ${PROJECT_PKG_GENDIR}/commands/__init__.py @ONLY) +# This project's own version module (VERSION / BUILD_DATE), baked from the build-time-resolved +# PROJECT_VERSION_STRING / PROJECT_BUILD_DATE set in the top-level CMakeLists - the same source of +# truth the C++ commands compile in via project_version.cpp. Mirrors MRtrix3's mrtrix3/version.py; +# each Python launcher imports it and passes it to mrtrix3.app so Python commands report the project +# version exactly as the C++ commands do. See cmake/templates/version.py.in. +configure_file(${PROJECT_SOURCE_DIR}/cmake/templates/version.py.in + ${PROJECT_PKG_GENDIR}/version.py @ONLY) + +# --- Discover this project's Python command names (standalone .py, or /.py) ------- +set(PROJECT_PY_COMMANDS "") +if(EXISTS ${PROJECT_CMD_SRCDIR}) + file(GLOB PROJECT_PY_ENTRIES ${PROJECT_CMD_SRCDIR}/*) + foreach(ENTRY ${PROJECT_PY_ENTRIES}) + get_filename_component(ENTRY_NAME ${ENTRY} NAME_WE) + if(ENTRY_NAME STREQUAL "CMakeLists" OR ENTRY_NAME STREQUAL "__init__") + continue() + endif() + if(IS_DIRECTORY ${ENTRY}) + if(EXISTS ${ENTRY}/${ENTRY_NAME}.py) + list(APPEND PROJECT_PY_COMMANDS ${ENTRY_NAME}) + endif() + elseif(ENTRY MATCHES "\\.py$") + list(APPEND PROJECT_PY_COMMANDS ${ENTRY_NAME}) + endif() + endforeach() +endif() + +# This project's own commands (C++ + Python): the version-matched allow-list given to the launchers. +set(PROJECT_ALL_COMMANDS ${PROJECT_CPP_COMMANDS} ${PROJECT_PY_COMMANDS}) + +# Relative paths (from a launcher in /bin) to the two Python library locations. +set(PROJECT_LIB_RELPATH "../lib") +set(MRTRIX_LIB_RELPATH "../${PROJECT_MRTRIX3_SUBDIR}/lib") + +# ------------------------------------------------------------------------------------------------- +# Build tree: assemble the importable package and the launchers under /. +# ------------------------------------------------------------------------------------------------- +set(RT_BIN ${PROJECT_BINARY_DIR}/bin) +set(RT_LIB ${PROJECT_BINARY_DIR}/lib) +set(RT_PKG ${RT_LIB}/${PROJECT_PKG_NAME}) + +add_custom_target(${PROJECT_NAME}_python ALL) +if(PROJECT_MRTRIX3_SOURCE STREQUAL "VENDORED") + add_dependencies(${PROJECT_NAME}_python MakePythonCommandsInit MRtrixCppCommands) +endif() + +add_custom_command(TARGET ${PROJECT_NAME}_python POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory ${RT_BIN} ${RT_PKG}/commands + COMMENT "Preparing ${PROJECT_NAME} runtime tree") + +# MRtrix3 self-contained subtree at / (mirrors /). +add_custom_command(TARGET ${PROJECT_NAME}_python POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rm -f ${PROJECT_BINARY_DIR}/${PROJECT_MRTRIX3_SUBDIR} + COMMAND ${CMAKE_COMMAND} -E create_symlink ${MRTRIX3_SUBTREE_DIR} ${PROJECT_BINARY_DIR}/${PROJECT_MRTRIX3_SUBDIR}) + +# Generated package boilerplate. +add_custom_command(TARGET ${PROJECT_NAME}_python POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy ${PROJECT_PKG_GENDIR}/__init__.py ${RT_PKG}/__init__.py + COMMAND ${CMAKE_COMMAND} -E copy ${PROJECT_PKG_GENDIR}/commands/__init__.py ${RT_PKG}/commands/__init__.py + COMMAND ${CMAKE_COMMAND} -E copy ${PROJECT_PKG_GENDIR}/version.py ${RT_PKG}/version.py + COMMENT "Installing generated ${PROJECT_NAME} package boilerplate into the build tree") + +# Symlink each command source into the package's commands/ (standalone file or sub-directory). +foreach(CMD ${PROJECT_PY_COMMANDS}) + if(IS_DIRECTORY ${PROJECT_CMD_SRCDIR}/${CMD}) + set(_cmd_src ${PROJECT_CMD_SRCDIR}/${CMD}) + set(_cmd_dst ${RT_PKG}/commands/${CMD}) + else() + set(_cmd_src ${PROJECT_CMD_SRCDIR}/${CMD}.py) + set(_cmd_dst ${RT_PKG}/commands/${CMD}.py) + endif() + add_custom_command(TARGET ${PROJECT_NAME}_python POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rm -f ${_cmd_dst} + COMMAND ${CMAKE_COMMAND} -E create_symlink ${_cmd_src} ${_cmd_dst}) +endforeach() + +# A launcher for each Python command. Pass the command allow-list as a comma-separated string: an +# embedded CMake list (semicolons) would be re-split into separate arguments of the custom command. +string(REPLACE ";" "," PROJECT_ALL_COMMANDS_CSV "${PROJECT_ALL_COMMANDS}") +foreach(CMD ${PROJECT_PY_COMMANDS}) + add_custom_command(TARGET ${PROJECT_NAME}_python POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DCMDNAME=${CMD} + -DOUTPUT_DIR=${RT_BIN} + -DPACKAGE=${PROJECT_PKG_NAME} + -DSRCDIR=${PROJECT_CMD_SRCDIR} + -DPROJECT_LIB_RELPATH=${PROJECT_LIB_RELPATH} + -DMRTRIX_LIB_RELPATH=${MRTRIX_LIB_RELPATH} + -DEXTERNAL_COMMANDS=${PROJECT_ALL_COMMANDS_CSV} + -P ${MRTRIX3_CMAKE_DIR}/MakePythonExecutable.cmake + COMMENT "Creating Python launcher for ${CMD}") +endforeach() + +# ------------------------------------------------------------------------------------------------- +# Install tree. MRtrix3 installs itself to //... (MRTRIX_SUBPROJECT_INSTALL_PREFIX); +# this project installs its own commands to /bin and its package to /lib/. +# ------------------------------------------------------------------------------------------------- +# Generated boilerplate. +install(FILES ${PROJECT_PKG_GENDIR}/__init__.py + DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_PKG_NAME}) +install(FILES ${PROJECT_PKG_GENDIR}/version.py + DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_PKG_NAME}) +install(FILES ${PROJECT_PKG_GENDIR}/commands/__init__.py + DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_PKG_NAME}/commands) +# Command sources (standalone files and sub-directories), Python files only. +if(EXISTS ${PROJECT_CMD_SRCDIR}) + install(DIRECTORY ${PROJECT_CMD_SRCDIR}/ + DESTINATION ${CMAKE_INSTALL_LIBDIR}/${PROJECT_PKG_NAME}/commands + FILES_MATCHING PATTERN "*.py") +endif() + +# Installed launchers. +foreach(CMD ${PROJECT_PY_COMMANDS}) + install(PROGRAMS ${RT_BIN}/${CMD} DESTINATION ${CMAKE_INSTALL_BINDIR}) +endforeach() diff --git a/testing/make_test_image.py b/testing/make_test_image.py new file mode 100755 index 0000000..6b47527 --- /dev/null +++ b/testing/make_test_image.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Write a small 4D NIfTI-1 image of positive random values, for testing the demo commands. + +Self-contained (standard library only), so it can generate test input without any MRtrix3 command. +Usage: make_test_image.py [nx ny nz nt] +""" + +import random +import struct +import sys + + +def write_nifti(path, nx, ny, nz, nt): + header = bytearray(352) + struct.pack_into(' valid geometric mean + with open(path, 'wb') as out: + out.write(header) + out.write(data) + + +if __name__ == '__main__': + out_path = sys.argv[1] + dims = [int(arg) for arg in sys.argv[2:6]] if len(sys.argv) >= 6 else [4, 4, 4, 3] + write_nifti(out_path, *dims) diff --git a/testing/splice-project.sh b/testing/splice-project.sh new file mode 100644 index 0000000..da7133f --- /dev/null +++ b/testing/splice-project.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Splice an external project's source tree into this (scaffold-only) template checkout. +# +# This template intentionally contains the build system but no command source. A consumer's project +# repository contains only its source files (cpp/cmd/*.cpp, cpp/core/**, python//commands/**) +# laid out in the same directory structure; this script overlays that source onto the template so the +# combined tree can be configured and built. +# +# Usage: testing/splice-project.sh +set -euo pipefail + +SRC="$1" +if [ ! -d "$SRC" ]; then + echo "error: project source directory '$SRC' not found" >&2 + exit 1 +fi + +# Overlay the project's C++ and Python source onto the template, merging with (not replacing) the +# template's CMakeLists.txt / package skeleton files. +for sub in cpp python; do + if [ -d "$SRC/$sub" ]; then + cp -a "$SRC/$sub/." "$sub/" + fi +done + +# Overlay the project's metadata: project.cmake (project name/version; read before project()) and the +# MRtrix3 command-selection file. These make the scaffold project-agnostic - the template itself names +# no specific project. A project that omits project.cmake builds under the template's default name. +if [ -f "$SRC/project.cmake" ]; then + cp -a "$SRC/project.cmake" project.cmake +fi +if [ -f "$SRC/cmake/mrtrix3_commands.txt" ]; then + mkdir -p cmake + cp -a "$SRC/cmake/mrtrix3_commands.txt" cmake/mrtrix3_commands.txt +fi + +# Record the project's own version from its git metadata. The build reads this file in preference to +# `git describe` (which, in this spliced tree, would otherwise report the TEMPLATE's version rather +# than the project's). Without this, an external project's reported version has the wrong provenance. +if git -C "$SRC" rev-parse --git-dir >/dev/null 2>&1; then + mkdir -p cmake + if ! git -C "$SRC" describe --always --dirty --tags > cmake/spliced_project_version.txt 2>/dev/null; then + rm -f cmake/spliced_project_version.txt + fi +fi + +echo "Spliced project source from '$SRC' into $(pwd)" diff --git a/testing/verify.sh b/testing/verify.sh new file mode 100755 index 0000000..89133d5 --- /dev/null +++ b/testing/verify.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +# End-to-end verification of a built external MRtrix3 project. +# +# Beyond a functional smoke test, this asserts the CI contracts: +# 1. Linkage: exactly one C++ binary in total -> a self-contained binary with NO shared MRtrix3/ +# project library; more than one -> a single shared libmrtrix-core.so linked by the project's +# command(s) AND the built MRtrix3 command(s), plus lib.so when the project shares its +# own core across >1 command. With --project-lib-symbol, lib.so is further required to +# DEFINE that (project-specific) symbol while libmrtrix-core.so does not - proving the project +# library genuinely carries the project's own compiled core, over and above the MRtrix3 core. +# 2. Command set: exactly the expected MRtrix3 commands are built/bundled (a subset, none, or all). +# With --mrtrix-py-cmds, a requested MRtrix3 Python command forces (and is verified against) the +# FULL C++ set, since cmake cannot see which C++ commands that Python command invokes. +# 3. No-shadow / resolution: the project's bin never exposes an MRtrix3 command on PATH, and an +# MRtrix3 command of the same name placed earlier on PATH is ignored in favour of the +# version-matched copy; the launchers are wired to resolve the project's own commands too, and +# they import MRtrix3's application machinery from the nested mrtrix3.app (no project app.py shim). +# 4. Versions: BOTH a C++ and a Python command report this project's own version AND the MRtrix3 +# version it was built/executed against, each matching expectation (a Python command's project +# version comes from the generated /version.py the launcher feeds to mrtrix3.app, never +# a run-time `git describe`; this is what stops it reporting its version as 'unknown'). +# +# Usage (flags; lists are space-separated strings): +# verify.sh --bindir DIR --linkage static|shared --mrtrix-cmds "a b c"|none|all \ +# [--cpp-cmds "c1 c2"] [--py-cmds "p1"] [--project-name NAME] \ +# [--expect-project-lib yes|no] [--project-lib-symbol SUBSTR] [--mrtrix-py-cmds "p1 p2"] \ +# [--project-version STR] [--mrtrix-version STR] [--subdir mrtrix3] [--mode build-dir|installed] +set -euo pipefail + +# ---- defaults / arg parsing ---------------------------------------------------------------------- +BINDIR=""; LINKAGE=""; MRTRIX_CMDS=""; CPP_CMDS=""; PY_CMDS="" +PROJECT_NAME=""; EXPECT_PROJECT_LIB="no"; PROJECT_VERSION=""; MRTRIX_VERSION="" +SUBDIR="mrtrix3"; MODE="build-dir" +PROJECT_LIB_SYMBOL=""; MRTRIX_PY_CMDS="" +while [ $# -gt 0 ]; do + case "$1" in + --bindir) BINDIR="$2"; shift 2;; + --linkage) LINKAGE="$2"; shift 2;; + --mrtrix-cmds) MRTRIX_CMDS="$2"; shift 2;; + --cpp-cmds) CPP_CMDS="$2"; shift 2;; + --py-cmds) PY_CMDS="$2"; shift 2;; + --project-name) PROJECT_NAME="$2"; shift 2;; + --expect-project-lib) EXPECT_PROJECT_LIB="$2"; shift 2;; + # Task 1: a demangled-symbol substring expected to be DEFINED in lib.so (and absent from + # libmrtrix-core.so) - proves the project library genuinely carries the project's own compiled + # core, over and above the MRtrix3 core. The pattern is demo-specific, so CI supplies it. + --project-lib-symbol) PROJECT_LIB_SYMBOL="$2"; shift 2;; + # Task 2: MRtrix3 *Python* commands requested as dependencies. Because cmake cannot see which C++ + # commands a Python command invokes, requesting any Python command builds the FULL MRtrix3 + # command set; these names are additionally asserted present and version-matched. + --mrtrix-py-cmds) MRTRIX_PY_CMDS="$2"; shift 2;; + --project-version) PROJECT_VERSION="$2"; shift 2;; + --mrtrix-version) MRTRIX_VERSION="$2"; shift 2;; + --subdir) SUBDIR="$2"; shift 2;; + --mode) MODE="$2"; shift 2;; + *) echo "verify.sh: unknown argument '$1'" >&2; exit 2;; + esac +done +[ -n "$BINDIR" ] || { echo "verify.sh: --bindir is required" >&2; exit 2; } +[ -n "$LINKAGE" ] || { echo "verify.sh: --linkage is required" >&2; exit 2; } + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$BINDIR/.." && pwd)" # or : holds bin/, lib/, / +MRTRIX_BIN="$ROOT/$SUBDIR/bin" +BASE_PATH="$PATH" # PATH as inherited, before we prepend the project bin +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } +note() { echo " -> $*"; } + +export PATH="$BINDIR:$BASE_PATH" +python3 "$HERE/make_test_image.py" "$TMP/in.nii" + +# ---- helpers ------------------------------------------------------------------------------------- +# NEEDED dependency present in an ELF file (independent of whether it resolves)? +elf_needs() { readelf -d "$1" 2>/dev/null | grep -q "\[$2\]"; } +# Dependency resolves to a real path (catches a wrong RPATH in the installed tree)? +elf_resolves() { ldd "$1" 2>/dev/null | grep -F "$2" | grep -qv "not found"; } +# First matching library anywhere under ROOT (follows the build-tree subtree symlink). +find_lib() { find -L "$ROOT" -name "$1" -print -quit 2>/dev/null; } +# Is a (demangled) symbol substring DEFINED in an ELF object? Prefer the dynamic symbol table +# (nm -D), demangled (-C); fall back to the full table for unstripped build-tree artifacts. +# Capture nm output first (rather than piping straight into `grep -q`): under `set -o pipefail`, +# grep -q closes the pipe on the first match, nm then dies with SIGPIPE, and pipefail would turn +# that into a spurious non-zero result. +elf_defines_symbol() { # + local _syms + _syms="$( { nm -D -C --defined-only "$1" 2>/dev/null; nm -C --defined-only "$1" 2>/dev/null; } || true )" + printf '%s\n' "$_syms" | grep -qF "$2" +} +# Executable command names in the MRtrix3 subtree bin (excluding build helpers). +mrtrix_built_cmds() { + [ -d "$MRTRIX_BIN" ] || return 0 + find -L "$MRTRIX_BIN" -maxdepth 1 -type f -perm -100 -printf '%f\n' 2>/dev/null \ + | grep -Ev '^(pch_cmd|0_dummy)$' | sort +} + +CORE_SO="libmrtrix-core.so" +PROJ_SO="" +[ -n "$PROJECT_NAME" ] && PROJ_SO="lib${PROJECT_NAME}.so" + +# ================================================================================================= +echo "== [req4] C++ commands: functional run + dual-version reporting ==" +for c in $CPP_CMDS; do + bin="$BINDIR/$c" + [ -x "$bin" ] || fail "C++ command '$c' missing at $bin" + # Functional smoke (MRtrix3 command convention: [options] input output). + "$bin" -force "$TMP/in.nii" "$TMP/out_$c.mif" + test -s "$TMP/out_$c.mif" || fail "$c produced no output" + # MRtrix3's own CLI convention is single-dash flags ('-version'); the C++ parser also tolerates + # '--version', but the Python parser recognises only '-version', so use the canonical form here. + v="$("$bin" -version 2>&1)" + echo "$v" | grep -qi "against MRtrix" || fail "$c -version lacks dual-version ('against MRtrix')" + if [ -n "$MRTRIX_VERSION" ]; then + echo "$v" | grep -qF "$MRTRIX_VERSION" || fail "$c -version missing MRtrix3 version '$MRTRIX_VERSION'" + fi + if [ -n "$PROJECT_VERSION" ]; then + echo "$v" | grep -qF "$PROJECT_VERSION" || fail "$c -version missing project version '$PROJECT_VERSION'" + fi + note "$c: ran, dual-version OK" +done + +# ================================================================================================= +echo "== [req4] Python commands: functional run + dual-version reporting ==" +# A Python command must report the SAME dual version as its C++ siblings: this project's own version +# AND the MRtrix3 version it executes against. The project version is baked at build time into the +# generated /version.py (mirroring MRtrix3's mrtrix3/version.py) and handed to mrtrix3.app by +# the launcher - NOT derived from a run-time `git describe` (which fails in an installed tree with no +# .git, and historically left Python commands reporting their version as 'unknown'). +for p in $PY_CMDS; do + bin="$BINDIR/$p" + [ -x "$bin" ] || fail "Python command '$p' missing at $bin" + "$bin" -force "$TMP/in.nii" "$TMP/out_$p.mif" + test -s "$TMP/out_$p.mif" || fail "$p produced no output" + # MRtrix3 Python commands accept only single-dash '-version' (see app.py). + v="$("$bin" -version 2>&1)" || fail "$p -version failed" + # The legacy failure mode this guards against: an external project's Python command printing + # '== unknown =='. + echo "$v" | grep -qiw "unknown" && fail "$p -version reports an 'unknown' version (project version not propagated)" + echo "$v" | grep -qi "against MRtrix" || fail "$p -version lacks dual-version ('against MRtrix')" + if [ -n "$MRTRIX_VERSION" ]; then + echo "$v" | grep -qF "$MRTRIX_VERSION" || fail "$p -version missing MRtrix3 version '$MRTRIX_VERSION'" + fi + if [ -n "$PROJECT_VERSION" ]; then + echo "$v" | grep -qF "$PROJECT_VERSION" || fail "$p -version missing project version '$PROJECT_VERSION'" + # Parity with the C++ commands: the project version banner must match exactly. + echo "$v" | grep -qF "== $p $PROJECT_VERSION ==" \ + || fail "$p -version banner is not '== $p $PROJECT_VERSION =='" + fi + note "$p: ran, dual-version OK" +done + +# The project ships a generated version.py (the build-time source of truth the launcher feeds to +# mrtrix3.app), and each launcher imports it and passes it to _execute(). +if [ -n "$PY_CMDS" ] && [ -n "$PROJECT_NAME" ]; then + ver_py="$ROOT/lib/$PROJECT_NAME/version.py" + [ -f "$ver_py" ] || fail "generated project version module missing at $ver_py" + grep -qE '^VERSION *= *"' "$ver_py" || fail "$ver_py does not define VERSION" + if [ -n "$PROJECT_VERSION" ]; then + grep -qF "\"$PROJECT_VERSION\"" "$ver_py" || fail "$ver_py VERSION does not match project version '$PROJECT_VERSION'" + fi + for p in $PY_CMDS; do + launcher="$BINDIR/$p" + grep -q "import version as _project" "$launcher" \ + || fail "$p launcher does not import the project version module" + grep -q "project=_project" "$launcher" \ + || fail "$p launcher does not pass project=_project to _execute()" + done + note "project version.py generated and wired into the Python launchers" +fi + +# ================================================================================================= +echo "== [req1] Linkage ($LINKAGE) ==" +FIRST_CPP="$(set -- $CPP_CMDS; echo "${1:-}")" +core_path="$(find_lib "$CORE_SO" || true)" +proj_path=""; [ -n "$PROJ_SO" ] && proj_path="$(find_lib "$PROJ_SO" || true)" + +if [ "$LINKAGE" = "static" ]; then + [ -z "$core_path" ] || fail "static build but $CORE_SO exists at $core_path" + [ -z "$proj_path" ] || fail "static build but $PROJ_SO exists at $proj_path" + if [ -n "$FIRST_CPP" ]; then + ! elf_needs "$BINDIR/$FIRST_CPP" "$CORE_SO" || fail "$FIRST_CPP dynamically needs $CORE_SO in a static build" + fi + note "no shared MRtrix3/project library; binary is self-contained" +elif [ "$LINKAGE" = "shared" ]; then + [ -n "$core_path" ] || fail "shared build but $CORE_SO not found under $ROOT" + note "$CORE_SO present at $core_path" + if [ -n "$FIRST_CPP" ]; then + elf_needs "$BINDIR/$FIRST_CPP" "$CORE_SO" || fail "$FIRST_CPP does not link $CORE_SO" + elf_resolves "$BINDIR/$FIRST_CPP" "$CORE_SO" || fail "$FIRST_CPP cannot resolve $CORE_SO (RPATH?)" + fi + # A built MRtrix3 command must link the same shared core (proves the core is genuinely shared). + any_mrtrix="$(mrtrix_built_cmds | head -n1 || true)" + if [ -n "$any_mrtrix" ]; then + elf_needs "$MRTRIX_BIN/$any_mrtrix" "$CORE_SO" || fail "MRtrix3 command $any_mrtrix does not link $CORE_SO" + note "MRtrix3 command '$any_mrtrix' links the shared $CORE_SO" + fi + if [ "$EXPECT_PROJECT_LIB" = "yes" ]; then + [ -n "$proj_path" ] || fail "expected $PROJ_SO but it was not found under $ROOT" + [ -n "$FIRST_CPP" ] && { elf_needs "$BINDIR/$FIRST_CPP" "$PROJ_SO" || fail "$FIRST_CPP does not link $PROJ_SO"; } + note "$PROJ_SO present at $proj_path and linked" + # The project library must be a SEPARATE artifact from the MRtrix3 core (the two-shared-libraries + # invariant): lib.so is not libmrtrix-core.so. + [ "$proj_path" != "$core_path" ] || fail "$PROJ_SO and $CORE_SO resolved to the same file" + # Task 1: the project library must genuinely carry the project's OWN compiled core (the symbol + # split out of the header into geometric_mean.cpp), proving it is a project-specific library over + # and above the MRtrix3 core - not an empty/incidental artifact. + if [ -n "$PROJECT_LIB_SYMBOL" ]; then + elf_defines_symbol "$proj_path" "$PROJECT_LIB_SYMBOL" \ + || fail "$PROJ_SO does not define expected project-core symbol matching '$PROJECT_LIB_SYMBOL'" + if [ -n "$core_path" ] && elf_defines_symbol "$core_path" "$PROJECT_LIB_SYMBOL"; then + fail "project-core symbol '$PROJECT_LIB_SYMBOL' leaked into $CORE_SO (should be project-only)" + fi + note "$PROJ_SO defines project-core symbol '$PROJECT_LIB_SYMBOL' (absent from $CORE_SO)" + fi + else + [ -z "$proj_path" ] || fail "did not expect $PROJ_SO but found it at $proj_path" + fi +else + fail "unknown --linkage '$LINKAGE'" +fi + +# ================================================================================================= +echo "== [req2] MRtrix3 command set ($MRTRIX_CMDS) ==" +built="$(mrtrix_built_cmds | tr '\n' ' ' | sed 's/ *$//')" +note "built MRtrix3 commands: [${built}]" +case "$MRTRIX_CMDS" in + none) + [ -z "$built" ] || fail "expected NO MRtrix3 commands but found: $built" + note "none built, as expected" + ;; + all) + # The full MRtrix3 command set is built. This is the expectation both for an explicit "*" request + # and (Task 2) when a requested MRtrix3 Python command forces the entire C++ set to be built + # (because cmake cannot see which C++ commands the Python command invokes). A broad spread of C++ + # commands - well outside any minimal dependency subset - must therefore all be present in the + # MRtrix3 subtree's bin/. + for sentinel in mrconvert mrinfo mrcalc mrmath mrstats mrtransform mrregister mrthreshold \ + mrhistogram mrcat mrgrid dwiextract dwi2tensor tensor2metric tckgen tckconvert; do + echo " $built " | grep -q " $sentinel " \ + || fail "'all' build missing expected C++ command '$sentinel' (full C++ set not built?)" + done + count="$(echo "$built" | wc -w)" + [ "$count" -gt 50 ] || fail "'all' build has only $count commands; expected the full C++ set (100+)" + note "$count commands present in $MRTRIX_BIN (full C++ set, all sentinels found)" + ;; + *) + expected="$(echo "$MRTRIX_CMDS" | tr ' ' '\n' | grep -v '^$' | sort | tr '\n' ' ' | sed 's/ *$//')" + actual="$(echo "$built" | tr ' ' '\n' | grep -v '^$' | sort | tr '\n' ' ' | sed 's/ *$//')" + [ "$expected" = "$actual" ] || fail "command set mismatch: expected [$expected] got [$actual]" + note "exactly the expected subset built" + ;; +esac + +# Task 2: any MRtrix3 Python command requested as a dependency must itself be present in the subtree +# bin/ as a version-matched launcher. (Its presence is WHY the full C++ set above was built.) +if [ -n "$MRTRIX_PY_CMDS" ]; then + echo "== [req2] MRtrix3 Python-command dependencies present ($MRTRIX_PY_CMDS) ==" + for p in $MRTRIX_PY_CMDS; do + echo " $built " | grep -q " $p " || fail "requested MRtrix3 Python command '$p' not built into $MRTRIX_BIN" + grep -q "from mrtrix3.app import _execute" "$MRTRIX_BIN/$p" 2>/dev/null \ + || fail "MRtrix3 Python launcher '$p' is not a well-formed mrtrix3 launcher" + note "MRtrix3 Python command '$p' present and version-matched in the subtree" + done +fi + +# ================================================================================================= +echo "== [req3] No-shadow: project bin must not expose MRtrix3 commands ==" +for c in mrconvert mrinfo mrcalc mrmath $( [ "$MRTRIX_CMDS" != none ] && [ "$MRTRIX_CMDS" != all ] && echo "$MRTRIX_CMDS" ); do + [ ! -e "$BINDIR/$c" ] || fail "$c must not be in the project bin ($BINDIR)" +done +# Putting the project bin on PATH must not bring an MRtrix3 command onto PATH from the off-PATH subtree. +for c in mrconvert mrinfo; do + resolved="$(PATH="$BINDIR:$BASE_PATH" command -v "$c" 2>/dev/null || true)" + case "$resolved" in + "$ROOT/$SUBDIR/"*) fail "$c resolved into the off-PATH subtree ($resolved)";; + esac +done +note "project bin exposes none of MRtrix3's commands" + +echo "== [req3] Resolution: foreign MRtrix3 commands earlier on PATH are ignored ==" +if [ -n "$PY_CMDS" ] && [ "$MRTRIX_CMDS" != none ] && [ "$MRTRIX_CMDS" != all ]; then + p="$(set -- $PY_CMDS; echo "$1")" + mkdir -p "$TMP/fakebin" + for c in $MRTRIX_CMDS; do + printf '#!/bin/sh\necho SHADOWED-FAKE-USED:%s >&2\nexit 42\n' "$c" > "$TMP/fakebin/$c" + chmod +x "$TMP/fakebin/$c" + done + # If run.command() resolved via PATH instead of the version-matched subtree, one of these fakes would + # run (exit 42) and the command would fail. + PATH="$TMP/fakebin:$BINDIR:$BASE_PATH" "$BINDIR/$p" -force "$TMP/in.nii" "$TMP/out_shadow.mif" + test -s "$TMP/out_shadow.mif" || fail "$p failed with foreign [$MRTRIX_CMDS] earlier on PATH (resolution leaked to PATH?)" + note "$p ignored the foreign [$MRTRIX_CMDS] (used the version-matched copies)" +else + note "skipped (no Python command / no concrete MRtrix3 subset in this configuration)" +fi + +echo "== [req3] Launchers wire run.command() to the project's OWN commands too ==" +for p in $PY_CMDS; do + launcher="$BINDIR/$p" + grep -q "MRTRIX_EXTRA_EXECUTABLES_PATH" "$launcher" || fail "$p launcher missing MRTRIX_EXTRA_EXECUTABLES_PATH" + grep -q "MRTRIX_EXTRA_COMMANDS" "$launcher" || fail "$p launcher missing MRTRIX_EXTRA_COMMANDS" + for own in $CPP_CMDS $PY_CMDS; do + grep -q "$own" "$launcher" || fail "$p launcher does not list own command '$own' for resolution" + done +done +[ -n "$PY_CMDS" ] && note "project commands are on the version-matched allow-list (resolved regardless of PATH)" + +# Task 3: the generated launcher imports MRtrix3's application machinery straight from the nested +# 'mrtrix3.app'; there is consequently NO project-local app.py re-export shim in the project package. +if [ -n "$PY_CMDS" ]; then + echo "== [req3] No app.py shim: launchers import mrtrix3.app directly ==" + pkg_dir="$ROOT/lib/$PROJECT_NAME" + for p in $PY_CMDS; do + launcher="$BINDIR/$p" + grep -q "from mrtrix3.app import _execute" "$launcher" \ + || fail "$p launcher does not import _execute from mrtrix3.app (app.py shim not eliminated?)" + ! grep -q "from ${PROJECT_NAME}.app import" "$launcher" \ + || fail "$p launcher still imports a project-local ${PROJECT_NAME}.app shim" + done + if [ -n "$PROJECT_NAME" ]; then + [ ! -e "$pkg_dir/app.py" ] || fail "project package still ships an app.py shim at $pkg_dir/app.py" + note "no $PROJECT_NAME/app.py present; launchers reach mrtrix3.app in the nested subtree directly" + fi +fi + +echo +echo "ALL CHECKS PASSED (bindir=$BINDIR, linkage=$LINKAGE, mrtrix-cmds=$MRTRIX_CMDS, mode=$MODE)"