diff --git a/doc/Makefile b/doc/Makefile index 604b4873422..452b392759c 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -80,7 +80,7 @@ html-noplot: @echo "Build finished. The HTML pages are in _build/html_stable." html_dev-front: - @PATTERN="\(plot_mne_dspm_source_localization.py\|plot_receptive_field.py\|plot_mne_inverse_label_connectivity.py\|plot_sensors_decoding.py\|plot_stats_cluster_spatio_temporal.py\|plot_20_visualize_evoked.py\)" make html_dev-pattern; + @PATTERN="\(30_mne_dspm_loreta.py\|50_decoding.py\|30_strf.py\|20_cluster_1samp_spatiotemporal.py\|20_visualize_evoked.py\)" make html_dev-pattern; dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) _build/dirhtml diff --git a/doc/conf.py b/doc/conf.py index fd366ce7bc9..4a5406b991f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -6,14 +6,14 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +from datetime import datetime, timezone +import faulthandler import gc import os import subprocess import sys import time import warnings -from datetime import datetime, timezone -import faulthandler import numpy as np import matplotlib @@ -732,6 +732,7 @@ def append_attr_meth_examples(app, what, name, obj, options, lines): size=xxl), ], # \u00AD is an optional hyphen (not rendered unless needed) + # If these are changed, the Makefile should be updated, too 'carousel': [ dict(title='Source Estimation', text='Distributed, sparse, mixed-norm, beam\u00ADformers, dipole fitting, and more.', # noqa E501 diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py index 2fcfb9aef90..c467a67f2bd 100644 --- a/mne/gui/_coreg.py +++ b/mne/gui/_coreg.py @@ -179,7 +179,6 @@ def _get_default(var, val): self._mri_fids_modified = False self._mri_scale_modified = False self._accept_close_event = True - self._auto_cleanup = True self._fid_colors = tuple( DEFAULTS['coreg'][f'{key}_color'] for key in ('lpa', 'nasion', 'rpa')) @@ -231,7 +230,8 @@ def _get_default(var, val): # setup the window self._renderer = _get_renderer( size=self._defaults["size"], bgcolor=self._defaults["bgcolor"]) - self._renderer._window_close_connect(self._close_callback) + self._renderer._window_close_connect(self._clean) + self._renderer._window_close_connect(self._close_callback, after=False) self._renderer.set_interaction(interaction) # coregistration model setup @@ -1717,11 +1717,9 @@ def _configure_status_bar(self): 'status_message', 'hide', value=None, input_value=False ) - def _set_automatic_cleanup(self, state): - """Enable/Disable automatic cleanup (for testing purposes only).""" - self._auto_cleanup = state - def _clean(self): + if not self._accept_close_event: + return self._renderer = None self._widgets.clear() self._actors.clear() @@ -1792,7 +1790,4 @@ def _close_callback(self): modal=not MNE_3D_BACKEND_TESTING, ) self._widgets["close_dialog"].show() - - if self._accept_close_event and self._auto_cleanup: - self._clean() return self._accept_close_event diff --git a/mne/gui/tests/test_coreg_gui.py b/mne/gui/tests/test_coreg_gui.py index f25e4980e94..b75aeed6dde 100644 --- a/mne/gui/tests/test_coreg_gui.py +++ b/mne/gui/tests/test_coreg_gui.py @@ -223,8 +223,9 @@ def test_coreg_gui_pyvista(tmp_path, renderer_interactive_pyvistaqt): assert not coreg._trans_modified assert op.isfile(tmp_trans) + # first, disable auto cleanup + coreg._renderer._window_close_disconnect(after=True) # test _close_callback() - coreg._set_automatic_cleanup(False) coreg.close() coreg._widgets['close_dialog'].trigger('Discard') # do not save coreg._clean() # finally, cleanup internal structures diff --git a/mne/gui/tests/test_gui_api.py b/mne/gui/tests/test_gui_api.py index 7b39a50982a..9b06354dfd4 100644 --- a/mne/gui/tests/test_gui_api.py +++ b/mne/gui/tests/test_gui_api.py @@ -334,7 +334,15 @@ def _check_widget_trigger(widget, mock, before, after, call_count=True, # --- END: dialog --- renderer.show() + + renderer._window_close_connect(lambda: mock('first'), after=False) + renderer._window_close_connect(lambda: mock('last')) + old_call_count = mock.call_count renderer.close() + if renderer._kind == 'qt': + assert mock.call_count == old_call_count + 2 + assert mock.call_args_list[-1].args == ('last',) + assert mock.call_args_list[-2].args == ('first',) def test_gui_api_qt(renderer_interactive_pyvistaqt): diff --git a/mne/utils/misc.py b/mne/utils/misc.py index efc6535be9d..dd5d7315cf3 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -326,6 +326,14 @@ def _file_like(obj): return all(callable(getattr(obj, name, None)) for name in ('read', 'seek')) +def _fullname(obj): + klass = obj.__class__ + module = klass.__module__ + if module == 'builtins': + return klass.__qualname__ + return module + '.' + klass.__qualname__ + + def _assert_no_instances(cls, when=''): __tracebackhide__ = True n = 0 @@ -350,14 +358,15 @@ def _assert_no_instances(cls, when=''): if isinstance(r, (list, dict)): rep = f'len={len(r)}' r_ = gc.get_referrers(r) - types = (x.__class__.__name__ for x in r_) + types = (_fullname(x) for x in r_) types = "/".join(sorted(set( x for x in types if x is not None))) rep += f', {len(r_)} referrers: {types}' del r_ else: rep = repr(r)[:100].replace('\n', ' ') - ref.append(f'{r.__class__.__name__}: {rep}') + name = _fullname(r) + ref.append(f'{name}: {rep}') count += 1 del r del rr diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 3c1c40b8014..79bc0a8548f 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -745,7 +745,7 @@ def _clean(self): for renderer in self._renderer._all_renderers: renderer.RemoveAllLights() # app_window cannot be set to None because it is used in __del__ - for key in ('lighting', 'interactor'): + for key in ('lighting', 'interactor', '_RenderWindow'): setattr(self.plotter, key, None) # Qt LeaveEvent requires _Iren so we use _FakeIren instead of None # to resolve the ref to vtkGenericRenderWindowInteractor diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index f2bd3be2d59..fdf95eb928c 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -844,7 +844,11 @@ def _window_initialize(self): self._interactor_fraction = None @abstractmethod - def _window_close_connect(self, func): + def _window_close_connect(self, func, *, after=True): + pass + + @abstractmethod + def _window_close_disconnect(self, after=True): pass @abstractmethod diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index f1164c14a9e..bbf1357586d 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -388,7 +388,10 @@ def __init__(self, brain, width, height, dpi): class _IpyWindow(_AbstractWindow): - def _window_close_connect(self, func): + def _window_close_connect(self, func, *, after=True): + pass + + def _window_close_disconnect(self, after=True): pass def _window_get_dpi(self): diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index ecb4973795c..6d11f6b610a 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -543,29 +543,45 @@ def _window_initialize(self): self._window = self.figure.plotter.app_window self._window.setLocale(QLocale(QLocale.Language.English)) self._window.signal_close.connect(self._window_clean) - self._window_close_callbacks = list() + self._window_before_close_callbacks = list() + self._window_after_close_callbacks = list() # patch closeEvent def closeEvent(event): + # functions to call before closing accept_close_event = True - for callback in self._window_close_callbacks: + for callback in self._window_before_close_callbacks: ret = callback() # check if one of the callbacks ignores the close event if isinstance(ret, bool) and not ret: accept_close_event = False + if accept_close_event: self._window.signal_close.emit() event.accept() else: event.ignore() + + # functions to call after closing + for callback in self._window_after_close_callbacks: + callback() self._window.closeEvent = closeEvent def _window_clean(self): self.figure._plotter = None self._interactor = None - def _window_close_connect(self, func): - self._window_close_callbacks.append(func) + def _window_close_connect(self, func, *, after=True): + if after: + self._window_after_close_callbacks.append(func) + else: + self._window_before_close_callbacks.append(func) + + def _window_close_disconnect(self, after=True): + if after: + self._window_after_close_callbacks.clear() + else: + self._window_before_close_callbacks.clear() def _window_get_dpi(self): return self._window.windowHandle().screen().logicalDotsPerInch() diff --git a/tutorials/clinical/10_ieeg_localize.py b/tutorials/clinical/10_ieeg_localize.py index 9d830656876..8d40d6a6003 100644 --- a/tutorials/clinical/10_ieeg_localize.py +++ b/tutorials/clinical/10_ieeg_localize.py @@ -189,7 +189,7 @@ def plot_overlay(image, compare, title, thresh=None): # reg_affine, _ = mne.transforms.compute_volume_registration( # CT_orig, T1, pipeline='rigids', zooms=dict(translation=5))) # -# And instead we just hard-code the resulting 4x4 matrix: +# Instead we just hard-code the resulting 4x4 matrix: reg_affine = np.array([ [0.99270756, -0.03243313, 0.11610254, -133.094156], diff --git a/tutorials/preprocessing/70_fnirs_processing.py b/tutorials/preprocessing/70_fnirs_processing.py index ff97008453a..5fe3c759974 100644 --- a/tutorials/preprocessing/70_fnirs_processing.py +++ b/tutorials/preprocessing/70_fnirs_processing.py @@ -338,7 +338,7 @@ mne.viz.plot_evoked_topo(epochs['Right'].average(picks='hbo'), color='r', axes=axes, legend=False) -# Tidy the legend. +# Tidy the legend: leg_lines = [line for line in axes.lines if line.get_c() == 'b'][:1] leg_lines.append([line for line in axes.lines if line.get_c() == 'r'][0]) fig.legend(leg_lines, ['Left', 'Right'], loc='lower right')