diff --git a/CMakeLists.txt b/CMakeLists.txt index 82101db4e7..d87c5c44d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1084,6 +1084,49 @@ if(NRN_ENABLE_TESTS) cpp_cc_git_submodule(rxd/testdata) set(${CODING_CONV_PREFIX}_3RDPARTY_DIR "${backup}") add_subdirectory(test) + + # --------------------------------------------------------------------------- + # test-install: run portable foreign ctest against CMAKE_INSTALL_PREFIX. Requires a prior "ninja + # install" (or cmake --install). Configures the standalone test/foreign project into + # ${CMAKE_BINARY_DIR}/build-ctest. + # --------------------------------------------------------------------------- + set(NRN_FOREIGN_CTEST_BINARY_DIR + "${CMAKE_BINARY_DIR}/build-ctest" + CACHE PATH "Binary dir for foreign ctest (test/foreign) used by test-install") + if(NRN_ENABLE_PYTHON AND DEFINED NRN_DEFAULT_PYTHON_EXECUTABLE) + set(_nrn_test_install_python "${NRN_DEFAULT_PYTHON_EXECUTABLE}") + elseif(DEFINED Python3_EXECUTABLE) + set(_nrn_test_install_python "${Python3_EXECUTABLE}") + else() + set(_nrn_test_install_python "") + endif() + if(_nrn_test_install_python STREQUAL "") + message( + STATUS "test-install target not added: no Python interpreter available for foreign ctest") + else() + # Strict version match by default (same source tree that produced the prefix). Reconfigure + # build-ctest with -DNRN_FOREIGN_ALLOW_SKEW=ON if needed. + set(_nrn_test_install_skew OFF) + add_custom_target( + test-install + COMMAND + ${CMAKE_COMMAND} -E echo + "Configuring foreign ctest in ${NRN_FOREIGN_CTEST_BINARY_DIR} against ${CMAKE_INSTALL_PREFIX}" + COMMAND + ${CMAKE_COMMAND} -S "${PROJECT_SOURCE_DIR}/test/foreign" -B + "${NRN_FOREIGN_CTEST_BINARY_DIR}" -G "${CMAKE_GENERATOR}" + "-DNRN_FOREIGN_PYTHON=${_nrn_test_install_python}" + "-DNRN_FOREIGN_ROOT=${CMAKE_INSTALL_PREFIX}" + "-DNRN_FOREIGN_ALLOW_SKEW=${_nrn_test_install_skew}" + COMMAND ${CMAKE_COMMAND} --build "${NRN_FOREIGN_CTEST_BINARY_DIR}" --target test-install + --parallel + USES_TERMINAL + COMMENT + "Foreign install check (run 'ninja install' first). Dir: ${NRN_FOREIGN_CTEST_BINARY_DIR}") + message( + STATUS + "test-install | ninja install && ninja test-install -> ${NRN_FOREIGN_CTEST_BINARY_DIR}") + endif() elseif(NOT WIN32) # Windows installs the demo separately add_subdirectory(share/demo/release) diff --git a/cmake/NeuronTestHelper.cmake b/cmake/NeuronTestHelper.cmake index 311cf9fec8..52dc714d23 100644 --- a/cmake/NeuronTestHelper.cmake +++ b/cmake/NeuronTestHelper.cmake @@ -93,9 +93,47 @@ # REFERENCE_OUTPUT argument adds reference data files from the repository to # the comparison job with the magic name "reference_file". Paths are # specified relative to the root of the NEURON repository. +# +# Foreign-wheel mode (see test/foreign/): callers may set before including this file: +# NRN_TEST_SOURCE_ROOT - NEURON source tree (default: PROJECT_SOURCE_DIR) +# NRN_TEST_BINARY_ROOT - build tree for test outputs (default: PROJECT_BINARY_DIR) +# NRN_NRNIVMODL - path to nrnivmodl (default: ${CMAKE_BINARY_DIR}/bin/nrnivmodl) +# NRN_NRNIVMODL_DEPENDS - extra DEPENDS for special (default: nrniv_lib if that target exists) +# NRN_FOREIGN_MODE - ON to skip build-tree PYTHONPATH prepend and linked-lib deps # ~~~ -# Load the cpp_cc_build_time_copy helper function. -include("${CODING_CONV_CMAKE}/build-time-copy.cmake") +# Roots and nrnivmodl defaults (overridable for foreign installs). +if(NOT DEFINED NRN_TEST_SOURCE_ROOT) + set(NRN_TEST_SOURCE_ROOT "${PROJECT_SOURCE_DIR}") +endif() +if(NOT DEFINED NRN_TEST_BINARY_ROOT) + set(NRN_TEST_BINARY_ROOT "${PROJECT_BINARY_DIR}") +endif() +if(NOT DEFINED NRN_NRNIVMODL) + set(NRN_NRNIVMODL "${CMAKE_BINARY_DIR}/bin/nrnivmodl") +endif() + +# Load the cpp_cc_build_time_copy helper function (or a minimal fallback). +if(DEFINED CODING_CONV_CMAKE AND EXISTS "${CODING_CONV_CMAKE}/build-time-copy.cmake") + include("${CODING_CONV_CMAKE}/build-time-copy.cmake") +elseif(NOT COMMAND cpp_cc_build_time_copy) + function(cpp_cc_build_time_copy) + set(options NO_TARGET) + set(oneValueArgs INPUT OUTPUT) + cmake_parse_arguments(BTC "${options}" "${oneValueArgs}" "" ${ARGN}) + get_filename_component(_btc_outdir "${BTC_OUTPUT}" DIRECTORY) + add_custom_command( + OUTPUT "${BTC_OUTPUT}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${_btc_outdir}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${BTC_INPUT}" "${BTC_OUTPUT}" + DEPENDS "${BTC_INPUT}" + COMMENT "Copy ${BTC_INPUT} -> ${BTC_OUTPUT}" + VERBATIM) + if(NOT BTC_NO_TARGET) + string(SHA256 _btc_hash "${BTC_OUTPUT}") + add_custom_target(build-time-copy-${_btc_hash} DEPENDS "${BTC_OUTPUT}") + endif() + endfunction() +endif() function(nrn_add_test_group) # NAME is used as a key, [CORENEURON, MODFILE_PATTERNS, NRNIVMODL_ARGS and SUBMODULE] are used to # set up a custom target that runs nrnivmod, everything else is a default that can be overriden in @@ -133,18 +171,23 @@ function(nrn_add_test_group) # submodule is initialised. If there is no submodule, everything is relative to the root nrn/ # directory. if(NOT ${NRN_ADD_TEST_GROUP_SUBMODULE} STREQUAL "") + if(NOT COMMAND cpp_cc_git_submodule) + message( + FATAL_ERROR + "nrn_add_test_group: SUBMODULE requires cpp_cc_git_submodule (coding-conventions)") + endif() cpp_cc_git_submodule(${NRN_ADD_TEST_GROUP_SUBMODULE} QUIET) # Construct the name of the source tree directory where the submodule has been checked out. - set(test_source_directory "${PROJECT_SOURCE_DIR}/external/${NRN_ADD_TEST_GROUP_SUBMODULE}") + set(test_source_directory "${NRN_TEST_SOURCE_ROOT}/external/${NRN_ADD_TEST_GROUP_SUBMODULE}") else() - set(test_source_directory "${PROJECT_SOURCE_DIR}") + set(test_source_directory "${NRN_TEST_SOURCE_ROOT}") endif() set(${prefix}_TEST_SOURCE_DIRECTORY "${test_source_directory}" PARENT_SCOPE) if(NOT DEFINED NRN_RUN_FROM_BUILD_DIR_ENV) # To avoid duplication we take this value from the {nrn}/test/CMakeLists.txt file by assuming - # this variable name. + # this variable name. Foreign mode sets this to the venv/wheel environment instead. message(WARNING "nrn_add_test: NRN_RUN_FROM_BUILD_DIR_ENV was not defined;" " building test files may not work") endif() @@ -155,15 +198,17 @@ function(nrn_add_test_group) # Escape special characters (problematic with Windows paths when calling nrnivmodl) string(REGEX REPLACE "([][+.*()^])" "\\\\\\1" NRN_RUN_FROM_BUILD_DIR_ENV "${NRN_RUN_FROM_BUILD_DIR_ENV}") - set(nrnivmodl_command cmake -E env ${NRN_RUN_FROM_BUILD_DIR_ENV} - ${CMAKE_BINARY_DIR}/bin/nrnivmodl ${NRN_ADD_TEST_GROUP_NRNIVMODL_ARGS}) + set(nrnivmodl_command cmake -E env ${NRN_RUN_FROM_BUILD_DIR_ENV} ${NRN_NRNIVMODL} + ${NRN_ADD_TEST_GROUP_NRNIVMODL_ARGS}) # The user decides whether or not this test group should have its MOD files compiled for # CoreNEURON. set(nrnivmodl_dependencies) if(NRN_ADD_TEST_GROUP_CORENEURON AND NRN_ENABLE_CORENEURON) list(APPEND hash_components -coreneuron) - list(APPEND nrnivmodl_dependencies ${CORENEURON_TARGET_TO_DEPEND}) - list(APPEND nrnivmodl_dependencies coreneuron-core) + if(NOT NRN_FOREIGN_MODE) + list(APPEND nrnivmodl_dependencies ${CORENEURON_TARGET_TO_DEPEND}) + list(APPEND nrnivmodl_dependencies coreneuron-core) + endif() list(APPEND nrnivmodl_command -coreneuron) endif() list(APPEND nrnivmodl_command .) @@ -180,9 +225,8 @@ function(nrn_add_test_group) endif() list(SORT modfiles) foreach(modfile ${modfiles}) - # ${modfile} is an absolute path starting with ${PROJECT_SOURCE_DIR}, let's only add the part - # below this common prefix to the hash - string(LENGTH "${PROJECT_SOURCE_DIR}/" prefix_length) + # Prefer a path relative to the NEURON source root for a stable hash key. + string(LENGTH "${NRN_TEST_SOURCE_ROOT}/" prefix_length) string(SUBSTRING "${modfile}" ${prefix_length} -1 relative_modfile) list(APPEND hash_components "${relative_modfile}") endforeach() @@ -190,7 +234,7 @@ function(nrn_add_test_group) string(SHA256 nrnivmodl_command_hash "${hash_components}") # Construct the name of a target that refers to the compiled special binaries set(binary_target_name "NRN_TEST_nrnivmodl_${nrnivmodl_command_hash}") - set(nrnivmodl_directory "${PROJECT_BINARY_DIR}/test/nrnivmodl/${nrnivmodl_command_hash}") + set(nrnivmodl_directory "${NRN_TEST_BINARY_ROOT}/test/nrnivmodl/${nrnivmodl_command_hash}") # Short-circuit if the target has already been created. if(NOT TARGET "${binary_target_name}") # Copy modfiles from source -> build tree. @@ -209,22 +253,26 @@ function(nrn_add_test_group) endforeach() # Construct the names of the important output files set(special "${nrnivmodl_directory}/${CMAKE_HOST_SYSTEM_PROCESSOR}/special") - # Add the custom command to generate the binaries. Get nrnivmodl from the build directory. At - # the moment it seems that `nrnivmodl` is generated at configure time, so there is no target - # to depend on and it should always be available, but it will try and link against libnrniv.so - # and libcorenrnmech.so so we must depend on those. TODO: could the logic of `nrnivmodl` be - # translated to CMake, so it can be called natively here and the `nrnivmodl` executable would - # be a wrapper that invokes CMake? + # Add the custom command to generate the binaries. nrnivmodl comes from the NEURON build tree + # or from a foreign install (NRN_NRNIVMODL). Linked builds also depend on nrniv_lib. set(output_binaries "${special}") - list(APPEND nrnivmodl_dependencies nrniv_lib) + if(DEFINED NRN_NRNIVMODL_DEPENDS) + list(APPEND nrnivmodl_dependencies ${NRN_NRNIVMODL_DEPENDS}) + elseif(NOT NRN_FOREIGN_MODE AND TARGET nrniv_lib) + list(APPEND nrnivmodl_dependencies nrniv_lib) + endif() if(NRN_ENABLE_CORENEURON AND NRN_ADD_TEST_GROUP_CORENEURON) list(APPEND output_binaries "${special}-core") - if((NOT coreneuron_FOUND) AND (NOT DEFINED CORENEURON_BUILTIN_MODFILES)) + if((NOT coreneuron_FOUND) + AND (NOT DEFINED CORENEURON_BUILTIN_MODFILES) + AND (NOT NRN_FOREIGN_MODE)) message(WARNING "nrn_add_test_group couldn't find the names of the builtin " "CoreNEURON modfiles that nrnivmodl-core implicitly depends " "on *and* CoreNEURON is being built internally") endif() - list(APPEND nrnivmodl_dependencies ${CORENEURON_BUILTIN_MODFILES}) + if(NOT NRN_FOREIGN_MODE) + list(APPEND nrnivmodl_dependencies ${CORENEURON_BUILTIN_MODFILES}) + endif() endif() add_custom_command( OUTPUT ${output_binaries} @@ -340,7 +388,7 @@ function(nrn_add_test) set(sim_directory "${NRN_ADD_TEST_SIM_DIRECTORY}") endif() # Finally a working directory for this specific test within the group - set(working_directory "${PROJECT_BINARY_DIR}/test/${NRN_ADD_TEST_GROUP}/${NRN_ADD_TEST_NAME}") + set(working_directory "${NRN_TEST_BINARY_ROOT}/test/${NRN_ADD_TEST_GROUP}/${NRN_ADD_TEST_NAME}") file(MAKE_DIRECTORY "${working_directory}") if(DEFINED nrnivmodl_directory) execute_process( @@ -409,8 +457,12 @@ function(nrn_add_test) list(TRANSFORM test_env REPLACE "^PATH=" "PATH=${nrnivmodl_directory}/${CMAKE_HOST_SYSTEM_PROCESSOR}:") endif() - list(TRANSFORM test_env REPLACE "^PYTHONPATH=" - "PYTHONPATH=${CMAKE_SOURCE_DIR}/docs/nmodl/python_scripts:") + # Prepend docs helper scripts on PYTHONPATH for in-tree builds only. In foreign mode PYTHONPATH= + # must stay empty so the wheel/venv site-packages remain visible. + if(NOT NRN_FOREIGN_MODE) + list(TRANSFORM test_env REPLACE "^PYTHONPATH=" + "PYTHONPATH=${NRN_TEST_SOURCE_ROOT}/docs/nmodl/python_scripts:") + endif() # Get the list of variables being set set(extra_env_var_names ${extra_environment}) list(TRANSFORM extra_env_var_names REPLACE "^([^=]+)=.*$" "\\1") @@ -434,7 +486,9 @@ function(nrn_add_test) list(APPEND test_env NRN_SANITIZER_PRELOAD_VAL=${NRN_SANITIZER_LIBRARY_PATH}) list(APPEND test_env NRN_PYTHON_EXECUTABLE=${NRN_DEFAULT_PYTHON_EXECUTABLE}) endif() - list(APPEND test_env ${NRN_SANITIZER_ENABLE_ENVIRONMENT}) + if(DEFINED NRN_SANITIZER_ENABLE_ENVIRONMENT) + list(APPEND test_env ${NRN_SANITIZER_ENABLE_ENVIRONMENT}) + endif() # Add the actual test job, including the `special` and `special-core` binaries in the path. TODOs: # # * Do we need to manipulate PYTHONPATH more to make `python options.py` invocations work? diff --git a/docs/cmake_doc/options.rst b/docs/cmake_doc/options.rst index 538ca95f94..083c444a32 100644 --- a/docs/cmake_doc/options.rst +++ b/docs/cmake_doc/options.rst @@ -533,27 +533,98 @@ Readline_ROOT_DIR:PATH=/usr NRN_ENABLE_TESTS:BOOL=OFF ------------------------- - Enable unit tests - - Clones the submodule catch2 from https://github.com/catchorg/Catch2.git and after a build using - ``make`` can run the tests with ``make test``. - May also need to ``pip install pytest``. - ``make test`` is quite terse. To get the same verbose output that is - seen with the CI tests, use ``ctest -VV`` (executed in the - build folder) or an individual test with ``ctest -VV -R name_of_the_test``. - One can also run individual test files - with ``python3 -m pytest -s `` or all the test files in that - folder with ``python3 -m pytest -s``. (The ``-s`` shows all output on - the terminal.) Note: It is helpful to ``make test`` - first to ensure any mod files needed are available to the tests. If - running a test outside the folder where the test is located, it may be - necessary to add the folder to PYTHONPATH. Note: The last python - mentioned in the ``-DNRN_PYTHON_DYNAMIC=...`` (if the semicolon separated - list is non-empty and ``-DNRN_ENABLE_PYTHON_DYNAMIC=ON``) - is the one used for ``make test`` and ``ctest -VV``. Otherwise the - value specified by ``PYTHON_EXECUTABLE`` is used. - - Example + Enable the NEURON test suite (build-tree and install checks). + + When ``ON``: + + * Clones the Catch2 submodule (https://github.com/catchorg/Catch2.git) for + C++ unit tests. + * Registers the full in-tree CTest suite under the main CMake binary + directory (unit tests, HOC/Python integration, RxD, optional MPI and + CoreNEURON paths, and so on). + * Adds a convenience target ``test-install`` that runs the **portable** + foreign test harness against ``CMAKE_INSTALL_PREFIX`` (see + :ref:`cmake-nrn-test-install` below). + + You typically need ``pip install pytest`` (and, for RxD plots, packages + such as ``matplotlib`` / ``plotly`` / ``anywidget`` as required by + individual tests). + + In-tree tests (build directory) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + After configuring and building, run tests from the **main build** + directory. ``make test`` / ``ninja test`` is terse; prefer ``ctest`` for + the same verbose style as CI: + + .. code-block:: shell + + mkdir build && cd build + cmake .. -G Ninja -DNRN_ENABLE_TESTS=ON -DCMAKE_INSTALL_PREFIX=install ... + ninja # or: cmake --build . -j + ctest --output-on-failure -j8 + ctest -VV -R parallel_tests + + It is helpful to complete a full ``ctest`` (or at least build the test + targets) once so that ``nrnivmodl`` has produced any mod-file libraries + the scripts expect. + + One can also run individual Python test files with + ``python3 -m pytest -s `` or all tests in a folder with + ``python3 -m pytest -s`` (``-s`` sends output to the terminal). If + running a test outside the folder where it lives, you may need that + folder on ``PYTHONPATH``. + + Note: the last Python listed in ``-DNRN_PYTHON_DYNAMIC=...`` (when + non-empty and ``-DNRN_ENABLE_PYTHON_DYNAMIC=ON``) is used for + ``make test`` / default ``ctest``. Otherwise the value of + ``PYTHON_EXECUTABLE`` / the default discovered Python is used. + + .. _cmake-nrn-test-install: + + Install check (``test-install`` / foreign ctest) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Linked C++ unit tests require the build tree. To validate a **prefix + install** (or a pip wheel), NEURON also provides a standalone project + under ``test/foreign`` that discovers an installed NEURON and runs the + portable subset of the suite (no rebuild of ``libnrniv``). + + With ``NRN_ENABLE_TESTS=ON``, the main build defines a target + ``test-install``. After installing into ``CMAKE_INSTALL_PREFIX``: + + .. code-block:: shell + + ninja install # or: cmake --build . --target install + ninja test-install # or: cmake --build . --target test-install + + That target: + + 1. Configures ``test/foreign`` into ``${CMAKE_BINARY_DIR}/build-ctest`` + (for example ``build/build-ctest``), using the same CMake generator as + the main build (for example Ninja). + 2. Points foreign discovery at the install prefix + (``NRN_FOREIGN_ROOT``) and the build’s default Python. + 3. Builds mechanisms for the portable tests and runs a default + ``ctest -L serial`` install check. + 4. Prints how to re-run ``ctest`` with other filters against that + foreign binary directory. + + Afterwards you can use ordinary CTest options against the foreign dir: + + .. code-block:: shell + + ctest --test-dir build-ctest --output-on-failure + ctest --test-dir build-ctest -L mpi -j2 + ctest --test-dir build-ctest -L coreneuron -j2 + ctest --test-dir build-ctest -R 'pytest::' --rerun-failed + + You can also configure the foreign project yourself (wheels, custom + prefixes, or a different binary dir). See ``test/foreign/README.md`` + and ``test/foreign/INVENTORY.md`` (what runs under foreign mode vs + build-only). + + Example (in-tree tests only, classic workflow) .. code-block:: shell @@ -562,9 +633,6 @@ NRN_ENABLE_TESTS:BOOL=OFF make -j make test ctest -VV -R parallel_tests - cd ../test/pynrn - python3 -m pytest - python3 -m pytest test_currents.py NRN_ENABLE_COVERAGE:BOOL=OFF ---------------------------- diff --git a/docs/dev/gpu-testing.rst b/docs/dev/gpu-testing.rst index 87ecdf2011..ce7c7a1257 100644 --- a/docs/dev/gpu-testing.rst +++ b/docs/dev/gpu-testing.rst @@ -57,7 +57,10 @@ If you have configured NEURON with CoreNEURON, CoreNEURON GPU support and tests $ ctest --output-on-failure -in your CMake build directory will execute a large number of tests, many of them including GPU execution. +in your **main** CMake build directory will execute a large number of tests, many of them including GPU execution. +(That is the in-tree suite. The separate ``test-install`` / ``test/foreign`` path +validates an install or wheel and is CPU-oriented; GPU install checks are not +the focus of that harness yet.) You can filter which tests are run by name using the ``-R`` option to CTest, for example: .. code-block:: console diff --git a/docs/install/code_coverage.md b/docs/install/code_coverage.md index ea63d2ec03..1183fa1241 100644 --- a/docs/install/code_coverage.md +++ b/docs/install/code_coverage.md @@ -24,7 +24,9 @@ and ``make cover_html``. See [Simplified Workflow](#simplified-workflow) below. In addition to the COVERAGE_FLAGS use whatever cmake options you desire. But you will generally want ```-DNRN_ENABLE_TESTS=ON``` to see what -effect your new tests have on coverage. +effect your new tests have on coverage. Use in-tree `ctest` in the main +build directory for coverage (not the foreign install/wheel +`test-install` suite, which exercises an installed tree). ``` COVERAGE_FLAGS="--coverage -O0 -fno-inline -g" cmake .. -DCMAKE_INSTALL_PREFIX=install -DCMAKE_C_FLAGS="${COVERAGE_FLAGS}" -DCMAKE_CXX_FLAGS="${COVERAGE_FLAGS}" -DNRN_ENABLE_TESTS=ON diff --git a/docs/install/developer.rst b/docs/install/developer.rst index 704481e7d0..8c091c9368 100644 --- a/docs/install/developer.rst +++ b/docs/install/developer.rst @@ -1,9 +1,22 @@ Developer Builds -============ +================ Developer builds for creating Binary and Python wheel distributions, tests, documentation, code coverage. Each aspect generally has extra dependencies and special instructions. +**Tests overview** + +* In-tree CTest (build directory): enable with + :ref:`-DNRN_ENABLE_TESTS=ON `, then + ``ctest`` from the main build directory. +* Install / wheel portable suite: target ``test-install`` (same option), or + configure ``test/foreign`` directly — see that option page and + ``test/foreign/README.md``. +* Wheel smoke script: ``packaging/python/test_wheels.sh`` (documented under + :doc:`python_wheels`). +* Coverage workflow: :doc:`code_coverage`. +* Sanitizers / debugging: :doc:`debug`. + .. toctree:: :maxdepth: 2 diff --git a/docs/install/install_instructions.md b/docs/install/install_instructions.md index a3da116383..6c7fb5e46c 100644 --- a/docs/install/install_instructions.md +++ b/docs/install/install_instructions.md @@ -330,29 +330,47 @@ step method. You can find detailed instructions [here](../coreneuron/index.rst) #### Run integrated tests -**NEURON** includes also some unit and integration tests. To enable you need to set the `CMake` flag **-DNRN\_ENABLE\_TESTS=ON**. -The tests lie in the `test` directory and cover various aspects of **NEURON**: -* **CoreNEURON** integration (if enabled in build step) -* Functionality and result regression test for [ringtest](https://github.com/neuronsimulator/ringtest) and [testcorenrn](https://github.com/neuronsimulator/testcorenrn) -* HOC interpreter tests -* Python interpreter tests -* Parallel Context tests +**NEURON** includes unit and integration tests. Enable them with the CMake +flag **-DNRN\_ENABLE\_TESTS=ON**. Sources live under `test/` and cover +(among other areas): +* **CoreNEURON** integration (if enabled at configure time) +* Functionality and result regression tests for [ringtest](https://github.com/neuronsimulator/ringtest) and [testcorenrn](https://github.com/neuronsimulator/testcorenrn) +* HOC and Python interpreter tests +* Parallel Context / MPI tests * Rx3d tests -* Unit tests -* GapJunction tests +* C++ unit tests (Catch2) +* Gap junction tests + +**In-tree tests** (against the build directory; includes linked unit tests): -To run the tests it's needed to: ```bash cd nrn/build cmake .. \ + -G Ninja \ + -DNRN_ENABLE_TESTS=ON \ -DNRN_ENABLE_INTERVIEWS=OFF \ -DNRN_ENABLE_MPI=OFF \ -DNRN_ENABLE_RX3D=OFF \ -DCMAKE_INSTALL_PREFIX=/path/to/install/directory cmake --build . --parallel 8 - ctest # use --parallel for speed, -R to run specific tests + ctest --output-on-failure -j8 # -R to select tests by name + ``` + +**Install check** (portable suite against the install prefix; no rebuild of +`libnrniv`). After the same configure with `-DNRN_ENABLE_TESTS=ON`: + + ```bash + cmake --build . --target install + cmake --build . --target test-install + # creates build/build-ctest and runs a default serial foreign ctest + ctest --test-dir build-ctest -L mpi --output-on-failure # optional filters ``` +Details, labels (`serial`, `mpi`, `coreneuron`), and wheel testing are +documented under the CMake option `NRN_ENABLE_TESTS` and in +`test/foreign/README.md`. For a short smoke test of a built wheel, see +also `packaging/python/test_wheels.sh` in [Building Python Wheels](python_wheels.md). + ### FAQs * **I am getting link errors "undefined reference to 'tgoto, tgetent, tputs'".** diff --git a/docs/install/python_wheels.md b/docs/install/python_wheels.md index 5284a48023..d3be440107 100644 --- a/docs/install/python_wheels.md +++ b/docs/install/python_wheels.md @@ -172,7 +172,13 @@ Change the pretend version to whatever is relevant for your case. ## Testing the wheels -To test the generated wheels, you can do: +There are two complementary approaches: a **smoke script** that ships with +the packaging tree, and the **foreign CTest harness** that reuses a large +portable subset of the developer suite against an installed wheel. + +### Smoke tests (`test_wheels.sh`) + +Quick health check after building a wheel (or against TestPyPI): ``` # first arg is a python exe and second arg is the corresponding wheel @@ -182,11 +188,55 @@ bash packaging/python/test_wheels.sh python3.9 wheelhouse/NEURON-7.8.0.236-cp39- bash packaging/python/test_wheels.sh python3.9 "-i https://test.pypi.org/simple/NEURON==7.8.11.2" ``` +This covers import/`neuron.test()`, basic `nrnivmodl`, and a few MPI / +CoreNEURON paths when available. It is intentionally smaller than a full +developer `ctest` run. + +### Foreign CTest against a wheel (portable suite) + +For broader coverage without rebuilding NEURON, configure the standalone +project under `test/foreign` against a venv that has the wheel installed: + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -U pip pytest +# local wheel, or e.g. neuron-nightly from PyPI: +pip install path/to/NEURON-*.whl +# pip install neuron-nightly + +# From the NEURON source tree (same revision as the wheel when possible): +cmake -S test/foreign -B build-ctest \ + -DNRN_FOREIGN_PYTHON="$(which python)" \ + -DNRN_FOREIGN_ALLOW_SKEW=ON # only if source tip ≠ wheel revision + +cmake --build build-ctest --target test-install -j +# default: build mechanisms + ctest -L serial + +# Full ctest control against the foreign binary dir: +ctest --test-dir build-ctest -L mpi --output-on-failure -j2 +ctest --test-dir build-ctest -L coreneuron --output-on-failure -j2 +``` + +Notes: + +* Version policy defaults to a hard match between the wheel’s git identity + and this source tree; use `-DNRN_FOREIGN_ALLOW_SKEW=ON` for exploratory + runs (for example `neuron-nightly` vs a feature branch). +* MPI tests register only if the wheel was built with MPI **and** `mpiexec` + is on `PATH` at foreign configure time. +* See `test/foreign/README.md` and `test/foreign/INVENTORY.md` for + labels, dependencies (e.g. RxD plot packages), and what remains + build-only (Catch2 unit tests, NMODL unit binaries, …). + +The same foreign harness is used after a **prefix install** via the main +build target `test-install` when `NRN_ENABLE_TESTS=ON` (see the CMake +option documentation for `NRN_ENABLE_TESTS`). + ### MacOS considerations On MacOS, launching `nrniv -python` or `special -python` can fail to load `neuron` module due to security restrictions. -For this specific purpose, please `export SKIP_EMBEDED_PYTHON_TEST=true` before launching the tests. - +For this specific purpose, please `export SKIP_EMBEDED_PYTHON_TEST=true` before launching the tests +(for `test_wheels.sh`). ## Publishing the wheels on Pypi via Azure ### Variables that drive PyPI upload diff --git a/test/foreign/CMakeLists.txt b/test/foreign/CMakeLists.txt new file mode 100644 index 0000000000..4392119934 --- /dev/null +++ b/test/foreign/CMakeLists.txt @@ -0,0 +1,143 @@ +# Standalone project: run NEURON integration tests against a foreign (wheel) install. See PLAN.md +# and README.md. +cmake_minimum_required(VERSION 3.20 FATAL_ERROR) +project(nrn-foreign-ctest LANGUAGES NONE) + +include(CTest) +if(NOT BUILD_TESTING) + message(FATAL_ERROR "BUILD_TESTING is OFF; foreign ctest requires testing to be enabled") +endif() + +# Repo root (nrn/) — this file lives at test/foreign/CMakeLists.txt +get_filename_component(NRN_FOREIGN_SOURCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(NRN_FOREIGN_SOURCE_ROOT + "${NRN_FOREIGN_SOURCE_ROOT}" + CACHE PATH "NEURON source tree root used for tests and version matching") + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}") + +include(DiscoverNeuron) +include(VersionGate) +include(ForeignTestHelpers) + +if(NOT DEFINED NRN_FOREIGN_PATH_PREFIX OR NRN_FOREIGN_PATH_PREFIX STREQUAL "") + get_filename_component(NRN_FOREIGN_PATH_PREFIX "${NRN_FOREIGN_PYTHON}" DIRECTORY) +endif() +# Prefer prefix site path when set; otherwise empty PYTHONPATH (wheel/site-packages). +if(DEFINED NRN_FOREIGN_SITE_PYTHONPATH AND NOT NRN_FOREIGN_SITE_PYTHONPATH STREQUAL "") + set(_nrn_foreign_smoke_pythonpath "${NRN_FOREIGN_SITE_PYTHONPATH}") +else() + set(_nrn_foreign_smoke_pythonpath "") +endif() +set(_nrn_foreign_test_env "PYTHONPATH=${_nrn_foreign_smoke_pythonpath}" + "PATH=${NRN_FOREIGN_PATH_PREFIX}:$ENV{PATH}") + +# --------------------------------------------------------------------------- +# Smoke tests (M1) +# --------------------------------------------------------------------------- +if(NRN_FOREIGN_NRNIV STREQUAL "") + message(WARNING "nrniv not found on PATH for foreign Python; nrniv smoke test skipped") +else() + add_test(NAME foreign::smoke_nrniv COMMAND "${NRN_FOREIGN_NRNIV}" -nogui -nopython -nobanner -c + "quit()") + set_tests_properties(foreign::smoke_nrniv PROPERTIES LABELS "foreign;serial;smoke" TIMEOUT 60 + ENVIRONMENT "${_nrn_foreign_test_env}") +endif() + +add_test(NAME foreign::smoke_import COMMAND "${NRN_FOREIGN_PYTHON}" -c + "import neuron; print(neuron.__version__)") +set_tests_properties(foreign::smoke_import PROPERTIES LABELS "foreign;serial;smoke" TIMEOUT 60 + ENVIRONMENT "${_nrn_foreign_test_env}") + +option(NRN_FOREIGN_SMOKE_NEURON_TEST "Run neuron.test() as a foreign smoke test" ON) +if(NRN_FOREIGN_SMOKE_NEURON_TEST) + add_test(NAME foreign::smoke_neuron_test + COMMAND "${NRN_FOREIGN_PYTHON}" -c + "import neuron; raise SystemExit(0 if neuron.test() else 1)") + set_tests_properties( + foreign::smoke_neuron_test PROPERTIES LABELS "foreign;serial;smoke" TIMEOUT 600 ENVIRONMENT + "${_nrn_foreign_test_env}") +endif() + +# --------------------------------------------------------------------------- +# Serial portable integration suite (M3) +# --------------------------------------------------------------------------- +set(_nrn_foreign_have_pytest OFF) +execute_process( + COMMAND "${NRN_FOREIGN_PYTHON}" -c "import pytest" + RESULT_VARIABLE _pytest_rc + OUTPUT_QUIET ERROR_QUIET) +if(_pytest_rc EQUAL 0) + set(_nrn_foreign_have_pytest ON) +else() + message(WARNING "pytest is not installed for ${NRN_FOREIGN_PYTHON}. " + "Install with: \"${NRN_FOREIGN_PYTHON}\" -m pip install pytest\n" + "Python pytest-based groups will be skipped.") +endif() + +include(SerialPortableTests.cmake) +include(MpiCoreNeuronTests.cmake) + +# Target that builds all foreign nrnivmodl (special) jobs and test prep. +add_custom_target(foreign COMMENT "Build mechanisms for foreign ctest groups") +if(NRN_FOREIGN_NRNIVMODL_TARGETS) + add_dependencies(foreign ${NRN_FOREIGN_NRNIVMODL_TARGETS}) + list(LENGTH NRN_FOREIGN_NRNIVMODL_TARGETS _nprep) + message(STATUS "Foreign prep targets (${_nprep})") +else() + message(STATUS "Foreign prep targets : (none)") +endif() + +# Default install check: build mechanisms, run serial ctest, print how to re-run ctest. Full +# filtering stays with plain ctest --test-dir ... +set(NRN_FOREIGN_TEST_INSTALL_LABELS + "serial" + CACHE STRING "CTest -L expression used by the test-install target") +set(NRN_FOREIGN_TEST_INSTALL_JOBS + "4" + CACHE STRING "ctest -j parallelism for the test-install target") +add_custom_target( + test-install + COMMAND + ${CMAKE_COMMAND} "-DNRN_FOREIGN_CTEST_COMMAND=${CMAKE_CTEST_COMMAND}" + "-DNRN_FOREIGN_BINARY_DIR=${CMAKE_BINARY_DIR}" + "-DNRN_FOREIGN_TEST_INSTALL_LABELS=${NRN_FOREIGN_TEST_INSTALL_LABELS}" + "-DNRN_FOREIGN_TEST_INSTALL_JOBS=${NRN_FOREIGN_TEST_INSTALL_JOBS}" -P + "${CMAKE_CURRENT_SOURCE_DIR}/RunTestInstall.cmake" + DEPENDS foreign + USES_TERMINAL + COMMENT + "Foreign install check: ctest -L ${NRN_FOREIGN_TEST_INSTALL_LABELS} (see target foreign + ctest)" +) + +# Configure-time summary (how many tests landed in each bucket) +set(_n_serial 0) +set(_n_mpi 0) +set(_n_cn 0) +set(_n_all 0) +get_property( + _all_tests + DIRECTORY + PROPERTY TESTS) +foreach(_t ${_all_tests}) + math(EXPR _n_all "${_n_all}+1") + get_test_property("${_t}" LABELS _labs) + if(_labs) + if("serial" IN_LIST _labs) + math(EXPR _n_serial "${_n_serial}+1") + endif() + if("mpi" IN_LIST _labs) + math(EXPR _n_mpi "${_n_mpi}+1") + endif() + if("coreneuron" IN_LIST _labs) + math(EXPR _n_cn "${_n_cn}+1") + endif() + endif() +endforeach() + +message(STATUS "Foreign ctest source root : ${NRN_FOREIGN_SOURCE_ROOT}") +message(STATUS "Foreign tests registered : ${_n_all} total" + " (serial≈${_n_serial}, mpi≈${_n_mpi}, coreneuron≈${_n_cn})") +message(STATUS "Inventory : ${CMAKE_CURRENT_SOURCE_DIR}/INVENTORY.md") +message(STATUS "Configure complete. cmake --build --target test-install") +message(STATUS " (or: --target foreign && ctest --test-dir -L serial)") diff --git a/test/foreign/DiscoverNeuron.cmake b/test/foreign/DiscoverNeuron.cmake new file mode 100644 index 0000000000..a359fa99be --- /dev/null +++ b/test/foreign/DiscoverNeuron.cmake @@ -0,0 +1,221 @@ +# Discover a foreign (installed) NEURON via NRN_FOREIGN_PYTHON and probe_neuron.py. +# +# Sets (CACHE INTERNAL unless noted): NRN_FOREIGN_PYTHON - interpreter used for +# discovery (user-facing CACHE) NRN_FOREIGN_NEURON_VERSION NRN_FOREIGN_NEURON_VERSION_FULL +# NRN_FOREIGN_NEURON_GIT_SHA NRN_FOREIGN_NEURON_FILE NRN_FOREIGN_NRNIV NRN_FOREIGN_NRNIVMODL +# NRN_FOREIGN_MODLUNIT NRN_FOREIGN_MPIEXEC NRN_FOREIGN_FEATURE_ - ON/OFF for known keys +# NRN_FOREIGN_PROBE_JSON - path to last probe output file + +set(NRN_FOREIGN_PYTHON + "" + CACHE FILEPATH "Python interpreter that can import the foreign NEURON install (venv wheel)") +set(NRN_FOREIGN_ROOT + "" + CACHE PATH "Optional install prefix (bin/ prepended for discovery and tests; prefix backend)") + +if(NRN_FOREIGN_PYTHON STREQUAL "") + find_package( + Python3 + COMPONENTS Interpreter + REQUIRED) + set(NRN_FOREIGN_PYTHON + "${Python3_EXECUTABLE}" + CACHE FILEPATH "Python interpreter that can import the foreign NEURON install (venv wheel)" + FORCE) +endif() + +if(NOT EXISTS "${NRN_FOREIGN_PYTHON}") + message(FATAL_ERROR "NRN_FOREIGN_PYTHON does not exist: ${NRN_FOREIGN_PYTHON}") +endif() + +set(_NRN_FOREIGN_PROBE "${CMAKE_CURRENT_LIST_DIR}/probe_neuron.py") +if(NOT EXISTS "${_NRN_FOREIGN_PROBE}") + message(FATAL_ERROR "Missing probe script: ${_NRN_FOREIGN_PROBE}") +endif() + +set(_NRN_FOREIGN_PROBE_OUT "${CMAKE_BINARY_DIR}/foreign_neuron_probe.json") + +# Prefer scripts next to NRN_FOREIGN_PYTHON (venv bin/) and optional NRN_FOREIGN_ROOT/bin. Drop +# ambient PYTHONPATH so a developer’s *other* build-tree install cannot shadow the foreign install. +# For a classic prefix, put ${ROOT}/lib/python on PYTHONPATH (NEURON’s default +# NRN_INSTALL_PYTHON_PREFIX parent). Wheels rely on site-packages instead. +get_filename_component(_nrn_foreign_py_bindir "${NRN_FOREIGN_PYTHON}" DIRECTORY) +set(_nrn_foreign_probe_path "${_nrn_foreign_py_bindir}") +set(_nrn_foreign_probe_pythonpath "") +if(NOT NRN_FOREIGN_ROOT STREQUAL "") + if(NOT IS_DIRECTORY "${NRN_FOREIGN_ROOT}") + message(FATAL_ERROR "NRN_FOREIGN_ROOT is not a directory: ${NRN_FOREIGN_ROOT}") + endif() + set(_nrn_foreign_probe_path "${NRN_FOREIGN_ROOT}/bin:${_nrn_foreign_py_bindir}") + set(_nrn_foreign_probe_pythonpath "${NRN_FOREIGN_ROOT}/lib/python") + message(STATUS "Foreign root (prefix) : ${NRN_FOREIGN_ROOT}") +endif() +# Optional override (colon-separated), e.g. custom NRN_INSTALL_PYTHON_PREFIX parent. +set(NRN_FOREIGN_PYTHONPATH + "" + CACHE STRING "Optional PYTHONPATH entries for foreign discovery/tests (prepended)") +if(NOT NRN_FOREIGN_PYTHONPATH STREQUAL "") + if(_nrn_foreign_probe_pythonpath STREQUAL "") + set(_nrn_foreign_probe_pythonpath "${NRN_FOREIGN_PYTHONPATH}") + else() + set(_nrn_foreign_probe_pythonpath "${NRN_FOREIGN_PYTHONPATH}:${_nrn_foreign_probe_pythonpath}") + endif() +endif() +# Expose for ForeignTestHelpers / tests (FORCE so reconfigure with ROOT updates). +set(NRN_FOREIGN_PATH_PREFIX + "${_nrn_foreign_probe_path}" + CACHE INTERNAL "PATH prefix for foreign tools" FORCE) +set(NRN_FOREIGN_SITE_PYTHONPATH + "${_nrn_foreign_probe_pythonpath}" + CACHE INTERNAL "PYTHONPATH prefix for foreign neuron package (prefix installs)" FORCE) +message(STATUS "Foreign site PYTHONPATH : ${NRN_FOREIGN_SITE_PYTHONPATH}") + +execute_process( + COMMAND + "${CMAKE_COMMAND}" -E env "PYTHONPATH=${_nrn_foreign_probe_pythonpath}" + "PATH=${_nrn_foreign_probe_path}:$ENV{PATH}" "${NRN_FOREIGN_PYTHON}" "${_NRN_FOREIGN_PROBE}" + RESULT_VARIABLE _probe_rc + OUTPUT_VARIABLE _probe_stdout + ERROR_VARIABLE _probe_stderr + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_STRIP_TRAILING_WHITESPACE) + +if(NOT _probe_rc EQUAL 0) + message( + FATAL_ERROR + "Foreign NEURON probe failed (rc=${_probe_rc}).\n" + "Python: ${NRN_FOREIGN_PYTHON}\n" + "PYTHONPATH=${_nrn_foreign_probe_pythonpath}\n" + "stdout:\n${_probe_stdout}\n" + "stderr:\n${_probe_stderr}\n" + "For a wheel: pip install neuron-nightly into a venv and pass that python.\n" + "For a prefix: ninja install first; set -DNRN_FOREIGN_ROOT=.") +endif() + +file(WRITE "${_NRN_FOREIGN_PROBE_OUT}" "${_probe_stdout}\n") +set(NRN_FOREIGN_PROBE_JSON + "${_NRN_FOREIGN_PROBE_OUT}" + CACHE FILEPATH "Path to foreign NEURON probe JSON" FORCE) + +# CMake 3.19+ string(JSON). path is a semicolon list of members, e.g. "tools;nrniv" +function(_nrn_foreign_json_get out_var json_text) + set(_path ${ARGN}) + string( + JSON + _query + ERROR_VARIABLE + _jerr + GET + "${json_text}" + ${_path}) + if(_jerr) + set(${out_var} + "" + PARENT_SCOPE) + return() + endif() + if(_query STREQUAL "null") + set(_query "") + endif() + set(${out_var} + "${_query}" + PARENT_SCOPE) +endfunction() + +_nrn_foreign_json_get(NRN_FOREIGN_NEURON_VERSION "${_probe_stdout}" version) +_nrn_foreign_json_get(NRN_FOREIGN_NEURON_VERSION_FULL "${_probe_stdout}" version_full) +_nrn_foreign_json_get(NRN_FOREIGN_NEURON_GIT_SHA "${_probe_stdout}" git_sha) +_nrn_foreign_json_get(NRN_FOREIGN_NEURON_FILE "${_probe_stdout}" neuron_file) +_nrn_foreign_json_get(NRN_FOREIGN_NRNIV "${_probe_stdout}" tools nrniv) +_nrn_foreign_json_get(NRN_FOREIGN_NRNIVMODL "${_probe_stdout}" tools nrnivmodl) +_nrn_foreign_json_get(NRN_FOREIGN_MODLUNIT "${_probe_stdout}" tools modlunit) +_nrn_foreign_json_get(NRN_FOREIGN_MPIEXEC "${_probe_stdout}" tools mpiexec) + +# Feature flags commonly used by NeuronTestHelper REQUIRES +set(_feature_keys + NRN_ENABLE_PYTHON + NRN_ENABLE_MPI + NRN_ENABLE_MPI_DYNAMIC + NRN_ENABLE_CORENEURON + NRN_ENABLE_RX3D + NRN_ENABLE_THREADS + NRN_ENABLE_MUSIC + CORENRN_ENABLE_GPU + CORENRN_ENABLE_SHARED) + +_nrn_foreign_json_get(_features_error "${_probe_stdout}" features _error) +if(NOT _features_error STREQUAL "") + message(WARNING "Foreign probe could not read neuron.config.arguments: ${_features_error}\n" + "All NRN_FOREIGN_FEATURE_* flags default to OFF.") +endif() + +set(_missing_feature_keys "") +foreach(_fk IN LISTS _feature_keys) + _nrn_foreign_json_get(_fval "${_probe_stdout}" features "${_fk}") + # string(JSON) maps JSON true/false to ON/OFF (CMake booleans) + set(_on OFF) + set(_known ON) + if(_fval STREQUAL "") + # Missing key (old wheel) → feature off, note it + set(_known OFF) + list(APPEND _missing_feature_keys "${_fk}") + elseif( + _fval STREQUAL "ON" + OR _fval STREQUAL "1" + OR _fval STREQUAL "TRUE" + OR _fval STREQUAL "true" + OR _fval STREQUAL "True") + set(_on ON) + endif() + set(NRN_FOREIGN_FEATURE_${_fk} + "${_on}" + CACHE BOOL "Foreign NEURON feature ${_fk}" FORCE) + set(NRN_FOREIGN_FEATURE_${_fk}_KNOWN + "${_known}" + CACHE BOOL "Whether wheel reported ${_fk}" FORCE) +endforeach() +if(NOT _missing_feature_keys STREQUAL "") + message(STATUS "Foreign feature keys missing (treated as OFF): ${_missing_feature_keys}") +endif() + +# Required tools for any useful foreign run +if(NRN_FOREIGN_NRNIVMODL STREQUAL "") + message( + FATAL_ERROR + "Foreign nrnivmodl not found on PATH for ${NRN_FOREIGN_PYTHON}.\n" + "Ensure the venv/prefix bin directory is used (NRN_FOREIGN_PYTHON / " "NRN_FOREIGN_ROOT).") +endif() +if(NRN_FOREIGN_NRNIV STREQUAL "") + message(WARNING "Foreign nrniv not found on PATH; HOC smoke and some tests will be skipped") +endif() + +# MPI tests need a launcher on the host even if the wheel was built with MPI +if(NRN_FOREIGN_FEATURE_NRN_ENABLE_MPI AND NRN_FOREIGN_MPIEXEC STREQUAL "") + message(STATUS "Foreign NEURON has MPI, but mpiexec was not found on PATH; " + "MPI tests will not be registered until a launcher is available") + set(NRN_FOREIGN_FEATURE_NRN_ENABLE_MPI + OFF + CACHE BOOL "Foreign NEURON feature NRN_ENABLE_MPI" FORCE) +endif() + +# If CoreNEURON is on but SHARED is unknown/missing, assume shared (wheels are shared). +if(NRN_FOREIGN_FEATURE_NRN_ENABLE_CORENEURON AND NOT + NRN_FOREIGN_FEATURE_CORENRN_ENABLE_SHARED_KNOWN) + message(STATUS "CORENRN_ENABLE_SHARED not reported by wheel; assuming ON (typical for wheels)") + set(NRN_FOREIGN_FEATURE_CORENRN_ENABLE_SHARED + ON + CACHE BOOL "Foreign NEURON feature CORENRN_ENABLE_SHARED" FORCE) +endif() + +message(STATUS "Foreign NEURON python : ${NRN_FOREIGN_PYTHON}") +message(STATUS "Foreign NEURON module : ${NRN_FOREIGN_NEURON_FILE}") +message(STATUS "Foreign NEURON version : ${NRN_FOREIGN_NEURON_VERSION_FULL}") +message(STATUS "Foreign NEURON git sha : ${NRN_FOREIGN_NEURON_GIT_SHA}") +message(STATUS "Foreign nrniv : ${NRN_FOREIGN_NRNIV}") +message(STATUS "Foreign nrnivmodl : ${NRN_FOREIGN_NRNIVMODL}") +message(STATUS "Foreign MPI : ${NRN_FOREIGN_FEATURE_NRN_ENABLE_MPI}") +message(STATUS "Foreign MPI dynamic : ${NRN_FOREIGN_FEATURE_NRN_ENABLE_MPI_DYNAMIC}") +message(STATUS "Foreign CoreNEURON : ${NRN_FOREIGN_FEATURE_NRN_ENABLE_CORENEURON}") +message(STATUS "Foreign CoreNEURON shared : ${NRN_FOREIGN_FEATURE_CORENRN_ENABLE_SHARED}") +message(STATUS "Foreign GPU : ${NRN_FOREIGN_FEATURE_CORENRN_ENABLE_GPU}") +message(STATUS "Foreign mpiexec : ${NRN_FOREIGN_MPIEXEC}") +message(STATUS "Foreign probe JSON : ${NRN_FOREIGN_PROBE_JSON}") diff --git a/test/foreign/ForeignTestHelpers.cmake b/test/foreign/ForeignTestHelpers.cmake new file mode 100644 index 0000000000..abbb5db1a3 --- /dev/null +++ b/test/foreign/ForeignTestHelpers.cmake @@ -0,0 +1,132 @@ +# Configure NeuronTestHelper.cmake for a foreign (wheel) NEURON install. Call after DiscoverNeuron / +# VersionGate, with NRN_FOREIGN_SOURCE_ROOT set. + +set(NRN_FOREIGN_MODE ON) +set(NRN_TEST_SOURCE_ROOT "${NRN_FOREIGN_SOURCE_ROOT}") +set(NRN_TEST_BINARY_ROOT "${CMAKE_BINARY_DIR}") +set(NRN_NRNIVMODL "${NRN_FOREIGN_NRNIVMODL}") +# No in-tree libnrniv / CoreNEURON targets to depend on. +set(NRN_NRNIVMODL_DEPENDS "") + +# Map discovered features onto the variables NeuronTestHelper expects. +set(NRN_ENABLE_PYTHON ${NRN_FOREIGN_FEATURE_NRN_ENABLE_PYTHON}) +set(NRN_ENABLE_MPI ${NRN_FOREIGN_FEATURE_NRN_ENABLE_MPI}) +set(NRN_ENABLE_MPI_DYNAMIC ${NRN_FOREIGN_FEATURE_NRN_ENABLE_MPI_DYNAMIC}) +set(NRN_ENABLE_CORENEURON ${NRN_FOREIGN_FEATURE_NRN_ENABLE_CORENEURON}) +set(NRN_ENABLE_RX3D ${NRN_FOREIGN_FEATURE_NRN_ENABLE_RX3D}) +set(NRN_ENABLE_THREADS ${NRN_FOREIGN_FEATURE_NRN_ENABLE_THREADS}) +set(NRN_ENABLE_MUSIC ${NRN_FOREIGN_FEATURE_NRN_ENABLE_MUSIC}) +set(CORENRN_ENABLE_GPU ${NRN_FOREIGN_FEATURE_CORENRN_ENABLE_GPU}) +set(CORENRN_ENABLE_SHARED ${NRN_FOREIGN_FEATURE_CORENRN_ENABLE_SHARED}) +# Wheels with CoreNEURON use mod compatibility; without CN leave off. +if(NRN_ENABLE_CORENEURON) + set(NRN_ENABLE_MOD_COMPATIBILITY ON) +else() + set(NRN_ENABLE_MOD_COMPATIBILITY OFF) +endif() + +if(NRN_FOREIGN_NRNIVMODL STREQUAL "") + message(FATAL_ERROR "Foreign nrnivmodl not found; cannot register mechanism-based tests") +endif() + +get_filename_component(_nrn_foreign_py_bindir "${NRN_FOREIGN_PYTHON}" DIRECTORY) +if(NOT DEFINED NRN_FOREIGN_PATH_PREFIX OR NRN_FOREIGN_PATH_PREFIX STREQUAL "") + set(NRN_FOREIGN_PATH_PREFIX "${_nrn_foreign_py_bindir}") +endif() + +# Environment for nrnivmodl + tests: venv/prefix tools first. PYTHONPATH: optional prefix lib/python +# (classic install) + in-tree test/rxd helpers. Wheels keep using site-packages via the +# interpreter's site mechanism when the prefix entry is empty. +set(_nrn_foreign_test_pythonpath "${NRN_FOREIGN_SOURCE_ROOT}/test/rxd") +if(DEFINED NRN_FOREIGN_SITE_PYTHONPATH AND NOT NRN_FOREIGN_SITE_PYTHONPATH STREQUAL "") + set(_nrn_foreign_test_pythonpath "${NRN_FOREIGN_SITE_PYTHONPATH}:${_nrn_foreign_test_pythonpath}") +endif() +set(NRN_RUN_FROM_BUILD_DIR_ENV "PATH=${NRN_FOREIGN_PATH_PREFIX}:$ENV{PATH}" + "PYTHONPATH=${_nrn_foreign_test_pythonpath}") + +# Prefer the foreign interpreter for any test that runs Python. +set(NRN_DEFAULT_PYTHON_EXECUTABLE "${NRN_FOREIGN_PYTHON}") + +# MPI launcher (absolute path preferred so tests work if mpiexec is not first on PATH). +set(MPIEXEC_NAME "${NRN_FOREIGN_MPIEXEC}") +set(MPIEXEC_NUMPROC_FLAG + "-n" + CACHE STRING "mpiexec flag for process count") +set(MPIEXEC_PREFLAGS + "" + CACHE STRING "mpiexec flags before the executable") +set(MPIEXEC_POSTFLAGS + "" + CACHE STRING "mpiexec flags after the executable") +set(MPIEXEC_OVERSUBSCRIBE "") +if(NRN_ENABLE_MPI AND NOT MPIEXEC_NAME STREQUAL "") + execute_process( + COMMAND ${MPIEXEC_NAME} --oversubscribe --version + RESULT_VARIABLE _over_rc + OUTPUT_QUIET ERROR_QUIET) + if(_over_rc EQUAL 0) + set(MPIEXEC_OVERSUBSCRIBE "--oversubscribe") + endif() + get_filename_component(_nrn_foreign_mpiexec_dir "${MPIEXEC_NAME}" DIRECTORY) + # Ensure launcher directory is on PATH for nested tools (keep site PYTHONPATH). + set(NRN_RUN_FROM_BUILD_DIR_ENV + "PATH=${NRN_FOREIGN_PATH_PREFIX}:${_nrn_foreign_mpiexec_dir}:$ENV{PATH}" + "PYTHONPATH=${_nrn_foreign_test_pythonpath}") + message(STATUS "Foreign mpiexec : ${MPIEXEC_NAME}") + message(STATUS "Foreign mpiexec oversub : ${MPIEXEC_OVERSUBSCRIBE}") +endif() + +# coding-conventions is optional for foreign mode (fallback copy in NeuronTestHelper). +if(EXISTS "${NRN_FOREIGN_SOURCE_ROOT}/external/coding-conventions/cpp/cmake/build-time-copy.cmake") + set(CODING_CONV_CMAKE "${NRN_FOREIGN_SOURCE_ROOT}/external/coding-conventions/cpp/cmake") +endif() + +include("${NRN_FOREIGN_SOURCE_ROOT}/cmake/NeuronTestHelper.cmake") + +# Collect nrnivmodl custom targets so the top-level `foreign` target can depend on them. +set(NRN_FOREIGN_NRNIVMODL_TARGETS + "" + CACHE INTERNAL "nrnivmodl targets registered for foreign ctest") + +function(nrn_foreign_track_prep_target target_name) + if(target_name AND TARGET "${target_name}") + set(_all "${NRN_FOREIGN_NRNIVMODL_TARGETS}") + list(APPEND _all "${target_name}") + list(REMOVE_DUPLICATES _all) + set(NRN_FOREIGN_NRNIVMODL_TARGETS + "${_all}" + CACHE INTERNAL "nrnivmodl targets registered for foreign ctest" FORCE) + endif() +endfunction() + +function(nrn_foreign_note_nrnivmodl_group group_name) + set(prefix NRN_TEST_GROUP_${group_name}) + nrn_foreign_track_prep_target("${${prefix}_NRNIVMODL_TARGET_NAME}") +endfunction() + +# After nrn_add_test(GROUP g NAME n ...), hang copy-scripts and labels off `foreign`. Extra ARGN +# tokens become additional CTest labels. Default label "serial" is added unless ARGN includes "mpi" +# or "noserial". +function(nrn_foreign_finalize_test group_name test_name) + set(copy_tgt "copy-scripts-${group_name}-${test_name}") + nrn_foreign_track_prep_target("${copy_tgt}") + set(full_name "${group_name}::${test_name}") + # LABELS must be one property value (semicolon-separated). Bare tokens after LABELS are parsed as + # additional PROPERTY names. + set(_labels "foreign") + set(_add_serial ON) + foreach(_l ${ARGN}) + if(_l STREQUAL "mpi" OR _l STREQUAL "noserial") + set(_add_serial OFF) + endif() + endforeach() + if(_add_serial) + string(APPEND _labels ";serial") + endif() + foreach(_l ${ARGN}) + if(NOT _l STREQUAL "noserial") + string(APPEND _labels ";${_l}") + endif() + endforeach() + set_tests_properties("${full_name}" PROPERTIES LABELS "${_labels}") +endfunction() diff --git a/test/foreign/INVENTORY.md b/test/foreign/INVENTORY.md new file mode 100644 index 0000000000..77e3634a29 --- /dev/null +++ b/test/foreign/INVENTORY.md @@ -0,0 +1,78 @@ +# Foreign ctest inventory + +Which integration tests the **foreign** (wheel) harness can run vs what still +requires an in-tree NEURON build. + +Last updated with M5. Labels refer to `ctest -L …` on a `test/foreign` build. + +## Foreign-ok (registered by `test/foreign`) + +| Area | Labels | Needs | +|------|--------|--------| +| Smoke import / nrniv / `neuron.test()` | `smoke`, `serial` | wheel + PATH | +| `pytest` (mcna) | `pytest`, `serial` | `pytest`, nrnivmodl | +| `datahandle` | `datahandle`, `serial` | `pytest`, nrnivmodl | +| `coverage_tests` / cover | `cover`, `serial` | `pytest`, nrnivmodl | +| `unit_tests` hoc_python | `unit`, `serial` | `pytest` | +| `example_nmodl` | `example_nmodl`, `serial` | nrnivmodl / special | +| `hoctests` | `hoctests`, `serial` | nrnivmodl / special | +| `ringtest`, `connect_dend` | `ringtest` / `connect_dend`, `serial` | nrniv | +| `rxdmod_tests` | `rxd`, `serial` | RX3D wheel, `test/rxd/testdata`, `pytest`, optional matplotlib/plotly/anywidget | +| `gjtests` serial | `gjtests`, `serial` | `pytest`, nrnivmodl | +| `mpi_init` | `mpi` | MPI wheel + `mpiexec` | +| `parallel` | `mpi`, `parallel` | MPI + `mpiexec` + nrnivmodl | +| `pytest_coreneuron` | `coreneuron`, `serial` | CoreNEURON wheel, `pytest` | +| `coreneuron_*` CPU | `coreneuron` | CoreNEURON + nrnivmodl `-coreneuron` | +| CN + MPI (`inputpresyn`, …) | `mpi`, `coreneuron` | both | +| `gjtests::gj_par` | `mpi` | MPI + `mpiexec` | + +### Clean skip (not failure) + +| Condition | Effect | +|-----------|--------| +| No `mpiexec` at configure | MPI treated as off; no `-L mpi` tests | +| Wheel without CoreNEURON | No `-L coreneuron` tests | +| Wheel without RX3D / empty testdata | No `rxdmod_tests` | +| No `pytest` | Python pytest groups skipped (warning) | +| GPU off in wheel | No GPU CoreNEURON variants (not registered) | + +## Build-only (not foreign) + +These need a full NEURON CMake build (`libnrniv`, Catch2, etc.): + +| Area | Why build-only | +|------|----------------| +| `testneuron` / container Catch2 units | Links `nrniv_lib` | +| API C++ tests (`test/api`) | Links public C API / internals | +| CoreNEURON C++ unit tests | Links CoreNEURON unit libraries | +| NMODL transpiler unit / integration binaries | Builds against NMODL libs / `nmodl` target | +| Sanitizer / coverage instrumented binaries | Build-time instrumentation | +| MUSIC tests | Rare external dep; not wired for foreign | +| External model submodule suites (beyond rxd data) | Often assume full build env | +| `nrnivmodl_cmake` install-check against *this* build tree | Different goal (install layout of the tree under test) | +| pyinit multi-venv matrix | Assumes multiple build-configured Pythons | + +## Version policy (quick) + +| Mode | Behavior | +|------|----------| +| Default local | Mismatch → configure **fails** | +| `-DNRN_FOREIGN_ALLOW_SKEW=ON` | Mismatch → **warn**, continue | +| `-DNRN_FOREIGN_CI=ON` | Mismatch → **fails** (hard match) | + +Match rules (see `VersionGate.cmake`): git SHA (flexible length / embed in describe), +then full version string equality. + +## Optional prefix install + +Wheels remain primary. A classic prefix works if: + +```bash +export PATH=/path/to/prefix/bin:$PATH +cmake -S test/foreign -B build-ctest \ + -DNRN_FOREIGN_PYTHON="$(which python3)" \ + -DNRN_FOREIGN_ROOT=/path/to/prefix \ + -DNRN_FOREIGN_ALLOW_SKEW=ON # if needed +``` + +`NRN_FOREIGN_ROOT` prepends `bin/` for discovery and test PATH (prefix backend). diff --git a/test/foreign/MpiCoreNeuronTests.cmake b/test/foreign/MpiCoreNeuronTests.cmake new file mode 100644 index 0000000000..10fd9b1e0f --- /dev/null +++ b/test/foreign/MpiCoreNeuronTests.cmake @@ -0,0 +1,269 @@ +# MPI and CoreNEURON integration tests for foreign (wheel) mode (M4). Included after +# SerialPortableTests.cmake. Tests are registered only when the wheel reports the feature and the +# host has the needed tools (mpiexec, etc.). + +set(_nrn_foreign_pytest_args --capture=tee-sys) + +# --------------------------------------------------------------------------- +# MPI smoke (no mechanism compile) +# --------------------------------------------------------------------------- +if(NRN_ENABLE_MPI AND NOT MPIEXEC_NAME STREQUAL "") + nrn_add_test_group(NAME mpi_init MODFILE_PATTERNS NONE) + + nrn_add_test( + GROUP mpi_init + NAME nrniv_mpiopt + REQUIRES mpi + COMMAND nrniv -mpi -c "quit()") + nrn_foreign_finalize_test(mpi_init nrniv_mpiopt mpi) + + nrn_add_test( + GROUP mpi_init + NAME nrniv_nrnmpi_init + REQUIRES mpi + COMMAND nrniv -c "nrnmpi_init()" -c "quit()") + nrn_foreign_finalize_test(mpi_init nrniv_nrnmpi_init mpi) + + nrn_add_test( + GROUP mpi_init + NAME python_nrnmpi_init + REQUIRES python mpi + COMMAND "${NRN_FOREIGN_PYTHON}" -c + "from neuron import h$ h.nrnmpi_init()$ h.quit()") + nrn_foreign_finalize_test(mpi_init python_nrnmpi_init mpi) + + nrn_add_test( + GROUP mpi_init + NAME mpiexec_nrniv + REQUIRES mpi + PROCESSORS 2 + COMMAND ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 2 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + nrniv ${MPIEXEC_POSTFLAGS} -mpi -c "quit()") + nrn_foreign_finalize_test(mpi_init mpiexec_nrniv mpi) + + nrn_add_test( + GROUP mpi_init + NAME mpiexec_python + REQUIRES python mpi + PROCESSORS 2 + COMMAND + ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 2 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + "${NRN_FOREIGN_PYTHON}" ${MPIEXEC_POSTFLAGS} -c + "from neuron import h$ h.nrnmpi_init()$ h.quit()") + nrn_foreign_finalize_test(mpi_init mpiexec_python mpi) +endif() + +# --------------------------------------------------------------------------- +# Parallel integration (NEURON MPI, shared mod build with pytest_coreneuron) +# --------------------------------------------------------------------------- +if(NRN_ENABLE_MPI AND NOT MPIEXEC_NAME STREQUAL "") + nrn_add_test_group(NAME parallel MODFILE_PATTERNS test/pytest_coreneuron/*.mod) + nrn_foreign_note_nrnivmodl_group(parallel) + + nrn_add_test( + GROUP parallel + NAME bas + PROCESSORS 2 + REQUIRES mpi + SCRIPT_PATTERNS test/parallel_tests/test_bas.py + COMMAND ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 2 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + nrniv ${MPIEXEC_POSTFLAGS} -mpi -python test/parallel_tests/test_bas.py) + nrn_foreign_finalize_test(parallel bas mpi parallel) + + nrn_add_test( + GROUP parallel + NAME partrans + PROCESSORS 2 + REQUIRES mpi + SCRIPT_PATTERNS test/pytest_coreneuron/test_partrans.py + COMMAND ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 2 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + nrniv ${MPIEXEC_POSTFLAGS} -mpi -python test/pytest_coreneuron/test_partrans.py) + nrn_foreign_finalize_test(parallel partrans mpi parallel) + + nrn_add_test( + GROUP parallel + NAME netpar + PROCESSORS 2 + REQUIRES mpi + SCRIPT_PATTERNS test/pytest_coreneuron/test_hoc_po.py test/pytest_coreneuron/test_netpar.py + COMMAND ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 2 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + nrniv ${MPIEXEC_POSTFLAGS} -mpi -python test/pytest_coreneuron/test_netpar.py) + nrn_foreign_finalize_test(parallel netpar mpi parallel) + + nrn_add_test( + GROUP parallel + NAME subworld + PROCESSORS 6 + REQUIRES mpi + SCRIPT_PATTERNS test/parallel_tests/test_subworld.py + COMMAND ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 6 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + nrniv ${MPIEXEC_POSTFLAGS} -mpi -python test/parallel_tests/test_subworld.py) + nrn_foreign_finalize_test(parallel subworld mpi parallel) + + if(_nrn_foreign_have_pytest) + string(JOIN " " _pytest_arg_string ${_nrn_foreign_pytest_args}) + nrn_add_test( + GROUP parallel + NAME nrntest_fast + PROCESSORS 2 + REQUIRES mpi + ENVIRONMENT "NRN_PYTEST_ARGS=${_pytest_arg_string}" + SCRIPT_PATTERNS + test/pytest_coreneuron/run_pytest.py test/pytest_coreneuron/test_nrntest_fast.json + test/pytest_coreneuron/test_nrntest_fast.py + COMMAND + ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 2 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + special ${MPIEXEC_POSTFLAGS} -mpi -python test/pytest_coreneuron/run_pytest.py) + nrn_foreign_finalize_test(parallel nrntest_fast mpi parallel pytest) + endif() +endif() + +# --------------------------------------------------------------------------- +# pytest_coreneuron — serial pytest + CoreNEURON-enabled nrnivmodl +# --------------------------------------------------------------------------- +if(NRN_ENABLE_CORENEURON AND _nrn_foreign_have_pytest) + nrn_add_test_group( + CORENEURON + NAME pytest_coreneuron + MODFILE_PATTERNS test/pytest_coreneuron/*.mod) + nrn_foreign_note_nrnivmodl_group(pytest_coreneuron) + nrn_add_test( + GROUP pytest_coreneuron + NAME basic_tests + REQUIRES coreneuron + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} "./test/pytest_coreneuron" + SCRIPT_PATTERNS "test/pytest_coreneuron/*.json" "test/pytest_coreneuron/*.py") + nrn_foreign_finalize_test(pytest_coreneuron basic_tests coreneuron pytest) +endif() + +# --------------------------------------------------------------------------- +# CoreNEURON mechanism tests (CPU path; shared-library wheel layout) +# --------------------------------------------------------------------------- +if(NRN_ENABLE_CORENEURON) + # Launchers match the shared CoreNEURON wheel case (python/nrniv load special mechs). + if(CORENRN_ENABLE_SHARED) + set(_cn_launch_py "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args}) + set(_cn_launch_hoc nrniv) + set(_cn_launch_py_mpi + ${MPIEXEC_NAME} + ${MPIEXEC_NUMPROC_FLAG} + 2 + ${MPIEXEC_OVERSUBSCRIBE} + ${MPIEXEC_PREFLAGS} + "${NRN_FOREIGN_PYTHON}" + ${MPIEXEC_POSTFLAGS}) + else() + set(_cn_launch_py special -notatty -mpi -python) + set(_cn_launch_hoc special -notatty -mpi) + set(_cn_launch_py_mpi + ${MPIEXEC_NAME} + ${MPIEXEC_NUMPROC_FLAG} + 2 + ${MPIEXEC_OVERSUBSCRIBE} + ${MPIEXEC_PREFLAGS} + special + ${MPIEXEC_POSTFLAGS} + -notatty + -python) + endif() + + if(CORENRN_ENABLE_SHARED AND _nrn_foreign_have_pytest) + nrn_add_test_group( + CORENEURON + NAME coreneuron_standalone + MODFILE_PATTERNS NONE) + nrn_add_test( + GROUP coreneuron_standalone + NAME test_psolve + REQUIRES coreneuron + SCRIPT_PATTERNS test/coreneuron/test_psolve.py + COMMAND ${_cn_launch_py} test/coreneuron/test_psolve.py) + nrn_foreign_finalize_test(coreneuron_standalone test_psolve coreneuron) + endif() + + nrn_add_test_group( + CORENEURON + NAME coreneuron_modtests + SCRIPT_PATTERNS test/coreneuron/test_spikes.py + MODFILE_PATTERNS + "test/coreneuron/mod files/*.mod" "test/coreneuron/mod files/axial.inc" + test/pytest_coreneuron/unitstest.mod test/pytest_coreneuron/version_macros.mod + test/gjtests/natrans.mod) + nrn_foreign_note_nrnivmodl_group(coreneuron_modtests) + + if(_nrn_foreign_have_pytest) + nrn_add_test( + GROUP coreneuron_modtests + NAME version_macros + REQUIRES coreneuron + SCRIPT_PATTERNS test/pytest_coreneuron/test_version_macros.py + ENVIRONMENT NRN_CORENEURON_ENABLE=true + COMMAND ${_cn_launch_py} test/pytest_coreneuron/test_version_macros.py) + nrn_foreign_finalize_test(coreneuron_modtests version_macros coreneuron) + + # CPU-only CoreNEURON suite (GPU left for a later milestone). + foreach( + _cn_case + fornetcon:test/coreneuron/test_fornetcon.py + direct:test/coreneuron/test_direct.py + spikes:test/coreneuron/test_spikes.py + fast_imem:test/pytest_coreneuron/test_fast_imem.py + datareturn:test/coreneuron/test_datareturn.py + units:test/coreneuron/test_units.py + netmove:test/coreneuron/test_netmove.py + pointer:test/coreneuron/test_pointer.py + watchrange:test/coreneuron/test_watchrange.py + psolve:test/coreneuron/test_psolve.py) + string(REPLACE ":" ";" _cn_pair "${_cn_case}") + list(GET _cn_pair 0 _cn_name) + list(GET _cn_pair 1 _cn_script) + nrn_add_test( + GROUP coreneuron_modtests + NAME ${_cn_name}_py_cpu + REQUIRES coreneuron cpu + SCRIPT_PATTERNS ${_cn_script} + COMMAND ${_cn_launch_py} ${_cn_script}) + nrn_foreign_finalize_test(coreneuron_modtests ${_cn_name}_py_cpu coreneuron) + endforeach() + + nrn_add_test( + GROUP coreneuron_modtests + NAME direct_hoc_cpu + REQUIRES coreneuron cpu + SCRIPT_PATTERNS test/coreneuron/test_direct.hoc + COMMAND ${_cn_launch_hoc} test/coreneuron/test_direct.hoc) + nrn_foreign_finalize_test(coreneuron_modtests direct_hoc_cpu coreneuron) + endif() + + # MPI + CoreNEURON + if(NRN_ENABLE_MPI + AND NOT MPIEXEC_NAME STREQUAL "" + AND _nrn_foreign_have_pytest) + nrn_add_test( + GROUP coreneuron_modtests + NAME inputpresyn_py + REQUIRES coreneuron mpi + SCRIPT_PATTERNS test/coreneuron/test_inputpresyn.py + PROCESSORS 2 + COMMAND ${_cn_launch_py_mpi} test/coreneuron/test_inputpresyn.py) + nrn_foreign_finalize_test(coreneuron_modtests inputpresyn_py mpi coreneuron) + endif() +endif() + +# --------------------------------------------------------------------------- +# Gap junction MPI test (serial gj covered in M3; same mod group) +# --------------------------------------------------------------------------- +if(NRN_ENABLE_MPI + AND NOT MPIEXEC_NAME STREQUAL "" + AND _nrn_foreign_have_pytest) + # Group + nrnivmodl already registered in SerialPortableTests.cmake when pytest exists. + nrn_add_test( + GROUP gjtests + NAME gj_par + REQUIRES mpi + PROCESSORS 2 + SCRIPT_PATTERNS test/gjtests/test_par_gj.py + COMMAND ${MPIEXEC_NAME} ${MPIEXEC_NUMPROC_FLAG} 2 ${MPIEXEC_OVERSUBSCRIBE} ${MPIEXEC_PREFLAGS} + "${NRN_FOREIGN_PYTHON}" ${MPIEXEC_POSTFLAGS} test/gjtests/test_par_gj.py) + nrn_foreign_finalize_test(gjtests gj_par mpi gjtests) +endif() diff --git a/test/foreign/PLAN.md b/test/foreign/PLAN.md new file mode 100644 index 0000000000..2c2950af0f --- /dev/null +++ b/test/foreign/PLAN.md @@ -0,0 +1,118 @@ +# Foreign ctest against PyPI wheels + +**Branch:** `hines-grok/ctest-wheels` +**Architecture:** separate test-only CMake project (option B) +**Dev artifact:** normal venv + `pip install neuron-nightly` +**v1 done:** documented local workflow (metric C) +**Status:** M5 complete (hardening + inventory). Branch ready for review/park. + +Approved product decisions: + +| # | Decision | +|---|----------| +| 1 | Primary consumer: PyPI wheels; Windows setup.exe later backend | +| 2 | Version policy D: CI hard match; local soft skew needs `NRN_FOREIGN_ALLOW_SKEW` | +| 3 | Harness registers serial + MPI + CoreNEURON when available; CI/default serial first | +| 4 | Success metric C: local foreign-ctest workflow (not Azure replace) | +| 5 | Wheels first; develop against `pip install neuron-nightly` | + +--- + +## Goals / non-goals + +**Goals** + +- Configure/run integration tests from the NEURON source tree against an already installed wheel. +- Discover features from the wheel (`neuron.config.arguments` + path probes). +- Version policy D as above. +- Harness can register serial + MPI + CoreNEURON when wheel/host support them. +- Windows `setup.exe` backend: design hook only in early milestones. + +**Non-goals (early milestones)** + +- Building `libnrniv` / Catch2 unit tests / NMODL unit libs in foreign mode. +- Replacing `test_wheels.sh` in Azure. +- Install-check as a milestone. +- GPU foreign suite. +- Root-monorepo `-DNRN_FOREIGN` on full NEURON build. + +--- + +## Target UX (v1) + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -U pip neuron-nightly +# checkout matching revision, or pass -DNRN_FOREIGN_ALLOW_SKEW=ON +cmake -S test/foreign -B build-ctest -DNRN_FOREIGN_PYTHON="$(which python)" +cmake --build build-ctest --target foreign +ctest --test-dir build-ctest -j8 +# later / local full features: +ctest --test-dir build-ctest -R 'mpi|coreneuron' -j8 +``` + +--- + +## Design pillars + +1. **Discovery** — `-DNRN_FOREIGN_PYTHON=`; probe script returns version, features, tool paths. +2. **Version gate** — CI (`NRN_FOREIGN_CI=ON`): mismatch fatal. Local: mismatch fatal unless `NRN_FOREIGN_ALLOW_SKEW=ON`. +3. **Features** — map wheel config (+ `mpiexec` probe) to existing `REQUIRES` flags in NeuronTestHelper. +4. **Environment** — no build-tree `NRN_RUN_FROM_BUILD_DIR_ENV`; use venv/wheel; only append test-data paths. +5. **Mechanisms** — foreign `nrnivmodl`, no `DEPENDS nrniv_lib`; target `foreign` builds mod jobs. + +--- + +## File-level sketch + +```text +test/foreign/ # standalone CMake project root + PLAN.md # this document + CMakeLists.txt # M1+ + README.md # local workflow (metric C) + DiscoverNeuron.cmake + VersionGate.cmake + probe_neuron.py + ForeignTestHelpers.cmake + +cmake/NeuronTestHelper.cmake # parameterize nrnivmodl path, deps, RUN_ENV +test/CMakeLists.txt # guards: linked units only if TARGET nrniv_lib +``` + +--- + +## Milestones + +| ID | Content | Exit | +|----|---------|------| +| **M0** | Plan + branch `hines-grok/ctest-wheels` | Done | +| **M1** | Skeleton, discovery, version gate, smoke tests | Done (import / nrniv / neuron.test) | +| **M2** | Foreign nrnivmodl + one mod-using group; target `foreign` | Done (`pytest::basic_tests`) | +| **M3** | Serial portable subset + README (v1 done) | Done (~88 tests, `-L serial`) | +| **M4** | Register MPI + CoreNEURON for local `-R` | Done (`-L mpi`, `-L coreneuron`) | +| **M5** | Hardening, inventory, optional CI/prefix | Done (no Azure; prefix via `NRN_FOREIGN_ROOT`) | + +### PR1 vertical slice + +M0 + M1 (+ M2 if small). Review commands: + +```bash +python -m venv /tmp/nrn-foreign-venv && source /tmp/nrn-foreign-venv/bin/activate +pip install neuron-nightly +cmake -S test/foreign -B /tmp/build-ctest \ + -DNRN_FOREIGN_PYTHON="$(which python)" \ + -DNRN_FOREIGN_ALLOW_SKEW=ON +cmake --build /tmp/build-ctest --target foreign +ctest --test-dir /tmp/build-ctest --output-on-failure +``` + +--- + +## Approval (2026-07-31) + +- Entry point `test/foreign` — yes +- Soft skew requires explicit `NRN_FOREIGN_ALLOW_SKEW` — yes +- M1 smoke may be `neuron.test()` before nrnivmodl — yes +- M3 = v1 done (serial local); M4 = MPI/CN local — yes +- No Azure requirement in this plan — yes +- Wheels first; `pip install neuron-nightly` in a normal venv — yes diff --git a/test/foreign/README.md b/test/foreign/README.md new file mode 100644 index 0000000000..01a4c88811 --- /dev/null +++ b/test/foreign/README.md @@ -0,0 +1,200 @@ +# Foreign ctest (PyPI wheels) + +Run NEURON integration tests from this source tree against an **already installed** +wheel (for example `neuron-nightly` in a virtual environment). + +This is a **standalone** CMake project (`cmake -S test/foreign`). It does not build +`libnrniv`. + +See [PLAN.md](PLAN.md) for milestones and design. + +## Prerequisites + +- CMake ≥ 3.20 +- A C/C++ toolchain on `PATH` (for `nrnivmodl`) +- A venv with NEURON, e.g. `pip install neuron-nightly` +- Python packages for the serial suite: + - **required for most groups:** `pytest` + - **required for full RxD:** `matplotlib`, `plotly`, `anywidget` +- Prefer a clean shell for the venv: an ambient `PYTHONPATH` pointing at a + source build can shadow the wheel. Configure/tests replace `PYTHONPATH` + with in-tree helpers only (`test/rxd`). + +Optional: + +```bash +git submodule update --init -- test/rxd/testdata # for RxD comparison data +``` + +See [INVENTORY.md](INVENTORY.md) for foreign-ok vs build-only tests and skip rules. + +## Quick start (local, metric C — serial suite) + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -U pip neuron-nightly pytest matplotlib plotly anywidget mpi4py + +# From the NEURON repo root: +git submodule update --init -- test/rxd/testdata # optional but needed for rxd + +cmake -S test/foreign -B build-ctest \ + -DNRN_FOREIGN_PYTHON="$(which python)" \ + -DNRN_FOREIGN_ALLOW_SKEW=ON + +# Default serial install check (builds mechanisms, runs ctest -L serial, prints +# how to re-run ctest with other filters): +cmake --build build-ctest --target test-install -j +# same: ninja -C build-ctest test-install + +# Or split prep and ctest yourself: +cmake --build build-ctest --target foreign -j +ctest --test-dir build-ctest -L serial --output-on-failure -j4 + +# When the wheel has MPI / CoreNEURON and mpiexec is on PATH: +ctest --test-dir build-ctest -L mpi --output-on-failure -j2 +ctest --test-dir build-ctest -L coreneuron --output-on-failure -j2 +``` + +`test-install` is a convenience default only. Full `ctest` control is always: + +```bash +ctest --test-dir build-ctest [options…] +``` + +Cache knobs (reconfigure to change defaults): + +| Variable | Default | Meaning | +|----------|---------|---------| +| `NRN_FOREIGN_TEST_INSTALL_LABELS` | `serial` | `-L` for `test-install` | +| `NRN_FOREIGN_TEST_INSTALL_JOBS` | `4` | `-j` for `test-install` | + +`NRN_FOREIGN_ALLOW_SKEW=ON` is required when the wheel’s git revision does not +match this checkout (typical for `neuron-nightly` vs a feature branch). + +### Useful filters + +| Filter | Meaning | +|--------|---------| +| `-L serial` | Full M3 portable suite (metric C “done” set) | +| `-L smoke` | Import / nrniv / `neuron.test()` only | +| `-L mpi` | MPI smoke + parallel + CN+MPI (needs `mpiexec` at configure) | +| `-L coreneuron` | CoreNEURON CPU suite (skipped if wheel has no CN) | +| `-L pytest` / `-L hoctests` / `-L rxd` / … | Subsets by group label | +| `-R 'pytest::\|datahandle::'` | Regex on test names | + +If configure cannot find `mpiexec`, MPI feature is treated as off and no `-L mpi` +tests are registered (clean skip, not failure). + +## Version policy + +| Mode | CMake options | Mismatch | +|------|----------------|----------| +| Local strict (default) | (none) | Configure **fails** | +| Local exploration | `-DNRN_FOREIGN_ALLOW_SKEW=ON` | Configure **warns**, continues | +| CI | `-DNRN_FOREIGN_CI=ON` | Configure **fails** (hard match; leave `ALLOW_SKEW` off) | + +Match rules (`VersionGate.cmake`): + +1. Normalized git SHA (flexible length; also extracted from describe strings like + `9.0.1-85-g5ac449d89` used by nightlies) +2. Exact version / `git describe` string equality + +## Prefix install (optional) + +Wheels are primary. For a classic `CMAKE_INSTALL_PREFIX` layout you can either +use the **main NEURON build** helper target or configure foreign yourself. + +### From a normal NEURON build (`-DNRN_ENABLE_TESTS=ON`) + +```bash +cmake -S . -B build -G Ninja \ + -DCMAKE_INSTALL_PREFIX=$PWD/build/install \ + -DNRN_ENABLE_TESTS=ON +cmake --build build -j +cmake --build build --target install +cmake --build build --target test-install +# or from the build directory: +# ninja install +# ninja test-install +``` + +That creates **`${CMAKE_BINARY_DIR}/build-ctest`** (e.g. `build/build-ctest`), +configures `test/foreign` against the install prefix, builds mechanisms, and +runs the default serial foreign suite. It does **not** exist until you run +`test-install` once (or configure foreign yourself into that path). + +Then filter with plain ctest on that dir: + +```bash +ctest --test-dir build/build-ctest -L mpi --output-on-failure +``` + +### Manual foreign configure against a prefix + +```bash +cmake -S test/foreign -B build-ctest \ + -DNRN_FOREIGN_PYTHON=/path/to/python \ + -DNRN_FOREIGN_ROOT=/path/to/prefix \ + -DNRN_FOREIGN_ALLOW_SKEW=ON # only if tree ≠ install revision +cmake --build build-ctest --target test-install +``` + +`NRN_FOREIGN_ROOT/bin` is prepended for tools; `NRN_FOREIGN_ROOT/lib/python` is +put on `PYTHONPATH` so `import neuron` works for the default install layout. + +## Discovery + +Configure runs `probe_neuron.py` with `NRN_FOREIGN_PYTHON` and writes +`build-ctest/foreign_neuron_probe.json` (version, features, tool paths). + +Useful cache variables (see `cmake -L` / `CMakeCache.txt`): + +- `NRN_FOREIGN_PYTHON` +- `NRN_FOREIGN_NEURON_VERSION_FULL` +- `NRN_FOREIGN_NRNIV`, `NRN_FOREIGN_NRNIVMODL` +- `NRN_FOREIGN_FEATURE_NRN_ENABLE_MPI`, `NRN_FOREIGN_FEATURE_NRN_ENABLE_CORENEURON`, … + +## What is included (M3 serial) + +| Area | Notes | +|------|--------| +| Smoke | `foreign::smoke_*` | +| `pytest`, `datahandle`, `cover` | pytest + nrnivmodl | +| `unit_tests` (hoc_python) | no mods | +| `example_nmodl` | HOC via `special`, Python via pytest | +| `hoctests` | HOC via `special`, Python via plain interpreter | +| `ringtest`, `connect_dend` | foreign `nrniv` + `RunHOCTest.cmake` | +| `rxdmod_tests` | if wheel has RX3D and testdata submodule present | +| `gjtests` serial | pytest `-k "not par"` | + +### What is included (M4 — MPI / CoreNEURON) + +| Area | Labels | Notes | +|------|--------|--------| +| `mpi_init` | `mpi` | nrniv/python MPI smoke + mpiexec | +| `parallel` | `mpi` | bas, partrans, netpar, subworld, nrntest_fast | +| `pytest_coreneuron` | `coreneuron` | serial pytest with CN-enabled special | +| `coreneuron_standalone` / `coreneuron_modtests` | `coreneuron` | CPU direct/spikes/psolve/… + `inputpresyn` MPI | +| `gjtests::gj_par` | `mpi` | multi-rank gap junction | + +**Excluded:** Catch2 / API / NMODL unit binaries, GPU CoreNEURON matrix. + +Target `foreign` builds all registered `nrnivmodl` jobs (including `-coreneuron`) and copies test scripts. + +## Layout + +```text +test/foreign/ + CMakeLists.txt # project entry + DiscoverNeuron.cmake + VersionGate.cmake + ForeignTestHelpers.cmake # NeuronTestHelper foreign adapter + SerialPortableTests.cmake # M3 serial groups + MpiCoreNeuronTests.cmake # M4 MPI + CoreNEURON + RunTestInstall.cmake # body of test-install target + probe_neuron.py + INVENTORY.md # foreign-ok vs build-only + PLAN.md + README.md +``` diff --git a/test/foreign/RunTestInstall.cmake b/test/foreign/RunTestInstall.cmake new file mode 100644 index 0000000000..bb4aff3dcf --- /dev/null +++ b/test/foreign/RunTestInstall.cmake @@ -0,0 +1,39 @@ +# Invoked by the test-install custom target. Runs ctest with the configured label set, then always +# prints how to re-run ctest. +# +# Expected -D definitions from the parent project: NRN_FOREIGN_CTEST_COMMAND, +# NRN_FOREIGN_BINARY_DIR, NRN_FOREIGN_TEST_INSTALL_LABELS, NRN_FOREIGN_TEST_INSTALL_JOBS + +if(NOT DEFINED NRN_FOREIGN_CTEST_COMMAND) + set(NRN_FOREIGN_CTEST_COMMAND "ctest") +endif() +if(NOT DEFINED NRN_FOREIGN_BINARY_DIR) + message(FATAL_ERROR "NRN_FOREIGN_BINARY_DIR not set") +endif() +if(NOT DEFINED NRN_FOREIGN_TEST_INSTALL_LABELS) + set(NRN_FOREIGN_TEST_INSTALL_LABELS "serial") +endif() +if(NOT DEFINED NRN_FOREIGN_TEST_INSTALL_JOBS) + set(NRN_FOREIGN_TEST_INSTALL_JOBS "4") +endif() + +execute_process( + COMMAND ${NRN_FOREIGN_CTEST_COMMAND} --test-dir "${NRN_FOREIGN_BINARY_DIR}" --output-on-failure -L + "${NRN_FOREIGN_TEST_INSTALL_LABELS}" -j "${NRN_FOREIGN_TEST_INSTALL_JOBS}" + RESULT_VARIABLE _ctest_rc) + +message(STATUS "") +message( + STATUS + "Install check finished (label: ${NRN_FOREIGN_TEST_INSTALL_LABELS}, ctest rc=${_ctest_rc}).") +message(STATUS "Re-run or filter tests with ctest against this foreign build dir:") +message(STATUS " ctest --test-dir ${NRN_FOREIGN_BINARY_DIR} --output-on-failure") +message(STATUS " ctest --test-dir ${NRN_FOREIGN_BINARY_DIR} -L mpi -j2") +message(STATUS " ctest --test-dir ${NRN_FOREIGN_BINARY_DIR} -L coreneuron -j2") +message(STATUS " ctest --test-dir ${NRN_FOREIGN_BINARY_DIR} -R \"pytest::\" --rerun-failed") +message(STATUS "Foreign build dir: ${NRN_FOREIGN_BINARY_DIR}") +message(STATUS "") + +if(NOT _ctest_rc EQUAL 0) + message(FATAL_ERROR "test-install: ctest failed with exit code ${_ctest_rc}") +endif() diff --git a/test/foreign/SerialPortableTests.cmake b/test/foreign/SerialPortableTests.cmake new file mode 100644 index 0000000000..c5f3207ae3 --- /dev/null +++ b/test/foreign/SerialPortableTests.cmake @@ -0,0 +1,199 @@ +# Serial portable integration tests for foreign (wheel) mode. Included from +# test/foreign/CMakeLists.txt after ForeignTestHelpers. MPI / CoreNEURON groups live in +# MpiCoreNeuronTests.cmake (M4). + +set(_nrn_foreign_pytest_args --capture=tee-sys) + +# --------------------------------------------------------------------------- +# pytest + datahandle + coverage (need pytest package) +# --------------------------------------------------------------------------- +if(_nrn_foreign_have_pytest) + nrn_add_test_group(NAME pytest MODFILE_PATTERNS test/pytest/*.mod) + nrn_foreign_note_nrnivmodl_group(pytest) + nrn_add_test( + GROUP pytest + NAME basic_tests + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} "./test/pytest" + SCRIPT_PATTERNS "test/pytest/*.json" "test/pytest/*.py") + nrn_foreign_finalize_test(pytest basic_tests pytest) + + nrn_add_test_group(NAME datahandle MODFILE_PATTERNS test/datahandle/*.mod) + nrn_foreign_note_nrnivmodl_group(datahandle) + nrn_add_test( + GROUP datahandle + NAME datahandle_tests + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} ./test/datahandle + SCRIPT_PATTERNS test/datahandle/*.py) + nrn_foreign_finalize_test(datahandle datahandle_tests datahandle) + + nrn_add_test_group(NAME coverage_tests MODFILE_PATTERNS test/cover/mod/*.mod) + nrn_foreign_note_nrnivmodl_group(coverage_tests) + nrn_add_test( + GROUP coverage_tests + NAME cover_tests + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} ./test/cover + SCRIPT_PATTERNS test/cover/*.py test/cover/*.json) + nrn_foreign_finalize_test(coverage_tests cover_tests cover) + + # Pure Python unit tests under test/unit_tests/hoc_python (no modfiles). + nrn_add_test_group(NAME unit_tests MODFILE_PATTERNS NONE) + nrn_add_test( + GROUP unit_tests + NAME python_unit_tests + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} + "${NRN_FOREIGN_SOURCE_ROOT}/test/unit_tests/hoc_python" + SCRIPT_PATTERNS "test/unit_tests/hoc_python/*.py") + nrn_foreign_finalize_test(unit_tests python_unit_tests unit) +endif() + +# --------------------------------------------------------------------------- +# example_nmodl — share/examples/nrniv/nmodl via special +# --------------------------------------------------------------------------- +nrn_add_test_group( + NAME example_nmodl + MODFILE_PATTERNS *.mod *.inc + SIM_DIRECTORY share/examples/nrniv/nmodl) +nrn_foreign_note_nrnivmodl_group(example_nmodl) + +foreach(ext hoc py) + if(ext STREQUAL "py" AND NOT _nrn_foreign_have_pytest) + continue() + endif() + file( + GLOB example_nmodl_scripts + RELATIVE "${NRN_FOREIGN_SOURCE_ROOT}/share/examples/nrniv/nmodl" + "${NRN_FOREIGN_SOURCE_ROOT}/share/examples/nrniv/nmodl/*.${ext}") + foreach(example_script ${example_nmodl_scripts}) + get_filename_component(name "${example_script}" NAME_WLE) + if(ext STREQUAL "py") + # Match in-tree harness: python -m pytest + nrn_add_test( + GROUP example_nmodl + NAME ${name}_${ext} + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} "${example_script}" + SCRIPT_PATTERNS "${example_script}" "${name}.ses") + else() + nrn_add_test( + GROUP example_nmodl + NAME ${name}_${ext} + COMMAND special "${example_script}" + SCRIPT_PATTERNS "${example_script}" "${name}.ses") + endif() + nrn_foreign_finalize_test(example_nmodl ${name}_${ext} example_nmodl) + endforeach() +endforeach() + +# --------------------------------------------------------------------------- +# hoctests — each script under test/hoctests/* +# --------------------------------------------------------------------------- +nrn_add_test_group( + NAME hoctests + MODFILE_PATTERNS *.mod *.inc + SIM_DIRECTORY test/hoctests) +nrn_foreign_note_nrnivmodl_group(hoctests) +set(hoctest_utils expect_err.hoc) +foreach(ext hoc py) + file( + GLOB hoc_scripts + RELATIVE "${NRN_FOREIGN_SOURCE_ROOT}/test/hoctests" + "${NRN_FOREIGN_SOURCE_ROOT}/test/hoctests/*/*.${ext}") + foreach(hoc_script ${hoc_scripts}) + get_filename_component(name "${hoc_script}" NAME_WLE) + if(ext STREQUAL "py") + # Match in-tree: plain python interpreter (not pytest, not special -python). + nrn_add_test( + GROUP hoctests + NAME ${name}_${ext} + COMMAND "${NRN_FOREIGN_PYTHON}" "${hoc_script}" + SCRIPT_PATTERNS "${hoc_script}" "tests/${name}.json" ${hoctest_utils}) + else() + nrn_add_test( + GROUP hoctests + NAME ${name}_${ext} + COMMAND special "${hoc_script}" + SCRIPT_PATTERNS "${hoc_script}" "tests/${name}.json" ${hoctest_utils}) + endif() + nrn_foreign_finalize_test(hoctests ${name}_${ext} hoctests) + endforeach() +endforeach() + +# --------------------------------------------------------------------------- +# ringtest + connect_dend (HOC via foreign nrniv + RunHOCTest.cmake) +# --------------------------------------------------------------------------- +if(NOT NRN_FOREIGN_NRNIV STREQUAL "") + set(_ring_dir "${CMAKE_BINARY_DIR}/test/ringtest") + file(MAKE_DIRECTORY "${_ring_dir}") + add_test( + NAME foreign::ringtest + COMMAND + ${CMAKE_COMMAND} -E env "PYTHONPATH=" "PATH=${_nrn_foreign_py_bindir}:$ENV{PATH}" + ${CMAKE_COMMAND} -Dhoc_library_path=${NRN_FOREIGN_SOURCE_ROOT}/test/ringtest + -Dexecutable=${NRN_FOREIGN_NRNIV} + -Dexec_arg=${NRN_FOREIGN_SOURCE_ROOT}/test/ringtest/ring.hoc -Dout_file=out.dat + -Dref_file=${NRN_FOREIGN_SOURCE_ROOT}/test/ringtest/out.dat.ref -Dwork_dir=${_ring_dir} -P + ${NRN_FOREIGN_SOURCE_ROOT}/cmake/RunHOCTest.cmake) + set_tests_properties(foreign::ringtest PROPERTIES LABELS "foreign;serial;ringtest" TIMEOUT 120) + + set(_cd_dir "${CMAKE_BINARY_DIR}/test/hoc_tests/connect_dend") + file(MAKE_DIRECTORY "${_cd_dir}") + add_test( + NAME foreign::connect_dend + COMMAND + ${CMAKE_COMMAND} -E env "PYTHONPATH=" "PATH=${_nrn_foreign_py_bindir}:$ENV{PATH}" + ${CMAKE_COMMAND} -Dexecutable=${NRN_FOREIGN_NRNIV} + -Dexec_arg=${NRN_FOREIGN_SOURCE_ROOT}/test/hoc_tests/connect_dend/connect_dend.hoc + -Dout_file=cell3soma.dat + -Dref_file=${NRN_FOREIGN_SOURCE_ROOT}/test/hoc_tests/connect_dend/cell3soma.dat.ref + -Dwork_dir=${_cd_dir} -P ${NRN_FOREIGN_SOURCE_ROOT}/cmake/RunHOCTest.cmake) + set_tests_properties(foreign::connect_dend PROPERTIES LABELS "foreign;serial;connect_dend" + TIMEOUT 120) +endif() + +# --------------------------------------------------------------------------- +# RxD (serial) — needs RX3D in the wheel and test/rxd/testdata +# --------------------------------------------------------------------------- +if(NRN_ENABLE_RX3D AND _nrn_foreign_have_pytest) + set(_rxd_testdata "${NRN_FOREIGN_SOURCE_ROOT}/test/rxd/testdata") + if(NOT EXISTS "${_rxd_testdata}/.git" AND NOT EXISTS "${_rxd_testdata}/README") + # Try to populate the submodule once at configure time. + find_package(Git QUIET) + if(GIT_FOUND AND EXISTS "${NRN_FOREIGN_SOURCE_ROOT}/.gitmodules") + execute_process( + COMMAND "${GIT_EXECUTABLE}" -C "${NRN_FOREIGN_SOURCE_ROOT}" submodule update --init -- + test/rxd/testdata + RESULT_VARIABLE _rxd_sub_rc + OUTPUT_QUIET ERROR_QUIET) + endif() + endif() + file(GLOB _rxd_data "${_rxd_testdata}/*") + if(_rxd_data STREQUAL "") + message(STATUS "Skipping rxdmod_tests: test/rxd/testdata not populated " + "(git submodule update --init -- test/rxd/testdata)") + else() + nrn_add_test_group( + NAME rxdmod_tests + MODFILE_PATTERNS test/rxd/*.mod + SCRIPT_PATTERNS test/rxd/*.py test/rxd/*/*.py test/rxd/3d/*.asc test/rxd/testdata/**/*.dat) + nrn_foreign_note_nrnivmodl_group(rxdmod_tests) + nrn_add_test( + GROUP rxdmod_tests + NAME rxd_tests + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} ./test/rxd) + nrn_foreign_finalize_test(rxdmod_tests rxd_tests rxd) + endif() +endif() + +# --------------------------------------------------------------------------- +# gjtests (serial part only; MPI variant deferred to M4) +# --------------------------------------------------------------------------- +if(_nrn_foreign_have_pytest) + nrn_add_test_group(NAME gjtests MODFILE_PATTERNS test/gjtests/*.mod) + nrn_foreign_note_nrnivmodl_group(gjtests) + nrn_add_test( + GROUP gjtests + NAME gj_serial + COMMAND "${NRN_FOREIGN_PYTHON}" -m pytest ${_nrn_foreign_pytest_args} + "${NRN_FOREIGN_SOURCE_ROOT}/test/gjtests" -k "not par" + SCRIPT_PATTERNS test/gjtests/*.py) + nrn_foreign_finalize_test(gjtests gj_serial gjtests) +endif() diff --git a/test/foreign/VersionGate.cmake b/test/foreign/VersionGate.cmake new file mode 100644 index 0000000000..0311003d55 --- /dev/null +++ b/test/foreign/VersionGate.cmake @@ -0,0 +1,176 @@ +# Version policy D for foreign ctest: - NRN_FOREIGN_CI=ON -> mismatch is FATAL - +# otherwise mismatch is FATAL unless NRN_FOREIGN_ALLOW_SKEW=ON Match prefers git SHA (flexible +# length / embedded in describe), then version string. + +option(NRN_FOREIGN_CI "Treat foreign/source version mismatch as fatal (CI mode)" OFF) +option(NRN_FOREIGN_ALLOW_SKEW + "Allow foreign wheel / source tree version mismatch (local exploration)" OFF) + +if(NOT DEFINED NRN_FOREIGN_SOURCE_ROOT) + get_filename_component(NRN_FOREIGN_SOURCE_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +endif() + +set(NRN_FOREIGN_SOURCE_GIT_SHA "") +set(NRN_FOREIGN_SOURCE_GIT_SHA_SHORT "") +set(NRN_FOREIGN_SOURCE_DESCRIBE "") + +find_package(Git QUIET) +if(GIT_FOUND AND EXISTS "${NRN_FOREIGN_SOURCE_ROOT}/.git") + execute_process( + COMMAND "${GIT_EXECUTABLE}" -C "${NRN_FOREIGN_SOURCE_ROOT}" rev-parse HEAD + OUTPUT_VARIABLE NRN_FOREIGN_SOURCE_GIT_SHA + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + execute_process( + COMMAND "${GIT_EXECUTABLE}" -C "${NRN_FOREIGN_SOURCE_ROOT}" rev-parse --short=12 HEAD + OUTPUT_VARIABLE NRN_FOREIGN_SOURCE_GIT_SHA_SHORT + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + execute_process( + COMMAND "${GIT_EXECUTABLE}" -C "${NRN_FOREIGN_SOURCE_ROOT}" describe --tags --long --always + OUTPUT_VARIABLE NRN_FOREIGN_SOURCE_DESCRIBE + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) +endif() + +# --------------------------------------------------------------------------- +# Normalize / extract git SHAs +# --------------------------------------------------------------------------- +# Pull a hex SHA from values like "5ac449d89", "g5ac449d89", "9.0.1-85-g5ac449d89". +function(_nrn_foreign_extract_git_sha out_var text) + set(_t "${text}") + if(_t STREQUAL "" OR _t STREQUAL "null") + set(${out_var} + "" + PARENT_SCOPE) + return() + endif() + string(TOLOWER "${_t}" _t) + # Prefer -g / g from git describe style strings + if(_t MATCHES "g([0-9a-f]+)") + set(_sha "${CMAKE_MATCH_1}") + elseif(_t MATCHES "^([0-9a-f]+)$") + set(_sha "${CMAKE_MATCH_1}") + else() + set(_sha "") + endif() + set(${out_var} + "${_sha}" + PARENT_SCOPE) +endfunction() + +# True if either SHA is a prefix of the other (require at least 7 hex chars). +function(_nrn_foreign_sha_match out_var a b) + set(${out_var} + OFF + PARENT_SCOPE) + if(a STREQUAL "" OR b STREQUAL "") + return() + endif() + string(LENGTH "${a}" _la) + string(LENGTH "${b}" _lb) + if(_la LESS 7 OR _lb LESS 7) + return() + endif() + if(a STREQUAL b) + set(${out_var} + ON + PARENT_SCOPE) + return() + endif() + if(_la GREATER_EQUAL _lb) + string(SUBSTRING "${a}" 0 ${_lb} _ap) + if(_ap STREQUAL "${b}") + set(${out_var} + ON + PARENT_SCOPE) + endif() + else() + string(SUBSTRING "${b}" 0 ${_la} _bp) + if(_bp STREQUAL "${a}") + set(${out_var} + ON + PARENT_SCOPE) + endif() + endif() +endfunction() + +_nrn_foreign_extract_git_sha(_wheel_sha_raw "${NRN_FOREIGN_NEURON_GIT_SHA}") +if(_wheel_sha_raw STREQUAL "") + _nrn_foreign_extract_git_sha(_wheel_sha_raw "${NRN_FOREIGN_NEURON_VERSION_FULL}") +endif() +if(_wheel_sha_raw STREQUAL "") + _nrn_foreign_extract_git_sha(_wheel_sha_raw "${NRN_FOREIGN_NEURON_VERSION}") +endif() +_nrn_foreign_extract_git_sha(_src_sha_raw "${NRN_FOREIGN_SOURCE_GIT_SHA}") +if(_src_sha_raw STREQUAL "") + _nrn_foreign_extract_git_sha(_src_sha_raw "${NRN_FOREIGN_SOURCE_DESCRIBE}") +endif() + +set(_match OFF) +set(_match_reason "no match") + +# 1) Git SHA (flexible length; works for nightlies that embed g in describe) +_nrn_foreign_sha_match(_sha_ok "${_wheel_sha_raw}" "${_src_sha_raw}") +if(_sha_ok) + set(_match ON) + set(_match_reason "git SHA (${_wheel_sha_raw} ~ ${_src_sha_raw})") +endif() + +# 2) Exact version / describe string +if(NOT _match) + set(_wv "${NRN_FOREIGN_NEURON_VERSION_FULL}") + if(_wv STREQUAL "") + set(_wv "${NRN_FOREIGN_NEURON_VERSION}") + endif() + if(NOT _wv STREQUAL "" AND NOT NRN_FOREIGN_SOURCE_DESCRIBE STREQUAL "") + if(_wv STREQUAL "${NRN_FOREIGN_SOURCE_DESCRIBE}") + set(_match ON) + set(_match_reason "version string (${_wv})") + endif() + endif() +endif() + +# 3) Nightly-friendly: same leading X.Y.Z and same embedded g-sha even if "commits since tag" +# counters differ (e.g. wheel rebuilt vs local describe). Already covered by (1) when SHAs extract; +# keep (2) for tagged releases. + +set(NRN_FOREIGN_VERSION_MATCH + "${_match}" + CACHE BOOL "Whether foreign NEURON matches this source tree" FORCE) +set(NRN_FOREIGN_VERSION_MATCH_REASON + "${_match_reason}" + CACHE STRING "How foreign/source versions were compared" FORCE) +set(NRN_FOREIGN_WHEEL_GIT_SHA_NORMALIZED + "${_wheel_sha_raw}" + CACHE STRING "Normalized wheel git SHA used for matching" FORCE) +set(NRN_FOREIGN_SOURCE_GIT_SHA_NORMALIZED + "${_src_sha_raw}" + CACHE STRING "Normalized source git SHA used for matching" FORCE) + +message(STATUS "Source tree describe : ${NRN_FOREIGN_SOURCE_DESCRIBE}") +message(STATUS "Source tree git sha : ${NRN_FOREIGN_SOURCE_GIT_SHA_SHORT}") +message(STATUS "Normalized wheel sha : ${_wheel_sha_raw}") +message(STATUS "Normalized source sha : ${_src_sha_raw}") +message(STATUS "Foreign/source match : ${NRN_FOREIGN_VERSION_MATCH} (${_match_reason})") + +if(NOT _match) + string( + CONCAT + _msg + "Foreign NEURON does not match this source tree.\n" + " wheel version : ${NRN_FOREIGN_NEURON_VERSION_FULL} (sha ${NRN_FOREIGN_NEURON_GIT_SHA}" + " / normalized ${_wheel_sha_raw})\n" + " source tree : ${NRN_FOREIGN_SOURCE_DESCRIBE} (sha ${NRN_FOREIGN_SOURCE_GIT_SHA_SHORT}" + " / normalized ${_src_sha_raw})\n" + " match reason : ${_match_reason}\n") + if(NRN_FOREIGN_CI) + message(FATAL_ERROR "${_msg}NRN_FOREIGN_CI=ON: refusing to configure (hard match).") + elseif(NRN_FOREIGN_ALLOW_SKEW) + message(WARNING "${_msg}NRN_FOREIGN_ALLOW_SKEW=ON: continuing despite skew.\n" + "Feature gates still come from the wheel; API mismatches may fail tests.") + else() + message( + FATAL_ERROR + "${_msg}Refusing to configure. For local exploration re-run with:\n" + " -DNRN_FOREIGN_ALLOW_SKEW=ON\n" + "Or check out the revision that built the wheel, or install a matching wheel.") + endif() +endif() diff --git a/test/foreign/probe_neuron.py b/test/foreign/probe_neuron.py new file mode 100644 index 0000000000..bb34a4f1c4 --- /dev/null +++ b/test/foreign/probe_neuron.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Probe an installed NEURON (wheel/venv) for foreign-ctest configure. + +Prints a single JSON object on stdout. Invoked by CMake DiscoverNeuron.cmake +using NRN_FOREIGN_PYTHON (must be the interpreter that can import neuron). +""" +from __future__ import annotations + +import json +import os +import shutil +import sys + + +def _which(name: str) -> str | None: + return shutil.which(name) + + +def main() -> int: + try: + import neuron + except Exception as exc: # noqa: BLE001 - report any import failure to CMake + print( + json.dumps( + { + "ok": False, + "error": f"import neuron failed: {exc}", + "python": sys.executable, + } + ), + file=sys.stderr, + ) + return 1 + + version = getattr(neuron, "__version__", None) + git_sha = None + version_full = version + nrnversions: dict[str, str] = {} + try: + from neuron import h + + for i in range(8): + try: + nrnversions[str(i)] = str(h.nrnversion(i)) + except Exception: # noqa: BLE001 + pass + # nrnversion(3) is typically the short git SHA from the build + git_sha = nrnversions.get("3") or None + version_full = nrnversions.get("5") or version + except Exception as exc: # noqa: BLE001 + nrnversions["error"] = str(exc) + + # Fall back: extract g from describe-style version strings (nightlies). + if not git_sha: + import re + + for candidate in (version_full, version): + if not candidate: + continue + m = re.search(r"g([0-9a-fA-F]+)", str(candidate)) + if m: + git_sha = m.group(1) + break + + features: dict[str, object] = {} + try: + from neuron import config + + args = getattr(config, "arguments", None) or {} + for key, val in args.items(): + # JSON-friendly + if isinstance(val, (bool, int, float, str)) or val is None: + features[key] = val + elif isinstance(val, (list, tuple)): + features[key] = list(val) + else: + features[key] = str(val) + except Exception as exc: # noqa: BLE001 + features["_error"] = str(exc) + + tools = { + "nrniv": _which("nrniv"), + "nrnivmodl": _which("nrnivmodl"), + "modlunit": _which("modlunit"), + "nocmodl": _which("nocmodl"), + "mpiexec": _which("mpiexec"), + } + + neuron_file = getattr(neuron, "__file__", None) + payload = { + "ok": True, + "python": sys.executable, + "neuron_file": neuron_file, + "version": version, + "version_full": version_full, + "git_sha": git_sha, + "nrnversion": nrnversions, + "features": features, + "tools": tools, + "env": { + "PATH": os.environ.get("PATH", ""), + "VIRTUAL_ENV": os.environ.get("VIRTUAL_ENV"), + "NEURONHOME": os.environ.get("NEURONHOME"), + "NRNHOME": os.environ.get("NRNHOME"), + }, + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())