Skip to content

Commit f2d4ed3

Browse files
JCGoranGoran Jelic-Cizmeknrnhines
authored
Rework coverage for NMODL ODE solver and pybind (#3544)
* Rework coverage * Use readlink -f for getting the abs path to modfile * Okay I give up on nrnivmodl Put back whatever hackery it used before to collect the mod files, if some of the files are missing coverage I really don't care. * Try using a different coverage database file * Update gitignore also add comment about using single triple quotes --------- Co-authored-by: Goran Jelic-Cizmek <goran.jelic-cizmek@epfl.ch> Co-authored-by: nrnhines <michael.hines@yale.edu>
1 parent 9d29cc9 commit f2d4ed3

7 files changed

Lines changed: 105 additions & 22 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ virtualenv
2323
docs/_build
2424
docs/_generated
2525
.vscode
26+
.coverage*
27+
coverage*.xml

CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,11 @@ if(NRN_ENABLE_TESTS)
10191019
if(NOT PYTEST_COV_FOUND)
10201020
message(STATUS "pytest-cov package not installed. Python coverage will not be generated.")
10211021
endif()
1022+
set(NRN_PYTEST_LAUNCHER -m pytest --capture=tee-sys)
1023+
# pytest-cov runs extremely slowly under AddressSanitizer
1024+
if(PYTEST_COV_FOUND AND NOT "address" IN_LIST NRN_SANITIZERS_LIST)
1025+
list(APPEND NRN_PYTEST_LAUNCHER --cov-report=xml --cov=neuron)
1026+
endif()
10221027
endif()
10231028
add_dependencies(nrniv_lib copy_share_demo_to_build)
10241029
# Execute neurondemo as part of the build because it lazily calls nrnivmodl. If we don't do this

share/lib/python/neuron/nmodl/ode.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
# Lesser General Public License. See top-level LICENSE file for details.
66
# ***********************************************************************
77

8+
# NOTE: because we are testing this via `exec` for the purposes of obtaining
9+
# the correct coverage, do NOT use triple single quotation marks anywhere in
10+
# the below because the code will break (should also be enforced by the
11+
# formatter, but better safe than sorry)!
12+
813
import re
914
from importlib import import_module
1015

src/nmodl/pybind/CMakeLists.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ if(WIN32)
2929
# https://developercommunity.visualstudio.com/t/c-string-literal-max-length-much-shorter-than-docu/758957
3030
string(REGEX REPLACE "\n\n" "\n)jiowi\" R\"jiowi(\n" NMODL_ODE_PY "${NMODL_ODE_PY}")
3131
endif()
32+
if(NRN_ENABLE_COVERAGE)
33+
set(NMODL_ODE_PY_PATH "${NMODL_PROJECT_PURELIB_SOURCE_DIR}/ode.py")
34+
endif()
3235
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/ode_py.hpp.inc ${CMAKE_CURRENT_BINARY_DIR}/ode_py.hpp
3336
@ONLY)
3437

@@ -46,6 +49,11 @@ else()
4649
target_compile_definitions(pyembed PRIVATE NMODL_STATIC_PYWRAPPER=1)
4750
endif()
4851

52+
if(NRN_ENABLE_COVERAGE)
53+
target_compile_definitions(pywrapper PRIVATE NRN_ENABLE_COVERAGE)
54+
target_link_libraries(pywrapper PRIVATE util)
55+
endif()
56+
4957
target_link_libraries(pywrapper PRIVATE fmt::fmt)
5058

5159
target_include_directories(pyembed PRIVATE ${PYBIND11_INCLUDE_DIR} ${PYTHON_INCLUDE_DIRS})

src/nmodl/pybind/ode_py.hpp.inc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,7 @@ namespace nmodl::pybind_wrappers {
1515
const std::string ode_py = R"jiowi(
1616
@NMODL_ODE_PY@
1717
)jiowi";
18-
18+
#ifdef NRN_ENABLE_COVERAGE
19+
const std::string ode_py_path = "@NMODL_ODE_PY_PATH@";
20+
#endif
1921
}

src/nmodl/pybind/wrapper.cpp

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,69 @@
44
*
55
* SPDX-License-Identifier: Apache-2.0
66
*/
7+
#include <filesystem>
8+
#include <optional>
9+
#include <set>
10+
#include <vector>
711

8-
#include "wrapper.hpp"
9-
10-
#include "codegen/codegen_naming.hpp"
11-
#include "pybind/pyembed.hpp"
12+
// 3rd party headers
1213
#include <fmt/format.h>
13-
#include <optional>
1414
#include <pybind11/embed.h>
1515
#include <pybind11/stl.h>
1616

17-
#include <set>
18-
#include <vector>
17+
// NMODL headers
18+
#include "codegen/codegen_naming.hpp"
19+
#include "pybind/ode_py.hpp"
20+
#include "pybind/wrapper.hpp"
21+
#include "pybind/pyembed.hpp"
22+
#include "utils/common_utils.hpp"
1923

20-
#include "ode_py.hpp"
2124

25+
namespace fs = std::filesystem;
2226
namespace py = pybind11;
2327
using namespace py::literals;
2428

2529
namespace nmodl {
2630
namespace pybind_wrappers {
2731

32+
// This wrapper is used for obtaining better coverage in `ode.py`.
33+
// Since we embed the `ode.py` as a string, there is no way to check what was covered via running
34+
// pytest or similar. Instead, we use the coverage.py API directly.
35+
static void run_python_script(const std::string& script, const py::dict& locals) {
36+
#ifdef NRN_ENABLE_COVERAGE
37+
// to prevent race conditions during testing, we generate a random suffix
38+
const auto& suffix =
39+
nmodl::utils::generate_random_string(20, nmodl::utils::UseNumbersInString::WithoutNumbers);
40+
41+
py::exec(fmt::format(R"(
42+
import coverage
43+
cov = coverage.Coverage(data_suffix='{}')
44+
cov.start()
45+
)",
46+
suffix),
47+
locals);
48+
const auto& code_with_mapping = std::string("exec(compile(r'''" + ode_py + script + "''', '" +
49+
ode_py_path + "', 'exec'))");
50+
py::exec(code_with_mapping, locals);
51+
#else
52+
py::exec(ode_py + script, locals);
53+
#endif
54+
55+
#ifdef NRN_ENABLE_COVERAGE
56+
const auto& path = fs::current_path() / fmt::format("coverage_{}.xml", suffix);
57+
py::exec(fmt::format(R"(
58+
cov.stop()
59+
cov.save()
60+
# Check if we have any coverage data
61+
data = cov.get_data()
62+
if data.measured_files():
63+
cov.xml_report(outfile='{}')
64+
)",
65+
path.string()),
66+
locals);
67+
#endif
68+
}
69+
2870
std::tuple<std::vector<std::string>, std::vector<std::string>, std::string>
2971
call_solve_linear_system(const std::vector<std::string>& eq_system,
3072
const std::vector<std::string>& state_vars,
@@ -57,8 +99,7 @@ except Exception as e:
5799
new_local_vars = [""]
58100
exception_message = traceback.format_exc()
59101
)";
60-
61-
py::exec(nmodl::pybind_wrappers::ode_py + script, locals);
102+
run_python_script(script, locals);
62103
// returns a vector of solutions, i.e. new statements to add to block:
63104
auto solutions = locals["solutions"].cast<std::vector<std::string>>();
64105
// and a vector of new local variables that need to be declared in the block:
@@ -93,7 +134,7 @@ except Exception as e:
93134
exception_message = traceback.format_exc()
94135
)";
95136

96-
py::exec(nmodl::pybind_wrappers::ode_py + script, locals);
137+
run_python_script(script, locals);
97138
// returns a vector of solutions, i.e. new statements to add to block:
98139
auto solutions = locals["solutions"].cast<std::vector<std::string>>();
99140
// may also return a python exception message:
@@ -130,7 +171,7 @@ except Exception as e:
130171
exception_message = traceback.format_exc()
131172
)";
132173

133-
py::exec(nmodl::pybind_wrappers::ode_py + script, locals);
174+
run_python_script(script, locals);
134175
} else if (method == codegen::naming::CNEXP_METHOD) {
135176
// replace x' = f(x) differential equation
136177
// with analytic solution for x(t+dt) in terms of x(t)
@@ -147,7 +188,7 @@ except Exception as e:
147188
exception_message = traceback.format_exc()
148189
)";
149190

150-
py::exec(nmodl::pybind_wrappers::ode_py + script, locals);
191+
run_python_script(script, locals);
151192
} else {
152193
// nothing to do, but the caller should know.
153194
return {};
@@ -179,7 +220,7 @@ except Exception as e:
179220
exception_message = traceback.format_exc()
180221
)";
181222

182-
py::exec(nmodl::pybind_wrappers::ode_py + script, locals);
223+
run_python_script(script, locals);
183224

184225
auto solution = locals["solution"].cast<std::string>();
185226
auto exception_message = locals["exception_message"].cast<std::string>();
@@ -223,7 +264,7 @@ except Exception as e:
223264
statements,
224265
property.has_value() ? fmt::format("{}[{}]", name, property.value()) : name);
225266

226-
py::exec(nmodl::pybind_wrappers::ode_py + script, locals);
267+
run_python_script(script, locals);
227268

228269
auto solution = locals["solution"].cast<std::string>();
229270
auto exception_message = locals["exception_message"].cast<std::string>();

test/nmodl/transpiler/unit/CMakeLists.txt

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,20 +211,40 @@ endif()
211211
# =============================================================================
212212
if(NRN_ENABLE_PYTHON)
213213
if(NOT (CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang" AND NRN_SANITIZERS))
214-
add_test(NAME Ode COMMAND ${PYTHON_EXECUTABLE} -m pytest ${CMAKE_CURRENT_SOURCE_DIR}/ode)
215-
set_tests_properties(Ode PROPERTIES ENVIRONMENT "PYTHONPATH=${NMODL_TEST_PYTHONPATH}")
214+
# Most of the Python tests added by `add_nrn_test` run in their own directories. Unfortunately,
215+
# pytest-cov does not seem to have an option to specify the output file (it _always_ outputs to
216+
# `coverage.xml`), which means that the various tests here may clobber each other's coverage
217+
# reports. The way out of this is to a) either specify `WORKING_DIRECTORY` of each test, or b)
218+
# use a coverage config file (see
219+
# https://coverage.readthedocs.io/en/latest/config.html#xml-output); the latter requires far
220+
# more work than the former, so we use option a) in the below.
221+
file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/ode)
222+
add_test(
223+
NAME Ode
224+
COMMAND ${PYTHON_EXECUTABLE} ${NRN_PYTEST_LAUNCHER} ${CMAKE_CURRENT_SOURCE_DIR}/ode
225+
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/ode)
226+
set_tests_properties(
227+
Ode
228+
PROPERTIES ENVIRONMENT
229+
"PYTHONPATH=${NMODL_TEST_PYTHONPATH};NEURONHOME=${PROJECT_BINARY_DIR}/share/nrn")
216230
cpp_cc_configure_sanitizers(TEST Ode PRELOAD)
217231
endif()
218232

219233
if(NMODL_ENABLE_PYTHON_BINDINGS)
220234
# Apple Clang and ASAN do not play along nicely with NMODL's Python bindings, so we skip these
221235
# tests
222236
if(NOT (CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang" AND NRN_SANITIZERS))
223-
add_test(NAME Pybind COMMAND ${PYTHON_EXECUTABLE} -m pytest
224-
${CMAKE_CURRENT_SOURCE_DIR}/pybind)
237+
file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/pybind)
238+
add_test(
239+
NAME Pybind
240+
COMMAND ${PYTHON_EXECUTABLE} ${NRN_PYTEST_LAUNCHER} ${CMAKE_CURRENT_SOURCE_DIR}/pybind
241+
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/pybind)
225242
set_tests_properties(
226-
Pybind PROPERTIES ENVIRONMENT
227-
"PYTHONPATH=${NMODL_TEST_PYTHONPATH};NMODLHOME=${PROJECT_BINARY_DIR}")
243+
Pybind
244+
PROPERTIES
245+
ENVIRONMENT
246+
"PYTHONPATH=${NMODL_TEST_PYTHONPATH};NMODLHOME=${PROJECT_BINARY_DIR};NEURONHOME=${PROJECT_BINARY_DIR}/share/nrn"
247+
)
228248
cpp_cc_configure_sanitizers(TEST Pybind PRELOAD)
229249
endif()
230250
endif()

0 commit comments

Comments
 (0)