[074d3d]: / mne / tests / test_import_nesting.py

Download this file

272 lines (250 with data), 10.1 kB

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
import ast
import glob
import os
import sys
from pathlib import Path
from types import ModuleType
import pytest
import mne
from mne.utils import _pl, logger, run_subprocess
# To avoid circular import issues, we have a defined order of submodule
# priority. A submodule should nest an import from another submodule if and
# only if the other submodule is below it in this list.
# For example mne.fixes must nest all imports. mne.utils should nest all
# imports *except* mne.fixes, which should be an un-nested import.
IMPORT_NESTING_ORDER = (
"fixes",
"defaults",
"utils",
"cuda",
"_fiff",
"filter",
"transforms",
"surface",
"_freesurfer",
"viz",
"annotations",
"bem",
"source_space",
"channels",
"event",
"time_frequency",
"evoked",
"epochs",
"io",
"forward",
"minimum_norm",
"dipole",
"inverse_sparse",
"beamformer",
"decoding",
"preprocessing",
# The rest of these are less critical after the above are sorted out,
# so we'll just go alphabetical
"chpi",
"coreg",
"datasets",
"export",
"gui",
"report",
"simulation",
"stats",
)
# These are not listed in mne.__all__ but we want to consider them above
NON_ALL_SUBMODULES = (
"_fiff",
"_freesurfer",
"annotations",
"bem",
"cuda",
"evoked",
"filter",
"fixes",
"surface",
"transforms",
"utils",
)
IGNORE_SUBMODULES = ("commands",) # historically these are always root level
def test_import_nesting_hierarchy():
"""Test that our module nesting hierarchy is correct."""
# First check that our IMPORT_NESTING_ORDER has all submodules
submodule_names = [
submodule_name
for submodule_name in list(mne.__all__) + list(NON_ALL_SUBMODULES)
if isinstance(getattr(mne, submodule_name), ModuleType)
and submodule_name not in IGNORE_SUBMODULES
]
missing = set(IMPORT_NESTING_ORDER) - set(submodule_names)
assert missing == set(), "Submodules missing from mne.__init__"
missing = set(submodule_names) - set(IMPORT_NESTING_ORDER)
assert missing == set(), "Submodules missing from IMPORT_NESTING_ORDER"
# AST-parse all .py files in a submod dir to check nesting
class _ValidatingVisitor(ast.NodeVisitor):
def __init__(self, *, rel_path, must_nest, must_not_nest):
self.level = rel_path.count("/") # e.g., mne/surface.py will be 1
self.must_nest = set(must_nest)
self.must_not_nest = set(must_not_nest)
self.errors = list()
super().__init__()
def generic_visit(self, node):
if not isinstance(node, ast.Import | ast.ImportFrom):
super().generic_visit(node)
return
stmt = " " * node.col_offset
if isinstance(node, ast.Import):
stmt += "import "
else:
stmt += f"from {'.' * node.level}{node.module or ''} import "
stmt += ", ".join(n.name for n in node.names)
# No "import mne.*"
err = (node.lineno, stmt)
logger.debug(f" {node.lineno:}".ljust(6) + ":" + stmt)
if any(n.name == "mne" or n.name.startswith("mne.") for n in node.names):
self.errors.append(err + ("non-relative mne import",))
if isinstance(node, ast.ImportFrom): # from
if node.level != 0: # from .
# now we need to triage based on whether this is nested
if node.module is None:
self.errors.append(err + ("non-explicit relative import",))
elif node.level == self.level:
module_name = node.module.split(".")[0]
if node.col_offset: # nested
if (
module_name in self.must_not_nest
and node.module != "viz.backends.renderer"
):
self.errors.append(
err + (f"hierarchy: must not nest {module_name}",)
)
else: # non-nested
if module_name in self.must_nest:
self.errors.append(
err + (f"hierarchy: must nest {module_name}",)
)
super().generic_visit(node)
ignores = (
# File, statement, kind (omit line number because this can change)
("mne/utils/docs.py", " import mne", "non-relative mne import"),
(
"mne/io/_read_raw.py",
" from . import read_raw_ant, read_raw_artemis123, read_raw_bdf, read_raw_boxy, read_raw_brainvision, read_raw_cnt, read_raw_ctf, read_raw_curry, read_raw_edf, read_raw_eeglab, read_raw_egi, read_raw_eximia, read_raw_eyelink, read_raw_fieldtrip, read_raw_fif, read_raw_fil, read_raw_gdf, read_raw_kit, read_raw_nedf, read_raw_nicolet, read_raw_nihon, read_raw_nirx, read_raw_nsx, read_raw_persyst, read_raw_snirf", # noqa: E501
"non-explicit relative import",
),
(
"mne/datasets/utils.py",
" from . import eegbci, fetch_fsaverage, fetch_hcp_mmp_parcellation, fetch_infant_template, fetch_phantom, limo, sleep_physionet", # noqa: E501
"non-explicit relative import",
),
(
"mne/datasets/sleep_physionet/__init__.py",
"from . import age, temazepam, _utils",
"non-explicit relative import",
),
(
"mne/datasets/brainstorm/__init__.py",
"from . import bst_raw, bst_resting, bst_auditory, bst_phantom_ctf, bst_phantom_elekta", # noqa: E501
"non-explicit relative import",
),
(
"mne/channels/_standard_montage_utils.py",
"from . import __file__",
"non-explicit relative import",
),
(
"mne/source_space/__init__.py",
"from . import _source_space",
"non-explicit relative import",
),
(
"mne/time_frequency/spectrum.py",
" from ..viz._mpl_figure import _line_figure, _split_picks_by_type",
"hierarchy: must not nest viz",
),
)
root_dir = Path(mne.__file__).parent.resolve()
all_errors = list()
for si, submodule_name in enumerate(IMPORT_NESTING_ORDER):
must_not_nest = IMPORT_NESTING_ORDER[:si]
must_nest = IMPORT_NESTING_ORDER[si + 1 :]
submodule_path = root_dir / submodule_name
if submodule_path.is_dir():
# Get all .py files to parse
files = glob.glob(str(submodule_path / "**" / "*.py"), recursive=True)
assert len(files) > 1
else:
submodule_path = submodule_path.with_suffix(".py")
assert submodule_path.is_file()
files = [submodule_path]
del submodule_path
for file in files:
file = Path(file)
rel_path = "mne" / file.relative_to(root_dir)
if rel_path.parent.stem == "tests":
continue # never look at tests/*.py
validator = _ValidatingVisitor(
rel_path=rel_path.as_posix(),
must_nest=must_nest,
must_not_nest=must_not_nest,
)
tree = ast.parse(file.read_text(encoding="utf-8"), filename=file)
assert isinstance(tree, ast.Module)
rel_path = rel_path.as_posix() # str
logger.debug(rel_path)
validator.visit(tree)
errors = [
err for err in validator.errors if (rel_path,) + err[1:] not in ignores
]
# Format these for easy copy-paste
all_errors.extend(
f"Line {line}:".ljust(11) + f'("{rel_path}", "{stmt}", "{kind}"),'
for line, stmt, kind in errors
if not stmt.endswith((". import __version__", " import renderer"))
)
# Print a reasonable number of lines
n = len(all_errors)
all_errors = all_errors[:30] + (
[] if n <= 30 else [f"... {len(all_errors) - 30} more"]
)
if all_errors:
raise AssertionError(f"{n} nesting error{_pl(n)}:\n" + "\n".join(all_errors))
# scheme obeys the above order
# This test ensures that modules are lazily loaded by lazy_loader.
eager_import = os.getenv("EAGER_IMPORT", "")
run_script = """
import sys
import mne
out = set()
# check scipy (Numba imports it to check the version)
ok_scipy_submodules = {'version'}
scipy_submodules = set(x.split('.')[1] for x in sys.modules.keys()
if x.startswith('scipy.') and '__' not in x and
not x.split('.')[1].startswith('_')
and sys.modules[x] is not None)
bad = scipy_submodules - ok_scipy_submodules
if len(bad) > 0:
out |= {'scipy submodules: %s' % list(bad)}
# check sklearn and others
for x in sys.modules.keys():
for key in ('sklearn', 'pandas', 'pyvista', 'matplotlib',
'dipy', 'nibabel', 'cupy', 'picard', 'pyvistaqt', 'pooch',
'tqdm', 'jinja2'):
if x.startswith(key):
x = '.'.join(x.split('.')[:2])
out |= {x}
if len(out) > 0:
print('\\nFound un-nested import(s) for %s' % (sorted(out),), end='')
exit(len(out))
# but this should still work
mne.io.read_raw_fif
assert "scipy.signal" in sys.modules, "scipy.signal not in sys.modules"
"""
@pytest.mark.skipif(bool(eager_import), reason=f"EAGER_IMPORT={eager_import}")
def test_lazy_loading():
"""Test that module imports are properly nested."""
stdout, stderr, code = run_subprocess(
[sys.executable, "-c", run_script], return_code=True
)
assert code == 0, stdout + stderr