Skip to content

Commit 2a87af7

Browse files
committed
BUG: Move defusedxml to optional dependencies (#12264)
1 parent b876edb commit 2a87af7

12 files changed

Lines changed: 63 additions & 23 deletions

File tree

doc/changes/v1.6.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Version 1.6.1 (unreleased)
44
--------------------------
55

66
- Fix bug with type hints in :func:`mne.io.read_raw_neuralynx` (:gh:`12236` by `Richard Höchenberger`_)
7+
- ``defusedxml`` is now an optional (rather than required) dependency and needed when reading EGI-MFF data, NEDF data, and BrainVision montages (:gh:`12264` by `Eric Larson`_)
78

89
.. _changes_1_6_0:
910

mne/channels/_dig_montage_utils.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@
1313
# Copyright the MNE-Python contributors.
1414

1515
import numpy as np
16-
from defusedxml import ElementTree
1716

18-
from ..utils import Bunch, _check_fname, warn
17+
from ..utils import Bunch, _check_fname, _soft_import, warn
1918

2019

2120
def _read_dig_montage_egi(
@@ -28,8 +27,8 @@ def _read_dig_montage_egi(
2827
"hsp, hpi, elp, point_names, fif must all be " "None if egi is not None"
2928
)
3029
_check_fname(fname, overwrite="read", must_exist=True)
31-
32-
root = ElementTree.parse(fname).getroot()
30+
defusedxml = _soft_import("defusedxml", "reading EGI montages")
31+
root = defusedxml.ElementTree.parse(fname).getroot()
3332
ns = root.tag[root.tag.index("{") : root.tag.index("}") + 1]
3433
sensors = root.find("%ssensorLayout/%ssensors" % (ns, ns))
3534
fids = dict()
@@ -76,8 +75,8 @@ def _read_dig_montage_egi(
7675

7776
def _parse_brainvision_dig_montage(fname, scale):
7877
FID_NAME_MAP = {"Nasion": "nasion", "RPA": "rpa", "LPA": "lpa"}
79-
80-
root = ElementTree.parse(fname).getroot()
78+
defusedxml = _soft_import("defusedxml", "reading BrainVision montages")
79+
root = defusedxml.ElementTree.parse(fname).getroot()
8180
sensors = root.find("CapTrakElectrodeList")
8281

8382
fids, dig_ch_pos = dict(), dict()

mne/channels/_standard_montage_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@
99
from functools import partial
1010

1111
import numpy as np
12-
from defusedxml import ElementTree
1312

1413
from .._freesurfer import get_mni_fiducials
1514
from ..transforms import _sph_to_cart
16-
from ..utils import _pl, warn
15+
from ..utils import _pl, _soft_import, warn
1716
from . import __file__ as _CHANNELS_INIT_FILE
1817
from .montage import make_dig_montage
1918

@@ -344,7 +343,8 @@ def _read_brainvision(fname, head_size):
344343
# standard electrode positions: X-axis from T7 to T8, Y-axis from Oz to
345344
# Fpz, Z-axis orthogonal from XY-plane through Cz, fit to a sphere if
346345
# idealized (when radius=1), specified in millimeters
347-
root = ElementTree.parse(fname).getroot()
346+
defusedxml = _soft_import("defusedxml", "reading BrainVision montages")
347+
root = defusedxml.ElementTree.parse(fname).getroot()
348348
ch_names = [s.text for s in root.findall("./Electrode/Name")]
349349
theta = [float(s.text) for s in root.findall("./Electrode/Theta")]
350350
pol = np.deg2rad(np.array(theta))

mne/channels/tests/test_montage.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,8 @@ def test_documented():
513513
)
514514
def test_montage_readers(reader, file_content, expected_dig, ext, warning, tmp_path):
515515
"""Test that we have an equivalent of read_montage for all file formats."""
516+
if file_content.startswith("<?xml"):
517+
pytest.importorskip("defusedxml")
516518
fname = tmp_path / f"test.{ext}"
517519
with open(fname, "w") as fid:
518520
fid.write(file_content)
@@ -1067,6 +1069,7 @@ def test_fif_dig_montage(tmp_path):
10671069
@testing.requires_testing_data
10681070
def test_egi_dig_montage(tmp_path):
10691071
"""Test EGI MFF XML dig montage support."""
1072+
pytest.importorskip("defusedxml")
10701073
dig_montage = read_dig_egi(egi_dig_montage_fname)
10711074
fid, coord = _get_fid_coords(dig_montage.dig)
10721075

@@ -1123,6 +1126,7 @@ def _pop_montage(dig_montage, ch_name):
11231126
@testing.requires_testing_data
11241127
def test_read_dig_captrak(tmp_path):
11251128
"""Test reading a captrak montage file."""
1129+
pytest.importorskip("defusedxml")
11261130
EXPECTED_CH_NAMES_OLD = [
11271131
"AF3",
11281132
"AF4",
@@ -1933,13 +1937,12 @@ def test_get_builtin_montages():
19331937
def test_plot_montage():
19341938
"""Test plotting montage."""
19351939
# gh-8025
1940+
pytest.importorskip("defusedxml")
19361941
montage = read_dig_captrak(bvct_dig_montage_fname)
19371942
montage.plot()
1938-
plt.close("all")
19391943

19401944
f, ax = plt.subplots(1, 1)
19411945
montage.plot(axes=ax)
1942-
plt.close("all")
19431946

19441947
with pytest.raises(TypeError, match="must be an instance of Axes"):
19451948
montage.plot(axes=101)

mne/export/tests/test_export.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ def test_export_epochs_eeglab(tmp_path, preload):
459459
def test_export_evokeds_to_mff(tmp_path, fmt, do_history):
460460
"""Test exporting evoked dataset to MFF."""
461461
pytest.importorskip("mffpy", "0.5.7")
462+
pytest.importorskip("defusedxml")
462463
evoked = read_evokeds_mff(egi_evoked_fname)
463464
export_fname = tmp_path / "evoked.mff"
464465
history = [
@@ -515,6 +516,7 @@ def test_export_evokeds_to_mff(tmp_path, fmt, do_history):
515516
def test_export_to_mff_no_device():
516517
"""Test no device type throws ValueError."""
517518
pytest.importorskip("mffpy", "0.5.7")
519+
pytest.importorskip("defusedxml")
518520
evoked = read_evokeds_mff(egi_evoked_fname, condition="Category 1")
519521
evoked.info["device_info"] = None
520522
with pytest.raises(ValueError, match="No device type."):

mne/io/egi/egimff.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from pathlib import Path
1111

1212
import numpy as np
13-
from defusedxml.minidom import parse
1413

1514
from ..._fiff.constants import FIFF
1615
from ..._fiff.meas_info import _empty_info, _ensure_meas_date_none_or_dt, create_info
@@ -19,7 +18,7 @@
1918
from ...annotations import Annotations
2019
from ...channels.montage import make_dig_montage
2120
from ...evoked import EvokedArray
22-
from ...utils import _check_fname, _check_option, logger, verbose, warn
21+
from ...utils import _check_fname, _check_option, _soft_import, logger, verbose, warn
2322
from ..base import BaseRaw
2423
from .events import _combine_triggers, _read_events
2524
from .general import (
@@ -36,6 +35,9 @@
3635

3736
def _read_mff_header(filepath):
3837
"""Read mff header."""
38+
_soft_import("defusedxml", "reading EGI MFF data")
39+
from defusedxml.minidom import parse
40+
3941
all_files = _get_signalfname(filepath)
4042
eeg_file = all_files["EEG"]["signal"]
4143
eeg_info_file = all_files["EEG"]["info"]
@@ -289,6 +291,9 @@ def _get_eeg_calibration_info(filepath, egi_info):
289291

290292
def _read_locs(filepath, egi_info, channel_naming):
291293
"""Read channel locations."""
294+
_soft_import("defusedxml", "reading EGI MFF data")
295+
from defusedxml.minidom import parse
296+
292297
fname = op.join(filepath, "coordinates.xml")
293298
if not op.exists(fname):
294299
logger.warn("File coordinates.xml not found, not setting channel locations")

mne/io/egi/events.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@
77
from os.path import basename, join, splitext
88

99
import numpy as np
10-
from defusedxml.ElementTree import parse
1110

12-
from ...utils import logger
11+
from ...utils import _soft_import, logger
1312

1413

1514
def _read_events(input_fname, info):
@@ -82,7 +81,8 @@ def _read_mff_events(filename, sfreq):
8281

8382
def _parse_xml(xml_file):
8483
"""Parse XML file."""
85-
xml = parse(xml_file)
84+
defusedxml = _soft_import("defusedxml", "reading EGI MFF data")
85+
xml = defusedxml.ElementTree.parse(xml_file)
8686
root = xml.getroot()
8787
return _xml2list(root)
8888

mne/io/egi/general.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
import re
77

88
import numpy as np
9-
from defusedxml.minidom import parse
109

11-
from ...utils import _pl
10+
from ...utils import _pl, _soft_import
1211

1312

1413
def _extract(tags, filepath=None, obj=None):
1514
"""Extract info from XML."""
15+
_soft_import("defusedxml", "reading EGI MFF data")
16+
from defusedxml.minidom import parse
17+
1618
if obj is not None:
1719
fileobj = obj
1820
elif filepath is not None:
@@ -30,6 +32,9 @@ def _extract(tags, filepath=None, obj=None):
3032

3133
def _get_gains(filepath):
3234
"""Parse gains."""
35+
_soft_import("defusedxml", "reading EGI MFF data")
36+
from defusedxml.minidom import parse
37+
3338
file_obj = parse(filepath)
3439
objects = file_obj.getElementsByTagName("calibration")
3540
gains = dict()
@@ -46,6 +51,9 @@ def _get_gains(filepath):
4651

4752
def _get_ep_info(filepath):
4853
"""Get epoch info."""
54+
_soft_import("defusedxml", "reading EGI MFF data")
55+
from defusedxml.minidom import parse
56+
4957
epochfile = filepath + "/epochs.xml"
5058
epochlist = parse(epochfile)
5159
epochs = epochlist.getElementsByTagName("epoch")
@@ -123,6 +131,9 @@ def _get_blocks(filepath):
123131

124132
def _get_signalfname(filepath):
125133
"""Get filenames."""
134+
_soft_import("defusedxml", "reading EGI MFF data")
135+
from defusedxml.minidom import parse
136+
126137
listfiles = os.listdir(filepath)
127138
binfiles = list(
128139
f for f in listfiles if "signal" in f and f[-4:] == ".bin" and f[0] != "."

mne/io/egi/tests/test_egi.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
)
7171
def test_egi_mff_pause(fname, skip_times, event_times):
7272
"""Test EGI MFF with pauses."""
73+
pytest.importorskip("defusedxml")
7374
if fname == egi_pause_w1337_fname:
7475
# too slow to _test_raw_reader
7576
raw = read_raw_egi(fname).load_data()
@@ -129,6 +130,7 @@ def test_egi_mff_pause(fname, skip_times, event_times):
129130
)
130131
def test_egi_mff_pause_chunks(fname, tmp_path):
131132
"""Test that on-demand of all short segments works (via I/O)."""
133+
pytest.importorskip("defusedxml")
132134
fname_temp = tmp_path / "test_raw.fif"
133135
raw_data = read_raw_egi(fname, preload=True).get_data()
134136
raw = read_raw_egi(fname)
@@ -142,6 +144,7 @@ def test_egi_mff_pause_chunks(fname, tmp_path):
142144
@requires_testing_data
143145
def test_io_egi_mff():
144146
"""Test importing EGI MFF simple binary files."""
147+
pytest.importorskip("defusedxml")
145148
# want vars for n chans
146149
n_ref = 1
147150
n_eeg = 128
@@ -258,6 +261,7 @@ def test_io_egi():
258261
@requires_testing_data
259262
def test_io_egi_pns_mff(tmp_path):
260263
"""Test importing EGI MFF with PNS data."""
264+
pytest.importorskip("defusedxml")
261265
raw = read_raw_egi(egi_mff_pns_fname, include=None, preload=True, verbose="error")
262266
assert "RawMff" in repr(raw)
263267
pns_chans = pick_types(raw.info, ecg=True, bio=True, emg=True)
@@ -314,6 +318,7 @@ def test_io_egi_pns_mff(tmp_path):
314318
@pytest.mark.parametrize("preload", (True, False))
315319
def test_io_egi_pns_mff_bug(preload):
316320
"""Test importing EGI MFF with PNS data (BUG)."""
321+
pytest.importorskip("defusedxml")
317322
egi_fname_mff = testing_path / "EGI" / "test_egi_pns_bug.mff"
318323
with pytest.warns(RuntimeWarning, match="EGI PSG sample bug"):
319324
raw = read_raw_egi(
@@ -356,6 +361,7 @@ def test_io_egi_pns_mff_bug(preload):
356361
@requires_testing_data
357362
def test_io_egi_crop_no_preload():
358363
"""Test crop non-preloaded EGI MFF data (BUG)."""
364+
pytest.importorskip("defusedxml")
359365
raw = read_raw_egi(egi_mff_fname, preload=False)
360366
raw.crop(17.5, 20.5)
361367
raw.load_data()
@@ -383,6 +389,8 @@ def test_io_egi_crop_no_preload():
383389
def test_io_egi_evokeds_mff(idx, cond, tmax, signals, bads):
384390
"""Test reading evoked MFF file."""
385391
pytest.importorskip("mffpy", "0.5.7")
392+
393+
pytest.importorskip("defusedxml")
386394
# expected n channels
387395
n_eeg = 256
388396
n_ref = 1
@@ -468,6 +476,7 @@ def test_read_evokeds_mff_bad_input():
468476
@requires_testing_data
469477
def test_egi_coord_frame():
470478
"""Test that EGI coordinate frame is changed to head."""
479+
pytest.importorskip("defusedxml")
471480
info = read_raw_egi(egi_mff_fname).info
472481
want_idents = (
473482
FIFF.FIFFV_POINT_LPA,
@@ -505,6 +514,7 @@ def test_egi_coord_frame():
505514
)
506515
def test_meas_date(fname, timestamp, utc_offset):
507516
"""Test meas date conversion."""
517+
pytest.importorskip("defusedxml")
508518
raw = read_raw_egi(fname, verbose="warning")
509519
dt = datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f%z")
510520
measdate = dt.astimezone(timezone.utc)
@@ -526,6 +536,7 @@ def test_meas_date(fname, timestamp, utc_offset):
526536
)
527537
def test_set_standard_montage_mff(fname, standard_montage):
528538
"""Test setting a standard montage."""
539+
pytest.importorskip("defusedxml")
529540
raw = read_raw_egi(fname, verbose="warning")
530541
n_eeg = int(standard_montage.split("-")[-1])
531542
n_dig = n_eeg + 3

mne/io/nedf/nedf.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
from datetime import datetime, timezone
77

88
import numpy as np
9-
from defusedxml import ElementTree
109

1110
from ..._fiff.meas_info import create_info
1211
from ..._fiff.utils import _mult_cal_one
13-
from ...utils import _check_fname, verbose, warn
12+
from ...utils import _check_fname, _soft_import, verbose, warn
1413
from ..base import BaseRaw
1514

1615

@@ -52,6 +51,7 @@ def _parse_nedf_header(header):
5251
n_samples : int
5352
The number of data samples.
5453
"""
54+
defusedxml = _soft_import("defusedxml", "reading NEDF data")
5555
info = {}
5656
# nedf files have three accelerometer channels sampled at 100Hz followed
5757
# by five EEG samples + TTL trigger sampled at 500Hz
@@ -69,7 +69,7 @@ def _parse_nedf_header(header):
6969
headerend = header.find(b"\0")
7070
if headerend == -1:
7171
raise RuntimeError("End of header null not found")
72-
headerxml = ElementTree.fromstring(header[:headerend])
72+
headerxml = defusedxml.ElementTree.fromstring(header[:headerend])
7373
nedfversion = headerxml.findtext("NEDFversion", "")
7474
if nedfversion not in ["1.3", "1.4"]:
7575
warn("NEDFversion unsupported, use with caution")

0 commit comments

Comments
 (0)