diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 50d42052a4..eedfcc154c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,8 +20,8 @@ on: # - 'docs/**' env: - PY_MIN_VERSION: '3.7' - PY_MAX_VERSION: '3.11' + PY_MIN_VERSION: '3.9' + PY_MAX_VERSION: '3.13' jobs: coverage: diff --git a/.readthedocs.yml b/.readthedocs.yml index dc4223186f..3b4186235a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,9 +1,30 @@ version: 2 +sphinx: + configuration: docs/conf.py + build: os: "ubuntu-22.04" tools: - python: "mambaforge-22.9" + python: "3.12" + apt_packages: + - build-essential + - libx11-dev + - libxcomposite-dev + - libmpich-dev + - ffmpeg + - doxygen + - pandoc + - cmake + - bison + - flex + - libfl-dev + - libreadline-dev + +submodules: + recursive: true -conda: - environment: docs/conda_environment.yml +python: + install: + - requirements: nrn_requirements.txt + - requirements: docs/docs_requirements.txt diff --git a/docs/conf.py b/docs/conf.py index c9e1f5c8ae..9c5713aaeb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,9 +10,11 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # +import glob import os import sys import subprocess +from pathlib import Path # Make translators & domains available for include sys.path.insert(0, os.path.abspath("./translators")) @@ -40,6 +42,7 @@ "sphinx.ext.mathjax", "nbsphinx", "sphinx_design", + "sphinx_inline_tabs", ] source_suffix = { @@ -112,17 +115,32 @@ def setup(app): rtd_ver = PKGVER.parse(os.environ.get("READTHEDOCS_VERSION")) - # Install neuron accordingly (nightly for master, otherwise incoming version) - # Note that neuron wheel must be published a priori. - subprocess.run( - "pip install neuron{}".format( - "=={}".format(rtd_ver.base_version) - if isinstance(rtd_ver, PKGVER.Version) - else "-nightly" - ), - shell=True, - check=True, - ) + # see: + # https://docs.readthedocs.com/platform/stable/reference/environment-variables.html#envvar-READTHEDOCS_VERSION_TYPE + if os.environ.get("READTHEDOCS_VERSION_TYPE") == "external": + # Build and install NEURON from source + subprocess.run( + "cd .. && python setup.py build_ext bdist_wheel", + shell=True, + check=True, + ) + subprocess.run( + f"pip install {glob.glob('../dist/*.whl')[0]}", + shell=True, + check=True, + ) + else: + # Install neuron accordingly (nightly for master, otherwise incoming version) + # Note that neuron wheel must be published a priori. + subprocess.run( + "pip install neuron{}".format( + f"=={rtd_ver.base_version}" + if isinstance(rtd_ver, PKGVER.Version) + else "-nightly" + ), + shell=True, + check=True, + ) # Execute & convert notebooks + doxygen subprocess.run("cd .. && python setup.py docs", check=True, shell=True) diff --git a/docs/docs_requirements.txt b/docs/docs_requirements.txt index a8a10b7fdd..d4a57b1711 100644 --- a/docs/docs_requirements.txt +++ b/docs/docs_requirements.txt @@ -1,18 +1,20 @@ sphinx +# do not check import of next line sphinx_rtd_theme jupyter nbconvert recommonmark matplotlib -# bokeh 3 seems to break docs notebooks -bokeh<3 +bokeh>=3 # do not check import of next line ipython plotnine -numpy<2 +numpy>=2 plotly nbsphinx jinja2 sphinx-design +sphinx-inline-tabs packaging==21.3 tenacity<8.4 +anywidget diff --git a/nrn_requirements.txt b/nrn_requirements.txt index c4ce7d3ae0..661904304c 100644 --- a/nrn_requirements.txt +++ b/nrn_requirements.txt @@ -2,12 +2,11 @@ wheel setuptools<=70.3.0 scikit-build matplotlib -# bokeh 3 seems to break docs notebooks -bokeh<3 +bokeh>=3 ipython -cython<3 +cython>=3 packaging pytest pytest-cov mpi4py -numpy<2 +numpy>=2 diff --git a/setup.py b/setup.py index 0ca138e607..45b0c4473d 100644 --- a/setup.py +++ b/setup.py @@ -410,7 +410,7 @@ def setup_package(): NRN_COLLECT_DIRS = ["bin", "lib", "include", "share"] docs_require = [] # sphinx, themes, etc - maybe_rxd_reqs = ["numpy<2", "Cython<3"] if Components.RX3D else [] + maybe_rxd_reqs = ["numpy", "Cython"] if Components.RX3D else [] maybe_docs = docs_require if "docs" in sys.argv else [] maybe_test_runner = ["pytest-runner"] if "test" in sys.argv else [] diff --git a/share/lib/python/neuron/__init__.py b/share/lib/python/neuron/__init__.py index 823fbec2d2..5f69b03816 100644 --- a/share/lib/python/neuron/__init__.py +++ b/share/lib/python/neuron/__init__.py @@ -1751,3 +1751,14 @@ def _mview_html_tree(hlist, inside_mechanisms_in_use=0): if _get_ipython() is not None: html_formatter = _get_ipython().display_formatter.formatters["text/html"] html_formatter.for_type(hoc.HocObject, _hocobj_html) + +# in case Bokeh is installed, register a serialization function for hoc.Vector +try: + from bokeh.core.serialization import Serializer + + Serializer.register( + type(h.Vector), + lambda obj, serializer: [serializer.encode(item) for item in obj], + ) +except ImportError: + pass diff --git a/share/lib/python/neuron/gui.py b/share/lib/python/neuron/gui.py index 3314aa0d52..c049d6d638 100644 --- a/share/lib/python/neuron/gui.py +++ b/share/lib/python/neuron/gui.py @@ -7,11 +7,11 @@ Note that python threads are not used if nrniv is launched instead of Python """ - from neuron import h - +from contextlib import contextmanager import threading import time +import atexit # recursive, especially in case stop/start pairs called from doNotify code. _lock = threading.RLock() @@ -29,9 +29,10 @@ def process_events(): _lock.acquire() try: h.doNotify() - except: - print("Exception in gui thread") - _lock.release() + except Exception as e: + print(f"Exception in gui thread: {e}") + finally: + _lock.release() class Timer: @@ -67,31 +68,40 @@ def end(self): class LoopTimer(threading.Thread): """ - a Timer that calls f every interval + A Timer that calls a function at regular intervals. """ def __init__(self, interval, fun): - """ - @param interval: time in seconds between call to fun() - @param fun: the function to call on timer update - """ self.started = False self.interval = interval self.fun = fun - threading.Thread.__init__(self) - self.setDaemon(True) + self._running = threading.Event() + threading.Thread.__init__(self, daemon=True) def run(self): h.nrniv_bind_thread(threading.current_thread().ident) self.started = True - while True: + self._running.set() + while self._running.is_set(): self.fun() time.sleep(self.interval) + def stop(self): + """Stop the timer thread and wait for it to terminate.""" + self._running.clear() + self.join() + -if h.nrnversion(9) == "2": # launched with python (instead of nrniv) +if h.nrnversion(9) == "2": # Launched with Python (instead of nrniv) timer = LoopTimer(0.1, process_events) timer.start() + + def cleanup(): + if timer.started: + timer.stop() + + atexit.register(cleanup) + while not timer.started: time.sleep(0.001) diff --git a/src/nrnpython/inithoc.cpp b/src/nrnpython/inithoc.cpp index 5744c7c88e..27f96329a9 100644 --- a/src/nrnpython/inithoc.cpp +++ b/src/nrnpython/inithoc.cpp @@ -215,6 +215,33 @@ static int have_opt(const char* arg) { return 0; } +#if defined(__linux__) || defined(DARWIN) + +/* we do this because thread sanitizer does not allow system calls. + In particular + system("stty sane") + returns an error code of 139 +*/ + +#include +#include +#include + +static struct termios original_termios; + +static void save_original_terminal_settings() { + if (tcgetattr(STDIN_FILENO, &original_termios) == -1 && isatty(STDIN_FILENO)) { + std::cerr << "Error getting original terminal attributes\n"; + } +} + +static void restore_original_terminal_settings() { + if (tcsetattr(STDIN_FILENO, TCSANOW, &original_termios) == -1 && isatty(STDIN_FILENO)) { + std::cerr << "Error restoring terminal attributes\n"; + } +} +#endif // __linux__ + void nrnpython_finalize() { #if USE_PTHREAD pthread_t now = pthread_self(); @@ -222,11 +249,18 @@ void nrnpython_finalize() { #else { #endif + // Call python_gui_cleanup() if defined in Python + PyRun_SimpleString( + "try:\n" + " gui.cleanup()\n" + "except NameError:\n" + " pass\n"); + + // Finalize Python Py_Finalize(); } -#if linux - if (system("stty sane > /dev/null 2>&1")) { - } // 'if' to avoid ignoring return value warning +#if defined(__linux__) || defined(DARWIN) + restore_original_terminal_settings(); #endif } @@ -239,6 +273,10 @@ extern "C" PyObject* PyInit_hoc() { main_thread_ = pthread_self(); #endif +#if defined(__linux__) || defined(DARWIN) + save_original_terminal_settings(); +#endif // __linux__ + if (nrn_global_argv) { // ivocmain was already called so already loaded return nrnpy_hoc(); } diff --git a/src/nrnpython/nrnpy_hoc.cpp b/src/nrnpython/nrnpy_hoc.cpp index 2d225e7df6..c8988ce1ec 100644 --- a/src/nrnpython/nrnpy_hoc.cpp +++ b/src/nrnpython/nrnpy_hoc.cpp @@ -1596,13 +1596,16 @@ PyObject* nrnpy_forall(PyObject* self, PyObject* args) { static PyObject* hocobj_iter(PyObject* self) { // printf("hocobj_iter %p\n", self); PyHocObject* po = (PyHocObject*) self; - if (po->type_ == PyHoc::HocObject) { + if (po->type_ == PyHoc::HocObject || po->type_ == PyHoc::HocSectionListIterator) { if (po->ho_->ctemplate == hoc_vec_template_) { return PySeqIter_New(self); } else if (po->ho_->ctemplate == hoc_list_template_) { return PySeqIter_New(self); } else if (po->ho_->ctemplate == hoc_sectionlist_template_) { // need a clone of self so nested loops do not share iteritem_ + // The HocSectionListIter arm of the outer 'if' became necessary + // at Python-3.13.1 upon which the following body is executed + // twice. See https://github.com/python/cpython/issues/127682 PyObject* po2 = nrnpy_ho2po(po->ho_); PyHocObject* pho2 = (PyHocObject*) po2; pho2->type_ = PyHoc::HocSectionListIterator; diff --git a/src/nrnpython/nrnpython.cpp b/src/nrnpython/nrnpython.cpp index b774623aa5..b09a77c07d 100644 --- a/src/nrnpython/nrnpython.cpp +++ b/src/nrnpython/nrnpython.cpp @@ -210,6 +210,13 @@ extern "C" int nrnpython_start(int b) { // del g // Also, NEURONMainMenu/File/Quit did not work. The solution to both // seems to be to just avoid gui threads if MINGW and launched nrniv + + // Beginning with Python 3.13.0 it seems that the readline + // module has not been loaded yet. Since PyInit_readline sets + // PyOS_ReadlineFunctionPointer = call_readline; without checking, + // we need to import here. + PyRun_SimpleString("import readline as nrn_readline"); + PyOS_ReadlineFunctionPointer = nrnpython_getline; // Is there a -c "command" or file.py arg.