Skip to content

Commit f21d6a6

Browse files
authored
Merge branch 'main' into xfit-remove-rot
2 parents f6df73f + 772970f commit f21d6a6

25 files changed

Lines changed: 567 additions & 123 deletions

.github/workflows/tests.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ jobs:
104104
python-version: ${{ matrix.python }}
105105
if: startswith(matrix.kind, 'pip') || matrix.kind == 'minimal'
106106
id: setup-python
107-
# Workaround macOS path behavior with login shells (which puts system Python first)
108-
- run: echo "export PATH=\"$(dirname ${{ steps.setup-python.outputs.python-path }}):$PATH\"" | tee -a ~/.bash_profile # zizmor: ignore[template-injection]
107+
# Workaround macOS path behavior with login shells (which puts system Python first).
108+
# \$PATH must stay escaped so that it is expanded when each login shell sources
109+
# ~/.bash_profile: writing the expanded value would freeze PATH as of this step and
110+
# silently discard anything a later step adds to $GITHUB_PATH.
111+
- run: echo "export PATH=\"$(dirname ${{ steps.setup-python.outputs.python-path }}):\$PATH\"" | tee -a ~/.bash_profile # zizmor: ignore[template-injection]
109112
if: startswith(matrix.kind, 'pip') && startswith(matrix.os, 'macos')
110113
# Python (if conda)
111114
- uses: mamba-org/setup-micromamba@v3

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ repos:
7979
rev: v6.0.0
8080
hooks:
8181
- id: file-contents-sorter
82-
files: ^doc/sphinxext/related_software.txt|^ignore_words.txt
82+
files: ^ignore_words.txt
8383
args: ["--ignore-case"]
8484

8585
- repo: https://github.com/pappasam/toml-sort

azure-pipelines.yml

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ stages:
5656
displayName: Setup up Xvfb
5757
- task: Cache@2
5858
inputs:
59-
key: minimal-cmds-1
59+
key: '"minimal-cmds" | "$(Agent.OS)" | $(MNE_TOOLS_DIR)/tools/get_minimal_commands.sh'
6060
path: /home/vsts/minimal_cmds
6161
displayName: Cache minimal commands
6262
- bash: $(MNE_TOOLS_DIR)/tools/get_minimal_commands.sh
@@ -166,6 +166,7 @@ stages:
166166
pool:
167167
vmImage: 'windows-latest'
168168
variables:
169+
MNE_TOOLS_DIR: '$(Pipeline.Workspace)/mne-tools'
169170
MNE_LOGGING_LEVEL: 'warning'
170171
MNE_FORCE_SERIAL: 'true'
171172
PYTEST_XDIST_N: '2' # Microsoft-hosted agents have 2 cores
@@ -181,17 +182,33 @@ stages:
181182
3.14 pip:
182183
TEST_MODE: 'pip'
183184
PYTHON_VERSION: '3.14'
184-
MNE_TEST_ALLOW_SKIP: "^.*(Requires (spm|brainstorm) dataset|Requires MNE-C|Requires FreeSurfer|CUDA not|on Windows|MNE_FORCE_SERIAL|PySide6 causes segfaults|SCIPY_ARRAY_API).*$"
185+
MNE_TEST_ALLOW_SKIP: "^.*(Requires (spm|brainstorm) dataset|Requires FreeSurfer|CUDA not|on Windows|MNE_FORCE_SERIAL|PySide6 causes segfaults|SCIPY_ARRAY_API).*$"
185186
3.14 pip pre:
186187
TEST_MODE: 'pip-pre'
187188
PYTHON_VERSION: '3.14'
188-
MNE_TEST_ALLOW_SKIP: "^.*(Requires (spm|brainstorm) dataset|Requires MNE-C|Requires FreeSurfer|CUDA not|Numba not|on Windows|MNE_FORCE_SERIAL|PySide6 causes segfaults|SCIPY_ARRAY_API).*$"
189+
MNE_TEST_ALLOW_SKIP: "^.*(Requires (spm|brainstorm) dataset|Requires FreeSurfer|CUDA not|Numba not|on Windows|MNE_FORCE_SERIAL|PySide6 causes segfaults|SCIPY_ARRAY_API).*$"
189190
steps:
190191
- task: UsePythonVersion@0
191192
inputs:
192193
versionSpec: $(PYTHON_VERSION)
193194
architecture: 'x64'
194195
addToPath: true
196+
- bash: |
197+
set -xeo pipefail
198+
git clone --depth 1 https://github.com/mne-tools/mne-tools.git "$MNE_TOOLS_DIR"
199+
displayName: Clone mne-tools
200+
- task: Cache@2
201+
inputs:
202+
key: '"minimal-cmds" | "$(Agent.OS)" | $(MNE_TOOLS_DIR)/tools/get_minimal_commands.sh'
203+
path: C:\Users\VssAdministrator\minimal_cmds
204+
displayName: Cache minimal commands
205+
# MNE_TOOLS_DIR is a Windows path and these steps run under Git Bash, hence
206+
# cygpath; backticks rather than $(...) so Azure does not read it as a macro
207+
- bash: |
208+
set -eo pipefail
209+
MNE_TOOLS_UNIX=`cygpath -u "$MNE_TOOLS_DIR"`
210+
bash "$MNE_TOOLS_UNIX/tools/get_minimal_commands.sh"
211+
displayName: Get minimal commands
195212
- bash: |
196213
set -xeo pipefail
197214
git clone --depth 1 https://github.com/pyvista/setup-headless-display-action.git
@@ -216,6 +233,11 @@ stages:
216233
displayName: Cache testing data
217234
- bash: ./tools/github_actions_download.sh
218235
displayName: Download testing data
236+
# Windows does not define HOME, and MNE-C dereferences it unconditionally:
237+
# mne_forward_solution crashes with 0xC0000005 without it. Set after the
238+
# steps above so that Git Bash keeps deriving its own MSYS-style HOME.
239+
- script: 'echo ##vso[task.setvariable variable=HOME]%USERPROFILE%'
240+
displayName: Set HOME for MNE-C
219241
- script: pytest -m "not (slowtest or pgtest)" -n $(PYTEST_XDIST_N) --dist loadscope --timeout=120 --timeout-method=thread -o faulthandler_timeout=110 --tb=short --cov=mne --cov-report=xml -vv mne
220242
displayName: Run tests
221243
- bash: bash <(curl -s https://codecov.io/bash)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a ``block`` parameter to :func:`mne.viz.plot_source_estimates`, :meth:`mne.SourceEstimate.plot` and :meth:`mne.VolSourceEstimate.plot_3d` to halt execution until the figure is closed, by `Cedric Conday`_.

doc/changes/dev/14232.other.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add categories to the :ref:`related-python-packages` list, by `Laetitia Grabot`_, `Daniel McCloy`_.

doc/conf.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,12 @@ def rstjinja(app, docname, source):
15191519
source[0] = rendered
15201520

15211521

1522+
def set_toc_level(app, pagename, templatename, context, doctree):
1523+
"""Show the auto-generated related-software subsections in the right sidebar."""
1524+
if pagename == "install/mne_tools_suite":
1525+
context["theme_show_toc_level"] = 2
1526+
1527+
15221528
# -- Connect our handlers to the main Sphinx app ---------------------------
15231529

15241530

@@ -1535,3 +1541,4 @@ def setup(app):
15351541
app.connect("build-finished", make_custom_redirects)
15361542
app.connect("build-finished", make_version)
15371543
app.connect("source-read", rstjinja)
1544+
app.connect("html-page-context", set_toc_level)

doc/install/mne_tools_suite.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ Related software
1414
computing cortically constrained Minimum Norm Estimates from MEG and EEG
1515
data. These tools were written in C by Matti Hämäläinen, and are
1616
documented `here <MNE-C manual_>`_. See :ref:`install_mne_c` for installation
17-
instructions.
18-
19-
- MNE-Python reimplements the functionality of MNE-C, extends considerably the
17+
instructions. MNE-Python reimplements the functionality of MNE-C, extends considerably the
2018
analysis and visualization capabilities, and adds support for additional data
2119
types like functional near-infrared spectroscopy (fNIRS). MNE-Python is
2220
collaboratively developed and has more than 200 contributors.
@@ -29,6 +27,12 @@ Related software
2927
- :ref:`MNE-CPP <mne_cpp>` provides core MNE functionality implemented in
3028
C++ and is primarily intended for embedded and real-time applications.
3129

30+
31+
.. _related-python-packages:
32+
33+
Related Python packages
34+
^^^^^^^^^^^^^^^^^^^^^^^
35+
3236
There is also a growing ecosystem of other Python packages that work alongside
3337
MNE-Python, including:
3438

doc/sphinxext/related_software.py

Lines changed: 103 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@
6767
"Home-page": "https://github.com/aaltoimaginglanguage/conpy",
6868
"Summary": "Functions and classes for performing connectivity analysis on MEG data.", # noqa: E501
6969
},
70+
# no wheels
71+
"eelbrain": {
72+
"Home-page": "https://eelbrain.readthedocs.io",
73+
"Summary": "MEG/EEG analysis tools",
74+
},
7075
}
7176

7277
REQUIRE_INSTALLED = os.getenv("MNE_REQUIRE_RELATED_SOFTWARE_INSTALLED", "false").lower()
@@ -79,7 +84,23 @@
7984
"matplotlib-base": "matplotlib",
8085
}
8186

82-
_memory = joblib.Memory(location=pathlib.Path(__file__).parent / ".joblib", verbose=0)
87+
# 4. Each package is associated to one or more category. The assignment is done in
88+
# related_software.txt
89+
cat_names = {
90+
"io": "Data I/O and interoperability",
91+
"organization": "Data organization and workflows",
92+
"preproc": "Preprocessing and artifact correction",
93+
"oscillations": "Oscillations and time-frequency analysis",
94+
"connectivity": "Connectivity and source analysis",
95+
"stats": "Statistics and machine learning",
96+
"microstates": "Microstates and neural states",
97+
"modalities": "Other physiological signals and modalities",
98+
"visu": "Visualization and real-time analysis",
99+
"Other": "Other",
100+
}
101+
102+
cwd = pathlib.Path(__file__).parent
103+
_memory = joblib.Memory(location=cwd / ".joblib", verbose=0)
83104

84105

85106
@_memory.cache(cache_validation_callback=joblib.expires_after(days=7))
@@ -104,6 +125,40 @@ def _get_installer_packages():
104125
return packages
105126

106127

128+
def _get_mapping(packages):
129+
txt = cwd / "related_software.txt"
130+
txt_nodeps = cwd / "related_software_nodeps.txt"
131+
mapping = dict()
132+
for line in (txt.read_text() + "\n" + txt_nodeps.read_text()).split("\n"):
133+
line = line.strip()
134+
if not line or line.startswith("##"):
135+
continue
136+
line = line.lstrip("# ")
137+
pkg = line.split("#")[0].strip()
138+
# just keep anything after "categories: " to end of line
139+
if "categories: " in line:
140+
categories = line.split("categories:")[-1].strip()
141+
else:
142+
categories = "Other"
143+
# split the comma-separated list of categories into a tuple of strings
144+
mapping[pkg] = tuple([x.strip() for x in categories.split(",")])
145+
# unpack the tuples to make a single sequence of (unique) categories
146+
categories = sorted(set([cat for cats in mapping.values() for cat in cats]))
147+
# put "other" last
148+
if "Other" in categories:
149+
categories.remove("Other")
150+
categories.append("Other")
151+
# now, invert the mapping to be category: list of packages
152+
rev_mapping = dict()
153+
for cat in categories:
154+
rev_mapping[cat] = tuple([pkg for pkg, cats in mapping.items() if cat in cats])
155+
# extra packages: not in the two text files
156+
extras = tuple(set(packages) - set(mapping))
157+
if len(other := tuple(sorted(rev_mapping.get("Other", ()) + extras))):
158+
rev_mapping["Other"] = other
159+
return rev_mapping
160+
161+
107162
@functools.lru_cache
108163
def _get_packages() -> dict[str, str]:
109164
try:
@@ -127,10 +182,11 @@ def _get_packages() -> dict[str, str]:
127182
if name not in packages:
128183
packages.append(name)
129184
# Simple alphabetical order
130-
packages = sorted(packages, key=lambda x: x.lower())
131185
packages = [RENAMES.get(package, package) for package in packages]
186+
packages = sorted(packages, key=lambda x: x.lower())
132187
out = dict()
133188
reasons = []
189+
assert "fsleyes" in packages
134190
for package in status_iterator(
135191
packages, f"Adding {len(packages)} related software packages: "
136192
):
@@ -141,13 +197,15 @@ def _get_packages() -> dict[str, str]:
141197
else:
142198
md = importlib.metadata.metadata(package)
143199
except importlib.metadata.PackageNotFoundError:
200+
assert "fsleyes" != package
144201
reasons.append(f"{package}: not found, needs to be installed")
145202
continue # raise a complete error later
146203
else:
147204
# Every project should really have this
148205
do_continue = False
149206
for key in ("Summary",):
150207
if key not in md:
208+
assert "fsleyes" != package
151209
reasons.extend(f"{package}: missing {repr(key)}")
152210
do_continue = True
153211
if do_continue:
@@ -185,31 +243,50 @@ def _get_packages() -> dict[str, str]:
185243
f"Could not find suitable metadata for related software:\n{reason_str}"
186244
)
187245

188-
return out
246+
# read the .txt files and build the category mapping
247+
cat_to_pkgs_mapping = _get_mapping(out)
248+
return out, cat_to_pkgs_mapping
189249

190250

191251
class RelatedSoftwareDirective(Directive):
192252
"""Create a directive that inserts a bullet list of related software."""
193253

194254
def run(self):
195255
"""Run the directive."""
196-
my_list = nodes.bullet_list(bullet="*")
197-
for package, data in _get_packages().items():
198-
item = nodes.list_item()
199-
if "description" not in data:
200-
para = nodes.paragraph(text=f"{package}")
201-
else:
202-
para = nodes.paragraph(text=f": {data['description']}")
203-
refnode = nodes.reference(
204-
"url",
205-
package,
206-
internal=False,
207-
refuri=data["url"],
208-
)
209-
para.insert(0, refnode)
210-
item += para
211-
my_list.append(item)
212-
return [my_list]
256+
my_section = list()
257+
pkg_data, cat_to_pkgs = _get_packages()
258+
# iterate over category, packages
259+
for category, packages in cat_to_pkgs.items():
260+
# Make each category a proper (sub)section so that it gets a real
261+
# heading, an anchor, and an entry in the page TOC. This mimics what
262+
# docutils itself does in RSTState.new_subsection.
263+
section = nodes.section()
264+
# Use real category name instead of short names
265+
title = nodes.title(text=cat_names[category])
266+
section["names"].append(nodes.fully_normalize_name(title.astext()))
267+
self.state.document.note_implicit_target(section, section)
268+
section += title
269+
my_section.append(section)
270+
this_list = nodes.bullet_list(bullet="*")
271+
272+
for package in packages:
273+
data = pkg_data.get(package.lower(), {})
274+
item = nodes.list_item()
275+
if "description" not in data:
276+
para = nodes.paragraph(text=f"{package}")
277+
else:
278+
para = nodes.paragraph(text=f": {data['description']}")
279+
refnode = nodes.reference(
280+
"url",
281+
package,
282+
internal=False,
283+
refuri=data["url"],
284+
)
285+
para.insert(0, refnode)
286+
item += para
287+
this_list.append(item)
288+
section += this_list
289+
return my_section
213290

214291

215292
def setup(app): # noqa: D103
@@ -228,7 +305,9 @@ def setup(app): # noqa: D103
228305
# running `python doc/sphinxext/related_software.py` for testing
229306
# require metadata for any installed packages (for debugging)
230307
REQUIRE_METADATA = True
231-
items = list(RelatedSoftwareDirective.run(None)[0].children)
232-
print(f"Got {len(items)} related software packages:")
233-
for item in items:
234-
print(f"- {item.astext()}")
308+
pkg_data, cat_to_pkgs = _get_packages()
309+
print(f"Got {len(pkg_data)} related software packages:")
310+
for category, packages in cat_to_pkgs.items():
311+
print(f"{cat_names[category]}:")
312+
for package in packages:
313+
print(f"- {package}")

0 commit comments

Comments
 (0)