Skip to content

Commit 6881480

Browse files
authored
Simplify doc building with more refleak (#14045)
1 parent 1326afe commit 6881480

9 files changed

Lines changed: 115 additions & 51 deletions

File tree

doc/sphinxext/mne_doc_utils.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,21 @@
1515
import numpy as np
1616
import pyvista
1717
import sphinx.util.logging
18-
from refleak.testing import assert_no_instances
18+
from refleak.testing import Snapshot, assert_no_instances, gc_collect_once
1919
from sphinx.errors import ExtensionError
2020

2121
import mne
22-
from mne.utils import Bunch, _get_extra_data_path, sizeof_fmt
22+
from mne.utils import Bunch, _get_extra_data_path, _is_vtk, sizeof_fmt
2323
from mne.viz import Brain
2424

2525
sphinx_logger = sphinx.util.logging.getLogger("mne")
2626
_np_print_defaults = np.get_printoptions()
2727

28+
# Taken at each example's "before" reset and diffed at its "after" reset, so
29+
# any VTK object an example creates but fails to release is flagged -- not
30+
# just the specific classes checked by name below.
31+
_vtk_snapshot = None
32+
2833

2934
def reset_warnings(gallery_conf, fname):
3035
"""Ensure we are future compatible and ignore silly warnings."""
@@ -148,10 +153,6 @@ def reset_modules(gallery_conf, fname, when):
148153
from pyvistaqt import BackgroundPlotter # noqa
149154
except ImportError:
150155
BackgroundPlotter = None # noqa
151-
try:
152-
from vtkmodules.vtkCommonDataModel import vtkPolyData # noqa
153-
except ImportError:
154-
vtkPolyData = None # noqa
155156
try:
156157
from mne_qt_browser._pg_figure import MNEQtBrowser
157158
except ImportError:
@@ -187,6 +188,9 @@ def reset_modules(gallery_conf, fname, when):
187188
IPython.core.completer.__main__ = sys.modules["__main__"]
188189
except Exception:
189190
pass
191+
# A plain collect (not the deduped one): dead figures must drop out of
192+
# ui_events._event_channels (a WeakKeyDictionary) before the emptiness
193+
# assert below.
190194
gc.collect()
191195

192196
# Agg does not call close_event so let's clean up on our own :(
@@ -198,25 +202,16 @@ def reset_modules(gallery_conf, fname, when):
198202
orig_when = when
199203
when = f"mne/conf.py:Resetter.__call__:{when}:{fname}"
200204
# Support stuff like
201-
# MNE_SKIP_INSTANCE_ASSERTIONS="Brain,Plotter,BackgroundPlotter,vtkPolyData,_Renderer" make html-memory # noqa: E501
205+
# MNE_SKIP_INSTANCE_ASSERTIONS="Brain,Plotter,BackgroundPlotter,vtk,_Renderer" make html-memory # noqa: E501
202206
# to just test MNEQtBrowser
203207
skips = os.getenv("MNE_SKIP_INSTANCE_ASSERTIONS", "").lower()
204208
prefix = ""
205209
request = Bunch() # just give it something to say "we have done GC already"
206210
request.node = Bunch()
211+
global _vtk_snapshot
207212
if skips not in ("true", "1", "all"):
208213
prefix = "Clean "
209214
skips = skips.split(",")
210-
if "brain" not in skips:
211-
assert_no_instances(Brain, when=when, request=request)
212-
if Plotter is not None and "plotter" not in skips:
213-
assert_no_instances(Plotter, when=when, request=request)
214-
if BackgroundPlotter is not None and "backgroundplotter" not in skips:
215-
assert_no_instances(BackgroundPlotter, when=when, request=request)
216-
if vtkPolyData is not None and "vtkpolydata" not in skips:
217-
assert_no_instances(vtkPolyData, when=when, request=request)
218-
if "_renderer" not in skips:
219-
assert_no_instances(_Renderer, when=when, request=request)
220215
if MNEQtBrowser is not None and "mneqtbrowser" not in skips:
221216
# Ensure any manual fig.close() events get properly handled
222217
from mne_qt_browser._pg_figure import QApplication
@@ -225,7 +220,36 @@ def reset_modules(gallery_conf, fname, when):
225220
if inst is not None:
226221
for _ in range(2):
227222
inst.processEvents()
228-
assert_no_instances(MNEQtBrowser, when=when, request=request)
223+
# This collect must come AFTER _cleanup_agg() above: an example's
224+
# subscribed ui-events callback can be the last anchor of its whole
225+
# object graph (channel dict -> callback -> __globals__ -> figures),
226+
# and only cleanup breaks that loop. It is the once-per-reset collect
227+
# that the checks below dedupe against (see gc_collect_once).
228+
gc_collect_once(request)
229+
objs = gc.get_objects() # scan the heap once, share across all checks
230+
if "brain" not in skips:
231+
assert_no_instances(Brain, when=when, request=request, objs=objs)
232+
if Plotter is not None and "plotter" not in skips:
233+
assert_no_instances(Plotter, when=when, request=request, objs=objs)
234+
if BackgroundPlotter is not None and "backgroundplotter" not in skips:
235+
assert_no_instances(
236+
BackgroundPlotter, when=when, request=request, objs=objs
237+
)
238+
if "_renderer" not in skips:
239+
assert_no_instances(_Renderer, when=when, request=request, objs=objs)
240+
if MNEQtBrowser is not None and "mneqtbrowser" not in skips:
241+
assert_no_instances(MNEQtBrowser, when=when, request=request, objs=objs)
242+
if "vtk" not in skips:
243+
# Diff all VTK objects across each example: catches leaks of any
244+
# VTK class (actors, mappers, arrays, ...), not just the specific
245+
# classes above, while tolerating VTK state that legitimately
246+
# pre-dates the example.
247+
if orig_when == "after" and _vtk_snapshot is not None:
248+
_vtk_snapshot.assert_no_new(when=when, request=request, objs=objs)
249+
_vtk_snapshot = None
250+
if orig_when == "before":
251+
_vtk_snapshot = Snapshot(_is_vtk, label="VTK", objs=objs)
252+
del objs
229253
# This will overwrite some Sphinx printing but it's useful
230254
# for memory timestamps
231255
if os.getenv("SG_STAMP_STARTS", "").lower() == "true":

mne/conftest.py

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import pytest
2323
from packaging.version import Version
2424
from pytest import StashKey, register_assert_rewrite
25-
from refleak.testing import assert_no_instances, gc_collect_once
25+
from refleak.testing import Snapshot, assert_no_instances, gc_collect_once
2626

2727
# Any `assert` statements in our testing functions should be verbose versions
2828
register_assert_rewrite("mne.utils._testing")
@@ -41,6 +41,7 @@
4141
Bunch,
4242
_check_qt_version,
4343
_chmod_rw_R,
44+
_is_vtk,
4445
_pl,
4546
_record_warnings,
4647
_TempDir,
@@ -1077,36 +1078,34 @@ def brain_gc(request):
10771078
return
10781079
from mne.viz import Brain
10791080

1080-
ignore = set(id(o) for o in gc.get_objects())
1081-
vtk_ignores = ("vtkBuffer_IhE",)
1081+
# Snapshot stores only ids (pins nothing alive) so VTK objects that
1082+
# pre-date the test (e.g. held by module-level state) are never reported.
1083+
snap = Snapshot(_is_vtk, label="VTK")
10821084
yield
10831085
close_func()
1086+
# pyvistaqt >= 0.11.3 schedules the plotter's window for deferred deletion
1087+
# (deleteLater) on close; until Qt processes it, the C++ window object
1088+
# keeps its Python wrapper (and thereby the whole plotter graph) alive.
1089+
from qtpy.QtCore import QEvent
1090+
from qtpy.QtWidgets import QApplication
1091+
1092+
app = QApplication.instance()
1093+
if app is not None:
1094+
for _ in range(2):
1095+
app.processEvents()
1096+
app.sendPostedEvents(None, QEvent.DeferredDelete)
10841097
if not _test_passed(request):
10851098
return
1099+
# The collect must happen *before* list(Brain._instances) is evaluated:
1100+
# a Brain in a dead reference cycle is still in the WeakSet until
1101+
# collected, and the list would pin it alive and falsely report it.
10861102
gc_collect_once(request)
10871103
# Brain._instances is a WeakSet populated only when MNE_3D_BACKEND_TESTING
10881104
# is set (see Brain.__init__), so use it instead of a slow gc.get_objects()
10891105
# scan of the whole process to check for lingering Brain instances.
10901106
assert_no_instances(Brain, "after", request=request, objs=list(Brain._instances))
1091-
# Check VTK -- these aren't individually tracked, so we still need a full
1092-
# heap scan here.
1093-
objs = gc.get_objects()
1094-
bad = list()
1095-
for o in objs:
1096-
try:
1097-
name = o.__class__.__name__
1098-
except Exception: # old Python, probably
1099-
pass
1100-
else:
1101-
if (
1102-
name.startswith("vtk")
1103-
and name not in vtk_ignores
1104-
and id(o) not in ignore
1105-
):
1106-
bad.append(name)
1107-
del o
1108-
del objs, ignore, Brain
1109-
assert len(bad) == 0, "VTK objects linger:\n" + "\n".join(bad)
1107+
# VTK objects aren't individually tracked, so this one is a full heap scan.
1108+
snap.assert_no_new("after", request=request)
11101109

11111110

11121111
_files = list()
@@ -1316,12 +1315,21 @@ def nirx_snirf(request):
13161315
def qt_windows_closed(request, qapp):
13171316
"""Ensure that no new Qt windows are open after a test."""
13181317
_check_skip_backend("pyvistaqt")
1319-
qapp.processEvents()
1318+
from qtpy.QtCore import QEvent
1319+
1320+
# pyvistaqt >= 0.11.3 deletes plotter windows via deleteLater on close;
1321+
# processEvents alone never dispatches those DeferredDelete events, so
1322+
# drain them symmetrically before counting and before re-counting (a
1323+
# pending deletion from an earlier test must not inflate n_before)
1324+
for _ in range(2):
1325+
qapp.processEvents()
1326+
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
13201327
gc.collect()
13211328
n_before = len(qapp.topLevelWidgets())
13221329
yield
13231330
for _ in range(2):
13241331
qapp.processEvents()
1332+
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
13251333
gc.collect()
13261334
# Don't check when the test fails
13271335
if not _test_passed(request):

mne/utils/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ __all__ = [
8787
"_import_nibabel",
8888
"_import_pymatreader_funcs",
8989
"_is_numeric",
90+
"_is_vtk",
9091
"_julian_to_date",
9192
"_mask_to_onsets_offsets",
9293
"_on_missing",
@@ -207,6 +208,7 @@ from ._testing import (
207208
ArgvSetter,
208209
_chmod_rw_R,
209210
_click_ch_name,
211+
_is_vtk,
210212
_raw_annot,
211213
_TempDir,
212214
assert_and_remove_boundary_annot,

mne/utils/_testing.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,3 +438,25 @@ def copytree_rw(src, dst):
438438
shutil.copytree(src, dst)
439439
_chmod_rw_R(dst)
440440
return dst
441+
442+
443+
_vtk_object_base = None
444+
445+
446+
def _is_vtk(obj):
447+
"""Check if an object is a VTK object worth leak-checking (for refleak).
448+
449+
An ``isinstance`` check, not a class-name-prefix one: VTK >= 9.6
450+
instantiates pythonic override subclasses whose names lack the ``vtk``
451+
prefix (``PolyData``, ``VTKAOSArray_vtkFloatArray``, ...), and pyvista
452+
wrapper subclasses count as VTK objects too. Requires ``vtkmodules``.
453+
"""
454+
global _vtk_object_base
455+
if _vtk_object_base is None:
456+
from vtkmodules.vtkCommonCore import vtkObjectBase
457+
458+
_vtk_object_base = vtkObjectBase
459+
# vtkBuffer_IhE (vtkBuffer<unsigned char>) instances are known to linger
460+
return (
461+
isinstance(obj, _vtk_object_base) and obj.__class__.__name__ != "vtkBuffer_IhE"
462+
)

mne/viz/backends/_qt.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
# non-object-based-abstraction-only, remove
2929
Signal,
3030
)
31-
from qtpy.QtGui import QCursor, QIcon, QKeyEvent
31+
from qtpy.QtGui import QCursor, QGuiApplication, QIcon, QKeyEvent
3232
from qtpy.QtWidgets import (
3333
QButtonGroup,
3434
QCheckBox,
@@ -1528,7 +1528,13 @@ def _window_close_disconnect(self, after=True):
15281528
self._window_before_close_callbacks.clear()
15291529

15301530
def _window_get_dpi(self):
1531-
return self._window.windowHandle().screen().logicalDotsPerInch()
1531+
# windowHandle() is None until the window is realized (e.g. when the
1532+
# figure has not been shown yet), so fall back to the primary screen
1533+
handle = self._window.windowHandle()
1534+
screen = None if handle is None else handle.screen()
1535+
if screen is None:
1536+
screen = QGuiApplication.primaryScreen()
1537+
return screen.logicalDotsPerInch()
15321538

15331539
def _window_get_size(self):
15341540
w = self._interactor.geometry().width()

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ doc = [
2323
"pyvistaqt >= 0.11", # released 2023-06-30, will become 0.12 on 2028-07-01
2424
"pyxdf",
2525
"pyzmq != 24.0.0",
26-
"refleak",
26+
"refleak >=0.2.1",
2727
"scikit-learn >= 1.5", # released 2024-05-21, will become 1.6 on 2026-12-09
2828
"seaborn >= 0.5, != 0.11.2",
2929
"selenium >= 4.27.1",
@@ -54,7 +54,7 @@ test = [
5454
"pytest-qt >= 4.3",
5555
"pytest-rerunfailures",
5656
"pytest-timeout >= 2.2",
57-
"refleak",
57+
"refleak >=0.2.1",
5858
"ruff >= 0.1",
5959
"twine",
6060
"vulture",

tools/install_pre_requirements.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,23 @@ python -m pip install $STD_ARGS pip setuptools packaging \
2020
patsy pytz tzdata nibabel tqdm trx-python joblib numexpr \
2121
"$MNE_QT_BACKEND!=6.9.1" \
2222
py-cpuinfo blosc2 hatchling "formulaic>=1.1.0" \
23-
matplotlib
23+
scikit-learn
2424
python -m pip uninstall -yq numpy
2525
echo "::endgroup::"
2626
echo "::group::Scientific Python Nightly Wheels"
2727
python -m pip install $STD_ARGS --only-binary ":all:" --default-timeout=60 \
2828
--index-url "https://pypi.anaconda.org/scientific-python-nightly-wheels/simple" \
2929
"numpy>=2.5.0.dev0" \
3030
"scipy>=1.18.0.dev0" \
31-
"scikit-learn>=1.9.dev0" \
3231
"pandas>=3.1.0.dev0" \
3332
"dipy>=1.12.0.dev0" \
3433
"tables>=3.10.3.dev0" \
3534
"pyarrow>=22.0.0.dev0" \
3635
"matplotlib>=3.11.0.dev0" \
3736
"statsmodels>=0.15.0.dev0" \
3837
"h5py>=3.13.0"
38+
# https://github.com/scikit-learn/scikit-learn/issues/34458
39+
# "scikit-learn>=1.9.dev0" \
3940
echo "::endgroup::"
4041
# No Numba because it forces an old NumPy version
4142

@@ -47,7 +48,7 @@ echo "::endgroup::"
4748
echo "::group::Everything else"
4849
python -m pip install $STD_ARGS \
4950
"pyvista @ https://github.com/pyvista/pyvista/archive/refs/heads/main.zip" \
50-
"pyvistaqt @ https://github.com/pyvista/pyvistaqt/archive/refs/heads/main.zip" \
51+
"pyvistaqt @ https://github.com/larsoner/pyvistaqt/archive/refs/heads/qvtk-opengl-widget.zip" \
5152
"git+https://github.com/nilearn/nilearn" \
5253
"git+https://github.com/pierreablin/picard" \
5354
"git+https://github.com/the-siesta-group/edfio" \

tools/pylock.ci-old.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,9 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93e
430430

431431
[[packages]]
432432
name = "refleak"
433-
version = "0.1.1"
434-
sdist = { url = "https://files.pythonhosted.org/packages/48/3d/b1fe0b6bd9fccf9666c40beafff4e042c5f83eaa54d5408ccc5559e782be/refleak-0.1.1.tar.gz", upload-time = 2026-07-04T00:57:10Z, size = 17584, hashes = { sha256 = "05c7ec81497c6f0dd80028fb933136a7d9b055a978f74b17f8bc787e1602b764" } }
435-
wheels = [{ url = "https://files.pythonhosted.org/packages/50/fe/b26d9cad0eb21bbbaf345b9543eabc7c28f093ed022a4df9b3027df54bcd/refleak-0.1.1-py3-none-any.whl", upload-time = 2026-07-04T00:57:09Z, size = 11937, hashes = { sha256 = "aafe8c1141b59df7c10eaa9bdf509ddefcf00624be644118adb678c4af30c38f" } }]
433+
version = "0.2.1"
434+
sdist = { url = "https://files.pythonhosted.org/packages/8a/70/b586bac19d443491b0b1bf18e017c98dfdb190503022e58afeb6240ee64f/refleak-0.2.1.tar.gz", upload-time = 2026-07-10T12:59:14Z, size = 28367, hashes = { sha256 = "8dd9585634375b0c0e1e9be12f6cf699b97f140c9a615c5c75b4fdb2305e0849" } }
435+
wheels = [{ url = "https://files.pythonhosted.org/packages/2c/e9/0818313965cdfd38f8639e6ac51d50c1bab20c42dfb8e1de571cf4553105/refleak-0.2.1-py3-none-any.whl", upload-time = 2026-07-10T12:59:13Z, size = 20801, hashes = { sha256 = "e48a7c54523273ec99adff87a83a88fddb52f21778a0479190425fcc456ab5a3" } }]
436436

437437
[[packages]]
438438
name = "requests"

tools/vulture_allowlist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
mpl_backend
2525
recwarn
2626
pytestmark
27+
_is_vtk
2728
nbexec
2829
disabled_event_channels
2930
ch_subset_adjacency

0 commit comments

Comments
 (0)