Repository: brian-team/brian2

Issue:
Create a mechanism to interrupt a simulation
This could be enabled automatically via an except hook, or be an option to `Network.run`. It would basically mean that pressing `Ctrl+C` during a run would call `Network.stop()`, so that the network would be in a well-defined state after the interruption.

Hints from issue comments:
@thesamovar: The excepthook version could be a Brian preference (I'd leave it off by default)?@Jasmine969: Hi Brian2 developers, is there any progress on this issue? In my work, I want to halt a simulation if a certain quantity is out of range. But there seems no method in Brian2 that can do this.@mstimberg: > Hi Brian2 developers, is there any progress on this issue? In my work, I want to halt a simulation if a certain quantity is out of range. But there seems no method in Brian2 that can do this.

@Jasmine969  Apologies, I've completely overlooked this question (or maybe you asked it on the forum as well)? This issue is now closed since we added the Ctrl+C mechanism. For more general stopping as you seem to have in mind, you can do this with a `network_operation` in runtime mode: see https://brian2.readthedocs.io/en/latest/examples/network_operation_stop.html Unfortunately, we don't have a corresponding mechanism in place for C++ standalone. However, with the recent change you could manually write a user-provided function with C++ code, and set `brian::Network::globally_stopped = true;` when your condition is fulfilled.@Jasmine969: Thanks. But `network_operation` will slow down the simulation significantly.@mstimberg: > Thanks. But `network_operation` will slow down the simulation significantly.

Well, it depends – in this type of use case, you probably do not have to do the check every timestep? If you provide a large `dt`, the effect should be negligible. Also, one has to be careful to do the check in the most performant way, e.g. rather use the unitless version of a value, do not loop over arrays, etc.

Solution patch:
diff --git a/.github/workflows/post_release_updates.yml b/.github/workflows/post_release_updates.yml
index 1e2a47cf..7bde7f95 100644
--- a/.github/workflows/post_release_updates.yml
+++ b/.github/workflows/post_release_updates.yml
@@ -17,7 +17,7 @@ jobs:
           fetch-depth: 0
           fetch-tags: true
       - name: Install Python
-        uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           python-version: 3.12
       - name: Install dependencies
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index e4b95d04..bea2d378 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -29,7 +29,7 @@ jobs:
           fetch-depth: 0
           persist-credentials: false
       - name: Set up Python 3.x
-        uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           python-version: '3.x'
       - name: Install platformdirs
@@ -49,7 +49,7 @@ jobs:
           path: ${{ steps.cibuildwheel-cache.outputs.dir }}
           key: ${{ runner.os }}-cibuildwheel
       - name: Build wheels
-        uses: pypa/cibuildwheel@d04cacbc9866d432033b1d09142936e6a0e2121a # v2.23.2
+        uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3
         env:
           CIBW_PROJECT_REQUIRES_PYTHON: ">=${{ needs.get_python_versions.outputs.min-python }}"
           CIBW_ARCHS: auto64
@@ -80,7 +80,7 @@ jobs:
           fetch-depth: 0
           persist-credentials: false
       - name: Set up Python 3.x
-        uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           python-version: '3.x'
       - name: Build source tarball
@@ -105,7 +105,7 @@ jobs:
           path: ${{ steps.cibuildwheel-cache.outputs.dir }}
           key: ${{ matrix.config.image }}-${{ matrix.config.arch }}-cibuildwheel
       - name: Build wheels
-        uses: pypa/cibuildwheel@d04cacbc9866d432033b1d09142936e6a0e2121a # v2.23.2
+        uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3
         env:
           CIBW_PROJECT_REQUIRES_PYTHON: ">=${{ needs.get_python_versions.outputs.min-python }}"
           CIBW_ARCHS_LINUX: ${{ matrix.config.arch }}
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 820f876f..67d43079 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -17,7 +17,7 @@ jobs:
         with:
           persist-credentials: false
       - name: Install the latest version of uv
-        uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
+        uses: astral-sh/setup-uv@c7f87aa956e4c323abf06d5dec078e358f6b4d04 # v6.0.0
       - name: Run zizmor 🌈
         run: uvx zizmor --format sarif . > results.sarif
         env:
diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml
index a9e080b7..b80542ab 100644
--- a/.github/workflows/testsuite.yml
+++ b/.github/workflows/testsuite.yml
@@ -23,7 +23,7 @@ jobs:
       - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
           persist-credentials: false
-      - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           python-version: '3.12'
           cache: 'pip'
@@ -84,7 +84,7 @@ jobs:
           cat ~/.brian/user_preferences
       - name: Install Python
         id: python
-        uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           cache: 'pip'
           python-version: ${{ matrix.python-version }}
diff --git a/brian2/__init__.py b/brian2/__init__.py
index 6730aa91..1baec8c8 100644
--- a/brian2/__init__.py
+++ b/brian2/__init__.py
@@ -3,6 +3,7 @@
 """
 
 import logging
+import signal
 
 
 def _check_dependencies():
@@ -225,3 +226,37 @@ def _check_caches():
 
 
 _check_caches()
+
+
+class _InterruptHandler:
+    """
+    Class to turn a Ctrl+C interruption (SIGINT signal) into a `stop` signal for
+    a running simulation (i.e., finish simulating the current time step and then
+    stop). This handler is activated by default, but can be switched off by
+    setting the `core.stop_on_keyboard_interrupt` preference to ``False``.
+    Note that this will only handle interruptions during a `Network.run`,
+    interrupting at any other time will raise a `KeyboardInterrupt` in the
+    usual way. In case that finishing the current time step takes a long time
+    (or hangs for some reason), interrupting with Ctrl+C a second time will
+    force the usual interrupt, regardless of the preference setting.
+    """
+
+    def __init__(self, previous_handler):
+        self.previous_handler = previous_handler
+
+    def __call__(self, signalnum, stack_frame):
+        if (
+            not prefs.core.stop_on_keyboard_interrupt
+            or not Network._globally_running
+            or Network._globally_stopped
+        ):
+            self.previous_handler(signalnum, stack_frame)
+        else:
+            logging.getLogger("brian2").warning(
+                "Simulation stop requested. Press Ctrl+C again to interrupt."
+            )
+            Network._globally_stopped = True
+
+
+_int_handler = _InterruptHandler(signal.getsignal(signal.SIGINT))
+signal.signal(signal.SIGINT, _int_handler)
diff --git a/brian2/codegen/cpp_prefs.py b/brian2/codegen/cpp_prefs.py
index 7ef655a3..a32e7893 100644
--- a/brian2/codegen/cpp_prefs.py
+++ b/brian2/codegen/cpp_prefs.py
@@ -17,15 +17,6 @@
 import subprocess
 import sys
 import tempfile
-
-try:
-    from setuptools.msvc import msvc14_get_vc_env as _get_vc_env
-except ImportError:  # Setuptools 0.74.0 removed this function
-    try:
-        from distutils._msvccompiler import _get_vc_env
-    except ImportError:  # Things keep moving around in distutils/setuptools
-        from distutils.compilers.C.msvc import _get_vc_env
-
 from distutils.ccompiler import get_default_compiler
 
 from brian2.core.preferences import BrianPreference, prefs
@@ -342,6 +333,14 @@ def get_compiler_and_args():
 
 
 def get_msvc_env():
+    try:
+        from setuptools.msvc import msvc14_get_vc_env as _get_vc_env
+    except ImportError:  # Setuptools 0.74.0 removed this function
+        try:
+            from distutils.compilers.C.msvc import _get_vc_env
+        except ImportError:  # Things keep moving around in distutils/setuptools
+            from distutils._msvccompiler import _get_vc_env
+
     global _msvc_env
     arch_name = prefs["codegen.cpp.msvc_architecture"]
     if arch_name == "":
diff --git a/brian2/core/core_preferences.py b/brian2/core/core_preferences.py
index 3f9e7047..2da18c76 100644
--- a/brian2/core/core_preferences.py
+++ b/brian2/core/core_preferences.py
@@ -43,6 +43,14 @@ def default_float_dtype_validator(dtype):
         a warning (``False``).
         """,
     ),
+    stop_on_keyboard_interrupt=BrianPreference(
+        default=True,
+        docs="""
+        Whether to "gracefully" stop a simulation after pressing Ctrl+C (defaults to
+        ``True``). Note that pressing Ctrl+C a second time will force the usual
+        interruption mechanism.
+        """,
+    ),
 )
 
 prefs.register_preferences(
diff --git a/brian2/core/network.py b/brian2/core/network.py
index 34df6345..4854584a 100644
--- a/brian2/core/network.py
+++ b/brian2/core/network.py
@@ -484,6 +484,7 @@ def profiling_info(self):
         return self.get_profiling_info()
 
     _globally_stopped = False
+    _globally_running = False
 
     def __getitem__(self, item):
         if not isinstance(item, str):
@@ -1197,6 +1198,7 @@ def run(
 
         active_objects = [obj for obj in all_objects if obj.active]
 
+        Network._globally_running = True
         while running and not self._stopped and not Network._globally_stopped:
             if not single_clock:
                 timestep, t, dt = self._clock_variables[clock]
@@ -1258,6 +1260,7 @@ def run(
                 running = timestep[0] < clock._i_end
 
         end_time = time.time()
+        Network._globally_running = False
         if self._stopped or Network._globally_stopped:
             self.t_ = clock.t_
         else:
@@ -1275,7 +1278,12 @@ def run(
                 obj._check_for_invalid_states()
 
         if report is not None:
-            report_callback((end_time - start_time) * second, 1.0, t_start, duration)
+            report_callback(
+                (end_time - start_time) * second,
+                device._last_run_completed_fraction,
+                t_start,
+                duration,
+            )
         self.after_run()
 
         logger.debug(
diff --git a/brian2/core/preferences.py b/brian2/core/preferences.py
index 682536ac..4c5e34a9 100644
--- a/brian2/core/preferences.py
+++ b/brian2/core/preferences.py
@@ -208,7 +208,7 @@ def __delitem__(self, item):
 
     def __getattr__(self, name):
         if name in self.__dict__ or name.startswith("__"):
-            return MutableMapping.__getattr__(self, name)
+            return super().__getattribute__(name)
 
         # This function might get called from BrianGlobalPreferencesView with
         # a prefixed name -- therefore the name can contain dots!
@@ -223,7 +223,14 @@ def __getattr__(self, name):
                 "unregistered. This should never happen!"
             )
 
-        return self[name]
+        try:
+            return self[name]
+        except KeyError as ex:
+            raise AttributeError(
+                f"Object of type {type(self).__name__} does not have an attribute '{ex.args[0]}'",
+                name=ex.args[0],
+                obj=self,
+            ) from ex
 
     def __setattr__(self, name, value):
         # Do not allow to set a category name to something else
diff --git a/brian2/devices/cpp_standalone/device.py b/brian2/devices/cpp_standalone/device.py
index 98946b2c..a2e95eba 100644
--- a/brian2/devices/cpp_standalone/device.py
+++ b/brian2/devices/cpp_standalone/device.py
@@ -24,6 +24,7 @@
 from brian2.codegen.generators.cpp_generator import c_data_type
 from brian2.core.functions import Function
 from brian2.core.namespace import get_local_namespace
+from brian2.core.network import Network
 from brian2.core.preferences import BrianPreference, prefs
 from brian2.core.variables import (
     ArrayVariable,
@@ -1287,15 +1288,19 @@ def run(
                 stdout = None
             if os.name == "nt":
                 start_time = time.time()
+                Network._globally_running = True
                 x = subprocess.call(["main"] + run_args, stdout=stdout)
                 self.timers["run_binary"] = time.time() - start_time
+                Network._globally_running = False
             else:
                 run_cmd = prefs.devices.cpp_standalone.run_cmd_unix
                 if isinstance(run_cmd, str):
                     run_cmd = [run_cmd]
                 start_time = time.time()
+                Network._globally_running = True
                 x = subprocess.call(run_cmd + run_args, stdout=stdout)
                 self.timers["run_binary"] = time.time() - start_time
+                Network._globally_running = False
             if stdout is not None:
                 stdout.close()
             if x:
diff --git a/brian2/devices/cpp_standalone/templates/main.cpp b/brian2/devices/cpp_standalone/templates/main.cpp
index ba57df81..510c806c 100644
--- a/brian2/devices/cpp_standalone/templates/main.cpp
+++ b/brian2/devices/cpp_standalone/templates/main.cpp
@@ -1,5 +1,6 @@
 #include <stdlib.h>
 #include "objects.h"
+#include <csignal>
 #include <ctime>
 #include <time.h>
 {{ openmp_pragma('include') }}
@@ -33,8 +34,21 @@ void set_from_command_line(const std::vector<std::string> args)
 		brian::set_variable_by_name(name, value);
 	}
 }
+
+void _int_handler(int signal_num) {
+	if (Network::_globally_running and !Network::_globally_stopped) {
+		Network::_globally_stopped = true;
+	} else {
+		std::signal(signal_num, SIG_DFL);
+		std::raise(signal_num);
+	}
+}
+
 int main(int argc, char **argv)
 {
+	{% if prefs.core.stop_on_keyboard_interrupt %}
+	std::signal(SIGINT, _int_handler);
+	{% endif %}
 	std::random_device _rd;
 	std::vector<std::string> args(argv + 1, argv + argc);
 	if (args.size() >=2 && args[0] == "--results_dir")
diff --git a/brian2/devices/cpp_standalone/templates/network.cpp b/brian2/devices/cpp_standalone/templates/network.cpp
index 8374d00e..280fd524 100644
--- a/brian2/devices/cpp_standalone/templates/network.cpp
+++ b/brian2/devices/cpp_standalone/templates/network.cpp
@@ -12,6 +12,8 @@
 
 double Network::_last_run_time = 0.0;
 double Network::_last_run_completed_fraction = 0.0;
+bool Network::_globally_stopped = false;
+bool Network::_globally_running = false;
 
 Network::Network()
 {
@@ -63,7 +65,9 @@ void Network::run(const double duration, void (*report_func)(const double, const
     double elapsed_realtime;
     bool did_break_early = false;
 
-    while(clock && clock->running())
+    Network::_globally_running = true;
+    Network::_globally_stopped = false;
+    while(clock && clock->running() && !Network::_globally_stopped)
     {
         t = clock->t[0];
 
@@ -112,8 +116,12 @@ void Network::run(const double duration, void (*report_func)(const double, const
         {% endif %}
 
     }
+    Network::_globally_running = false;
 
-    if(!did_break_early) t = t_end;
+    if(!did_break_early && !Network::_globally_stopped)
+        t = t_end;
+    else
+        t = clock->t[0];
 
     _last_run_time = elapsed_realtime;
     if(duration>0)
@@ -124,7 +132,7 @@ void Network::run(const double duration, void (*report_func)(const double, const
     }
     if (report_func)
     {
-        report_func(elapsed_realtime, 1.0, t_start, duration);
+        report_func(elapsed_realtime, _last_run_completed_fraction, t_start, duration);
     }
 }
 
@@ -189,6 +197,8 @@ class Network
     double t;
     static double _last_run_time;
     static double _last_run_completed_fraction;
+    static bool _globally_stopped;
+    static bool _globally_running;
 
     Network();
     void clear();


Test patch:
diff --git a/.github/workflows/test_latest.yml b/.github/workflows/test_latest.yml
index 5b26277d..cb232a0b 100644
--- a/.github/workflows/test_latest.yml
+++ b/.github/workflows/test_latest.yml
@@ -60,7 +60,7 @@ jobs:
           cat ~/.brian/user_preferences
       - name: Install Python
         id: python
-        uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           cache: 'pip'
           python-version: ${{ matrix.python-version }}
@@ -127,7 +127,7 @@ jobs:
           cat ~/.brian/user_preferences
       - name: Install Python
         id: python
-        uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
         with:
           cache: 'pip'
           python-version: ${{ matrix.python-version }}
diff --git a/brian2/tests/test_base.py b/brian2/tests/test_base.py
index 96eed033..6af2c63a 100644
--- a/brian2/tests/test_base.py
+++ b/brian2/tests/test_base.py
@@ -1,5 +1,6 @@
 import pytest
 from numpy.testing import assert_equal
+from packaging.version import parse as parse_version
 
 from brian2 import *
 from brian2.devices.device import reinit_and_delete
@@ -95,7 +96,19 @@ def test_version():
 
     # Check that the version tuple is correct
     version_tuple = brian2.__version_tuple__
-    assert version_tuple == tuple(int(i) for i in version.split(".")[:4])
+
+    expected = []
+    for v in version.split("."):
+        try:
+            expected.append(int(v))
+        except ValueError:
+            expected.append(v)
+
+    # Note that setuptools_scm >= v8.2.1 include a post suffix in the
+    # version tutple, but earlier versions don't
+    assert version_tuple == tuple(expected) or version_tuple == tuple(
+        t for t in expected if isinstance(t, int)
+    )
 
 
 if __name__ == "__main__":
diff --git a/brian2/tests/test_preferences.py b/brian2/tests/test_preferences.py
index 4dbdec45..45c17c74 100644
--- a/brian2/tests/test_preferences.py
+++ b/brian2/tests/test_preferences.py
@@ -277,9 +277,13 @@ def test_preference_name_access():
         gp["main.doesnotexist"]
     with pytest.raises(KeyError):
         gp["nonexisting.name"]
-    with pytest.raises(KeyError):
+    # Note that it is important to raise AttributeError here, since some tools like
+    # Google Collab check for a `.shape` attribute to display debugging information
+    # for numpy arrays. More generally, a call like `hasattr(gp, "main.doesnotexist")`
+    # would fail with an error instead of returning `False`
+    with pytest.raises(AttributeError):
         gp.main.doesnotexist
-    with pytest.raises(KeyError):
+    with pytest.raises(AttributeError):
         gp.nonexisting.name
 
     # Check dictionary functionality
@@ -315,6 +319,9 @@ def test_preference_name_access():
     assert len(gp.prefs)
     assert gp.main._basename == "main"
 
+    # Check that acessing internal attributes work
+    assert len(gp.prefs.__doc__)
+
 
 @pytest.mark.codegen_independent
 def test_str_repr():