Skip to content

Commit 88d009f

Browse files
authored
Merge pull request NeuralEnsemble#827 from apdavison/improved-testing
Add infrastucture to simplify setup and use of local test environments
2 parents 4ec235d + a310d10 commit 88d009f

12 files changed

Lines changed: 454 additions & 40 deletions

File tree

.github/workflows/full-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ jobs:
7878
flake8 pyNN --count --exit-zero --max-complexity=20 --max-line-length=127 --statistics
7979
- name: Run unit and system tests
8080
run: |
81-
pytest -v --cov=pyNN --cov-report=term test
81+
pytest -v -n auto --cov=pyNN --cov-report=term test
8282
- name: Upload coverage data
8383
run: |
8484
coveralls --service=github

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ examples/*.svg
7878
/examples/Potjans2014/clean.sh
7979
/test/benchmarks/*.h5
8080

81+
# Test environment (Makefile targets)
82+
.local-nest*/
83+
.nest-src/
84+
.nest-build/
85+
.condaenv-*/
86+
8187
# Other
8288
*.btr
8389
*.log

Makefile

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# PyNN test environment — three options for running the test suite
2+
#
3+
# Option A (native): builds NEST from source into a project-local venv+prefix
4+
# Option B (Docker): runs tests in an Ubuntu container with bind-mounted source
5+
# Option C (conda): uses micromamba + conda-forge (no compilation)
6+
#
7+
# All targets accept NEST_VERSION=<version> to switch NEST versions.
8+
#
9+
# Version string format differs between Options A/B and C for pre-releases:
10+
# stable: NEST_VERSION=3.9 (same for all options)
11+
# pre-release: GitHub tag format for A/B (e.g. 3.10.0rc1)
12+
# conda-forge format for C (e.g. 3.10_rc1)
13+
# Check https://github.com/nest/nest-simulator/releases for GitHub tag names.
14+
15+
NEST_VERSION ?= 3.9
16+
17+
# ── Option A: paths and variables ─────────────────────────────────────────────
18+
NEST_PREFIX = $(CURDIR)/.local-nest$(NEST_VERSION)
19+
NEST_PY = $(NEST_PREFIX)/bin/python3
20+
NEST_PIP = $(NEST_PREFIX)/bin/pip
21+
NEST_PYTEST = $(NEST_PREFIX)/bin/pytest
22+
NEST_VENV_STAMP = $(NEST_PREFIX)/.venv-ready
23+
NEST_STAMP = $(NEST_PREFIX)/.nest-installed
24+
NEST_SRC_DIR = $(CURDIR)/.nest-src
25+
NEST_BUILD_DIR = $(CURDIR)/.nest-build/nest-$(NEST_VERSION)
26+
NEST_TARBALL = $(NEST_SRC_DIR)/nest-$(NEST_VERSION).tar.gz
27+
NEST_SRC_UNPACKED = $(NEST_SRC_DIR)/nest-simulator-$(NEST_VERSION)
28+
NPROC = $(shell sysctl -n hw.logicalcpu 2>/dev/null || echo 4)
29+
# cmake will search /usr/local by default; override if your C deps are elsewhere
30+
EXTRA_CMAKE_ARGS ?=
31+
32+
# ── Option B: variables ────────────────────────────────────────────────────────
33+
DOCKER_IMAGE = pynn-test:nest$(NEST_VERSION)
34+
# Pass NEST_VERSION as env var so docker-compose.yml substitution picks it up
35+
COMPOSE = NEST_VERSION=$(NEST_VERSION) docker compose -f test/docker-compose.yml
36+
37+
# ── Option C: variables ────────────────────────────────────────────────────────
38+
MICROMAMBA ?= micromamba
39+
CONDAENV_PREFIX = $(CURDIR)/.condaenv-nest$(NEST_VERSION)
40+
CONDA_ENV_FILE = test/environment-nest$(NEST_VERSION).yml
41+
42+
# ══════════════════════════════════════════════════════════════════════════════
43+
# Option A: native venv + NEST built from source
44+
# ══════════════════════════════════════════════════════════════════════════════
45+
46+
.PHONY: setup
47+
setup: $(NEST_STAMP) ## [A] Build NEST $(NEST_VERSION) from source + create venv
48+
@echo ""
49+
@echo "Setup complete. Run 'make test NEST_VERSION=$(NEST_VERSION)'"
50+
51+
# 1. Create venv and install Python build prerequisites
52+
# mpi4py must be compiled against the same MPI that NEST will link to,
53+
# and must be present before cmake runs so NEST can detect it.
54+
$(NEST_VENV_STAMP):
55+
python3 -m venv $(NEST_PREFIX)
56+
$(NEST_PIP) install --upgrade pip
57+
$(NEST_PIP) install "cython<3.1.0"
58+
MPICC=$(MPI_ROOT)/bin/mpicc \
59+
$(NEST_PIP) install --no-binary=mpi4py mpi4py
60+
touch $@
61+
62+
# 2. Download NEST tarball
63+
$(NEST_TARBALL):
64+
@mkdir -p $(NEST_SRC_DIR)
65+
curl -fL -o $@ \
66+
https://github.com/nest/nest-simulator/archive/refs/tags/v$(NEST_VERSION).tar.gz
67+
68+
# 3. Unpack source and apply backport of NEST PR #3794:
69+
# "Add missing include needed on macOS 26.4" — <cstddef> was previously
70+
# available in numerics.h only via transitive includes that macOS 26 removed.
71+
$(NEST_SRC_UNPACKED): $(NEST_TARBALL)
72+
tar xzf $< -C $(NEST_SRC_DIR)
73+
sed -i '' 's|#include <cmath>|#include <cmath>\n#include <cstddef>|' \
74+
$(NEST_SRC_UNPACKED)/libnestutil/numerics.h
75+
touch $@
76+
77+
# 4. cmake build, install, then install remaining Python deps
78+
$(NEST_STAMP): $(NEST_VENV_STAMP) $(NEST_SRC_UNPACKED)
79+
mkdir -p $(NEST_BUILD_DIR)
80+
cd $(NEST_BUILD_DIR) && cmake \
81+
-DCMAKE_INSTALL_PREFIX=$(NEST_PREFIX) \
82+
-DPython_EXECUTABLE=$(NEST_PY) \
83+
-Dwith-mpi=ON \
84+
-Dwith-python=ON \
85+
-Dwith-gsl=ON \
86+
-Dwith-ltdl=ON \
87+
-Dwith-openmp=OFF \
88+
$(EXTRA_CMAKE_ARGS) \
89+
$(NEST_SRC_UNPACKED)
90+
cd $(NEST_BUILD_DIR) && make -j$(NPROC)
91+
cd $(NEST_BUILD_DIR) && make install
92+
# Build and install PyNN NEST extensions (pynn_extensions module)
93+
mkdir -p $(NEST_BUILD_DIR)/pynn_extensions
94+
cd $(NEST_BUILD_DIR)/pynn_extensions && cmake \
95+
-Dwith-nest=$(NEST_PREFIX)/bin/nest-config \
96+
$(EXTRA_CMAKE_ARGS) \
97+
$(CURDIR)/pyNN/nest/extensions
98+
cd $(NEST_BUILD_DIR)/pynn_extensions && make install
99+
$(NEST_PIP) install \
100+
"numpy<2" "neuron>=9.0.0" nrnutils "arbor==0.9.0" \
101+
brian2 libNeuroML scipy matplotlib Cheetah3 h5py Jinja2 \
102+
pytest pytest-xdist pytest-cov flake8
103+
$(NEST_PIP) install -e .
104+
# Compile NEURON .mod mechanisms against the venv's NEURON.
105+
# The compiled arm64/ dir lives in the source tree and is version-specific,
106+
# so it must be rebuilt whenever the NEURON version changes.
107+
cd $(CURDIR)/pyNN/neuron/nmodl && $(NEST_PREFIX)/bin/nrnivmodl .
108+
touch $@
109+
110+
.PHONY: test
111+
test: $(NEST_STAMP) ## [A] Run full test suite (NEST_VERSION=$(NEST_VERSION))
112+
$(NEST_PYTEST) -v -n auto test/
113+
114+
.PHONY: test-unit
115+
test-unit: $(NEST_STAMP) ## [A] Unit tests only (no simulator needed)
116+
$(NEST_PYTEST) -n auto test/unittests/
117+
118+
.PHONY: test-nest
119+
test-nest: $(NEST_STAMP) ## [A] NEST system + scenario tests
120+
$(NEST_PYTEST) -n auto test/system/test_nest.py test/system/scenarios/
121+
122+
.PHONY: test-neuron
123+
test-neuron: $(NEST_STAMP) ## [A] NEURON system + scenario tests
124+
$(NEST_PYTEST) -n auto test/system/test_neuron.py test/system/scenarios/
125+
126+
.PHONY: test-brian2
127+
test-brian2: $(NEST_STAMP) ## [A] Brian2 system tests
128+
$(NEST_PYTEST) -n auto test/system/test_brian2.py
129+
130+
.PHONY: clean-nmodl
131+
clean-nmodl: ## [A] Remove compiled NEURON mechanisms (required before switching NEURON versions)
132+
rm -rf $(CURDIR)/pyNN/neuron/nmodl/arm64 \
133+
$(CURDIR)/pyNN/neuron/nmodl/x86_64
134+
135+
.PHONY: clean
136+
clean: ## [A] Remove .local-nest$(NEST_VERSION)/ (triggers full rebuild)
137+
rm -rf $(NEST_PREFIX)
138+
139+
.PHONY: clean-build
140+
clean-build: ## [A] Remove cmake build dir only (retry after cmake failure)
141+
rm -rf $(NEST_BUILD_DIR)
142+
143+
.PHONY: clean-all
144+
clean-all: ## [A] Remove all .local-nest*/, .nest-build/, .nest-src/
145+
rm -rf .local-nest*/ .nest-build/ .nest-src/
146+
147+
# ══════════════════════════════════════════════════════════════════════════════
148+
# Option B: Docker with bind-mounted source
149+
# ══════════════════════════════════════════════════════════════════════════════
150+
151+
.PHONY: docker-build
152+
docker-build: ## [B] Build Docker image pynn-test:nest$(NEST_VERSION)
153+
$(COMPOSE) build
154+
155+
.PHONY: docker-test
156+
docker-test: ## [B] Run full test suite in Docker
157+
$(COMPOSE) run --rm pynn pytest -v -n auto test/
158+
159+
.PHONY: docker-test-unit
160+
docker-test-unit: ## [B] Unit tests in Docker
161+
$(COMPOSE) run --rm pynn pytest -n auto test/unittests/
162+
163+
.PHONY: docker-test-nest
164+
docker-test-nest: ## [B] NEST tests in Docker
165+
$(COMPOSE) run --rm pynn pytest -n auto test/system/test_nest.py test/system/scenarios/
166+
167+
.PHONY: docker-test-neuron
168+
docker-test-neuron: ## [B] NEURON tests in Docker
169+
$(COMPOSE) run --rm pynn pytest -n auto test/system/test_neuron.py test/system/scenarios/
170+
171+
.PHONY: docker-test-brian2
172+
docker-test-brian2: ## [B] Brian2 tests in Docker
173+
$(COMPOSE) run --rm pynn pytest -n auto test/system/test_brian2.py
174+
175+
.PHONY: docker-shell
176+
docker-shell: ## [B] Interactive bash inside Docker container
177+
$(COMPOSE) run --rm pynn bash
178+
179+
# ══════════════════════════════════════════════════════════════════════════════
180+
# Option C: micromamba + conda-forge (no compilation)
181+
# Install micromamba first: "${SHELL}" <(curl -L micro.mamba.pm/install.sh)
182+
# conda-forge RC version string uses underscore: e.g. NEST_VERSION=3.10_rc1
183+
# ══════════════════════════════════════════════════════════════════════════════
184+
185+
.PHONY: conda-setup
186+
conda-setup: ## [C] Create micromamba env .condaenv-nest$(NEST_VERSION)/
187+
@test -f $(CONDA_ENV_FILE) || \
188+
(echo "Error: $(CONDA_ENV_FILE) not found."; \
189+
echo "Copy test/environment-nest3.9.yml and update the nest-simulator pin."; \
190+
exit 1)
191+
$(MICROMAMBA) env create -p $(CONDAENV_PREFIX) -f $(CONDA_ENV_FILE) -y
192+
$(MICROMAMBA) run -p $(CONDAENV_PREFIX) pip install -e .
193+
194+
.PHONY: conda-test
195+
conda-test: ## [C] Run full test suite via micromamba
196+
$(MICROMAMBA) run -p $(CONDAENV_PREFIX) pytest -v -n auto test/
197+
198+
.PHONY: conda-test-unit
199+
conda-test-unit: ## [C] Unit tests via micromamba
200+
$(MICROMAMBA) run -p $(CONDAENV_PREFIX) pytest -n auto test/unittests/
201+
202+
.PHONY: conda-test-nest
203+
conda-test-nest: ## [C] NEST tests via micromamba
204+
$(MICROMAMBA) run -p $(CONDAENV_PREFIX) \
205+
pytest -n auto test/system/test_nest.py test/system/scenarios/
206+
207+
.PHONY: conda-test-neuron
208+
conda-test-neuron: ## [C] NEURON tests via micromamba
209+
$(MICROMAMBA) run -p $(CONDAENV_PREFIX) \
210+
pytest -n auto test/system/test_neuron.py test/system/scenarios/
211+
212+
.PHONY: conda-test-brian2
213+
conda-test-brian2: ## [C] Brian2 tests via micromamba
214+
$(MICROMAMBA) run -p $(CONDAENV_PREFIX) pytest -n auto test/system/test_brian2.py
215+
216+
.PHONY: conda-shell
217+
conda-shell: ## [C] Interactive shell via micromamba
218+
$(MICROMAMBA) run -p $(CONDAENV_PREFIX) bash
219+
220+
.PHONY: conda-clean
221+
conda-clean: ## [C] Remove .condaenv-nest$(NEST_VERSION)/
222+
rm -rf $(CONDAENV_PREFIX)
223+
224+
# ══════════════════════════════════════════════════════════════════════════════
225+
# Help
226+
# ══════════════════════════════════════════════════════════════════════════════
227+
228+
.PHONY: help
229+
help: ## Show available targets
230+
@echo "Usage: make <target> [NEST_VERSION=3.9]"
231+
@echo ""
232+
@grep -E '^[a-zA-Z0-9_-]+:.*##' $(MAKEFILE_LIST) | \
233+
awk 'BEGIN {FS = ":.*##"}; {printf " %-26s %s\n", $$1, $$2}'
234+
@echo ""
235+
@echo "Current NEST_VERSION: $(NEST_VERSION)"
236+
237+
.DEFAULT_GOAL := help

pyNN/nest/simulator.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ def build_extensions(build_dir=None):
7373
warnings.warn("Cannot create build directory for nest extensions")
7474
return
7575

76+
# Remove any stale CMakeCache.txt so cmake re-detects the SDK and toolchain.
77+
stale_cache = os.path.join(nest_build_dir, "CMakeCache.txt")
78+
try:
79+
os.remove(stale_cache)
80+
except FileNotFoundError:
81+
pass
82+
7683
source_dir = os.path.join(os.path.dirname(__file__), "extensions")
7784
result, stdout = run_command(f"cmake -Dwith-nest={nest_config} {source_dir}",
7885
nest_build_dir)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies = [
3434
]
3535

3636
[project.optional-dependencies]
37-
test = ["pytest", "pytest-cov", "flake8", "wheel", "mpi4py", "scipy", "matplotlib", "Cheetah3", "h5py", "Jinja2"]
37+
test = ["pytest", "pytest-xdist", "pytest-cov", "flake8", "wheel", "mpi4py", "scipy", "matplotlib", "Cheetah3", "h5py", "Jinja2"]
3838
doc = ["sphinx"]
3939
examples = ["matplotlib", "scipy"]
4040
plotting = ["matplotlib", "scipy"]

test/Dockerfile

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
ARG NEST_VERSION=3.9
2+
FROM python:3.12-slim
3+
4+
ARG NEST_VERSION
5+
6+
RUN apt-get update && apt-get install -y --no-install-recommends \
7+
libltdl-dev \
8+
libgsl-dev \
9+
libopenmpi-dev \
10+
openmpi-bin \
11+
cmake \
12+
make \
13+
wget \
14+
g++ \
15+
python3-dev \
16+
&& rm -rf /var/lib/apt/lists/*
17+
18+
# cython must be <3.1.0 for NEST to compile
19+
RUN pip install --no-cache-dir "cython<3.1.0"
20+
21+
# Build NEST from source (mirrors .github/workflows/full-test.yml)
22+
RUN wget -q https://github.com/nest/nest-simulator/archive/refs/tags/v${NEST_VERSION}.tar.gz \
23+
&& tar xzf v${NEST_VERSION}.tar.gz \
24+
&& mkdir nest-build \
25+
&& cd nest-build \
26+
&& cmake \
27+
-DCMAKE_INSTALL_PREFIX=/usr/local \
28+
-Dwith-mpi=ON \
29+
-Dwith-python=ON \
30+
-Dwith-gsl=ON \
31+
-Dwith-ltdl=ON \
32+
-Dwith-openmp=OFF \
33+
../nest-simulator-${NEST_VERSION} \
34+
&& make -j$(nproc) \
35+
&& make install \
36+
&& cd .. && rm -rf nest-simulator-${NEST_VERSION} nest-build v${NEST_VERSION}.tar.gz
37+
38+
# Build and install PyNN NEST extensions (pynn_extensions module).
39+
# Copied from the source tree so the image works without the bind mount at build time.
40+
COPY pyNN/nest/extensions /tmp/pynn-nest-extensions/
41+
RUN mkdir /tmp/pynn-ext-build \
42+
&& cd /tmp/pynn-ext-build \
43+
&& cmake -Dwith-nest=/usr/local/bin/nest-config /tmp/pynn-nest-extensions \
44+
&& make install \
45+
&& rm -rf /tmp/pynn-ext-build /tmp/pynn-nest-extensions
46+
47+
# Install PyNN's declared dependencies from its metadata.
48+
# The source tree is bind-mounted at runtime; only the deps need to live in the image.
49+
# A minimal stub package satisfies the install without baking in the real source.
50+
COPY pyproject.toml /tmp/pynn-meta/
51+
RUN mkdir -p /tmp/pynn-meta/pyNN && touch /tmp/pynn-meta/pyNN/__init__.py && \
52+
pip install --no-cache-dir \
53+
"numpy<2" \
54+
"/tmp/pynn-meta[test,neuron,brian2,arbor,MPI,sonata,neuroml]" && \
55+
rm -rf /tmp/pynn-meta
56+
57+
COPY test/docker-entrypoint.sh /entrypoint.sh
58+
59+
WORKDIR /workspace
60+
ENTRYPOINT ["/entrypoint.sh"]

test/docker-compose.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
services:
2+
pynn:
3+
image: pynn-test:nest${NEST_VERSION:-3.9}
4+
build:
5+
context: ..
6+
dockerfile: test/Dockerfile
7+
args:
8+
NEST_VERSION: ${NEST_VERSION:-3.9}
9+
volumes:
10+
- ..:/workspace
11+
environment:
12+
PYTHONPATH: /workspace
13+
working_dir: /workspace

test/docker-entrypoint.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/bin/sh
2+
# Compile NEURON NMODL mechanisms if not already compiled for this architecture.
3+
# nrnivmodl must run in the nmodl directory; it creates an arch-named subdirectory
4+
# (x86_64/ or aarch64/) alongside the .mod files, which is where NEURON looks.
5+
arch=$(uname -m)
6+
nmodl_dir=/workspace/pyNN/neuron/nmodl
7+
if [ -d "$nmodl_dir" ] && [ ! -d "$nmodl_dir/$arch" ]; then
8+
echo "Compiling NEURON NMODL mechanisms for $arch ..."
9+
cd "$nmodl_dir" && nrnivmodl . >/dev/null 2>&1 || echo "Warning: nrnivmodl failed"
10+
fi
11+
12+
# Remove any Arbor catalogue compiled for a different OS/architecture.
13+
# build_mechanisms() skips building if the .so already exists, so a stale
14+
# macOS Mach-O binary (non-ELF) must be removed before the module is imported.
15+
catalogue=/workspace/pyNN/arbor/nmodl/PyNN-catalogue.so
16+
if [ -f "$catalogue" ]; then
17+
python3 -c "
18+
with open('$catalogue', 'rb') as f:
19+
is_elf = f.read(4) == b'\x7fELF'
20+
if not is_elf:
21+
import os
22+
os.remove('$catalogue')
23+
try:
24+
os.remove('${catalogue}_')
25+
except FileNotFoundError:
26+
pass
27+
"
28+
fi
29+
30+
# Build Arbor catalogue if absent (either never built or just removed above).
31+
# Do this once here so pytest-xdist workers don't race to build it simultaneously.
32+
arbor_nmodl=/workspace/pyNN/arbor/nmodl
33+
if [ -d "$arbor_nmodl" ] && [ ! -f "$arbor_nmodl/PyNN-catalogue.so" ]; then
34+
if command -v arbor-build-catalogue >/dev/null 2>&1; then
35+
echo "Building Arbor PyNN catalogue ..."
36+
arbor-build-catalogue PyNN "$arbor_nmodl" >/dev/null 2>&1 \
37+
|| echo "Warning: arbor-build-catalogue failed"
38+
fi
39+
fi
40+
41+
exec "$@"

0 commit comments

Comments
 (0)