"""The config functions.""" # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. import atexit import json import multiprocessing import os import os.path as op import platform import shutil import subprocess import sys import tempfile from functools import lru_cache, partial from importlib import import_module from pathlib import Path from urllib.error import URLError from urllib.request import urlopen from packaging.version import parse from ._logging import logger, warn from .check import _check_fname, _check_option, _check_qt_version, _validate_type from .docs import fill_doc from .misc import _pl _temp_home_dir = None def set_cache_dir(cache_dir): """Set the directory to be used for temporary file storage. This directory is used by joblib to store memmapped arrays, which reduces memory requirements and speeds up parallel computation. Parameters ---------- cache_dir : str or None Directory to use for temporary file storage. None disables temporary file storage. """ if cache_dir is not None and not op.exists(cache_dir): raise OSError(f"Directory {cache_dir} does not exist") set_config("MNE_CACHE_DIR", cache_dir, set_env=False) def set_memmap_min_size(memmap_min_size): """Set the minimum size for memmaping of arrays for parallel processing. Parameters ---------- memmap_min_size : str or None Threshold on the minimum size of arrays that triggers automated memory mapping for parallel processing, e.g., '1M' for 1 megabyte. Use None to disable memmaping of large arrays. """ _validate_type(memmap_min_size, (str, None), "memmap_min_size") if memmap_min_size is not None: if memmap_min_size[-1] not in ["K", "M", "G"]: raise ValueError( "The size has to be given in kilo-, mega-, or " f"gigabytes, e.g., 100K, 500M, 1G, got {repr(memmap_min_size)}" ) set_config("MNE_MEMMAP_MIN_SIZE", memmap_min_size, set_env=False) # List the known configuration values _known_config_types = { "MNE_3D_OPTION_ANTIALIAS": ( "bool, whether to use full-screen antialiasing in 3D plots" ), "MNE_3D_OPTION_DEPTH_PEELING": "bool, whether to use depth peeling in 3D plots", "MNE_3D_OPTION_MULTI_SAMPLES": ( "int, number of samples to use for full-screen antialiasing" ), "MNE_3D_OPTION_SMOOTH_SHADING": ("bool, whether to use smooth shading in 3D plots"), "MNE_3D_OPTION_THEME": ("str, the color theme (light or dark) to use for 3D plots"), "MNE_BROWSE_RAW_SIZE": ( "tuple, width and height of the raw browser window (in inches)" ), "MNE_BROWSER_BACKEND": ( "str, the backend to use for the MNE Browse Raw window (qt or matplotlib)" ), "MNE_BROWSER_OVERVIEW_MODE": ( "str, the overview mode to use in the MNE Browse Raw window )" "(see mne.viz.plot_raw for valid options)" ), "MNE_BROWSER_PRECOMPUTE": ( "bool, whether to precompute raw data in the MNE Browse Raw window" ), "MNE_BROWSER_THEME": "str, the color theme (light or dark) to use for the browser", "MNE_BROWSER_USE_OPENGL": ( "bool, whether to use OpenGL for rendering in the MNE Browse Raw window" ), "MNE_CACHE_DIR": "str, path to the cache directory for parallel execution", "MNE_COREG_ADVANCED_RENDERING": ( "bool, whether to use advanced OpenGL rendering in mne coreg" ), "MNE_COREG_COPY_ANNOT": ( "bool, whether to copy the annotation files during warping" ), "MNE_COREG_FULLSCREEN": "bool, whether to use full-screen mode in mne coreg", "MNE_COREG_GUESS_MRI_SUBJECT": ( "bool, whether to guess the MRI subject in mne coreg" ), "MNE_COREG_HEAD_HIGH_RES": ( "bool, whether to use high-res head surface in mne coreg" ), "MNE_COREG_HEAD_OPACITY": ("bool, the head surface opacity to use in mne coreg"), "MNE_COREG_HEAD_INSIDE": ( "bool, whether to add an opaque inner scalp head surface to help " "occlude points behind the head in mne coreg" ), "MNE_COREG_INTERACTION": ( "str, interaction style in mne coreg (trackball or terrain)" ), "MNE_COREG_MARK_INSIDE": ( "bool, whether to mark points inside the head surface in mne coreg" ), "MNE_COREG_PREPARE_BEM": ( "bool, whether to prepare the BEM solution after warping in mne coreg" ), "MNE_COREG_ORIENT_TO_SURFACE": ( "bool, whether to orient the digitization markers to the head surface " "in mne coreg" ), "MNE_COREG_SCALE_LABELS": ( "bool, whether to scale the MRI labels during warping in mne coreg" ), "MNE_COREG_SCALE_BY_DISTANCE": ( "bool, whether to scale the digitization markers by their distance from " "the scalp in mne coreg" ), "MNE_COREG_SCENE_SCALE": ( "float, the scale factor of the 3D scene in mne coreg (default 0.16)" ), "MNE_COREG_WINDOW_HEIGHT": "int, window height for mne coreg", "MNE_COREG_WINDOW_WIDTH": "int, window width for mne coreg", "MNE_COREG_SUBJECTS_DIR": "str, path to the subjects directory for mne coreg", "MNE_CUDA_DEVICE": "int, CUDA device to use for GPU processing", "MNE_DATA": "str, default data directory", "MNE_DATASETS_BRAINSTORM_PATH": "str, path for brainstorm data", "MNE_DATASETS_EEGBCI_PATH": "str, path for EEGBCI data", "MNE_DATASETS_EPILEPSY_ECOG_PATH": "str, path for epilepsy_ecog data", "MNE_DATASETS_HF_SEF_PATH": "str, path for HF_SEF data", "MNE_DATASETS_MEGSIM_PATH": "str, path for MEGSIM data", "MNE_DATASETS_MISC_PATH": "str, path for misc data", "MNE_DATASETS_MTRF_PATH": "str, path for MTRF data", "MNE_DATASETS_SAMPLE_PATH": "str, path for sample data", "MNE_DATASETS_SOMATO_PATH": "str, path for somato data", "MNE_DATASETS_MULTIMODAL_PATH": "str, path for multimodal data", "MNE_DATASETS_FNIRS_MOTOR_PATH": "str, path for fnirs_motor data", "MNE_DATASETS_OPM_PATH": "str, path for OPM data", "MNE_DATASETS_SPM_FACE_DATASETS_TESTS": "str, path for spm_face data", "MNE_DATASETS_SPM_FACE_PATH": "str, path for spm_face data", "MNE_DATASETS_TESTING_PATH": "str, path for testing data", "MNE_DATASETS_VISUAL_92_CATEGORIES_PATH": "str, path for visual_92_categories data", "MNE_DATASETS_KILOWORD_PATH": "str, path for kiloword data", "MNE_DATASETS_FIELDTRIP_CMC_PATH": "str, path for fieldtrip_cmc data", "MNE_DATASETS_PHANTOM_KIT_PATH": "str, path for phantom_kit data", "MNE_DATASETS_PHANTOM_4DBTI_PATH": "str, path for phantom_4dbti data", "MNE_DATASETS_PHANTOM_KERNEL_PATH": "str, path for phantom_kernel data", "MNE_DATASETS_LIMO_PATH": "str, path for limo data", "MNE_DATASETS_REFMEG_NOISE_PATH": "str, path for refmeg_noise data", "MNE_DATASETS_SSVEP_PATH": "str, path for ssvep data", "MNE_DATASETS_ERP_CORE_PATH": "str, path for erp_core data", "MNE_FORCE_SERIAL": "bool, force serial rather than parallel execution", "MNE_LOGGING_LEVEL": ( "str or int, controls the level of verbosity of any function " "decorated with @verbose. See " "https://mne.tools/stable/auto_tutorials/intro/50_configure_mne.html#logging" ), "MNE_MEMMAP_MIN_SIZE": ( "str, threshold on the minimum size of arrays passed to the workers that " "triggers automated memory mapping, e.g., 1M or 0.5G" ), "MNE_REPR_HTML": ( "bool, represent some of our objects with rich HTML in a notebook " "environment" ), "MNE_SKIP_NETWORK_TESTS": ( "bool, used in a test decorator (@requires_good_network) to skip " "tests that include large downloads" ), "MNE_SKIP_TESTING_DATASET_TESTS": ( "bool, used in test decorators (@requires_spm_data, " "@requires_bstraw_data) to skip tests that require specific datasets" ), "MNE_STIM_CHANNEL": "string, the default channel name for mne.find_events", "MNE_TQDM": ( 'str, either "tqdm", "tqdm.auto", or "off". Controls presence/absence ' "of progress bars" ), "MNE_USE_CUDA": "bool, use GPU for filtering/resampling", "MNE_USE_NUMBA": ( "bool, use Numba just-in-time compiler for some of our intensive " "computations" ), "SUBJECTS_DIR": "path-like, directory of freesurfer MRI files for each subject", } # These allow for partial matches, e.g. 'MNE_STIM_CHANNEL_1' is okay key _known_config_wildcards = ( "MNE_STIM_CHANNEL", # can have multiple stim channels "MNE_DATASETS_FNIRS", # mne-nirs "MNE_NIRS", # mne-nirs "MNE_KIT2FIFF", # mne-kit-gui "MNE_ICALABEL", # mne-icalabel ) def _load_config(config_path, raise_error=False): """Safely load a config file.""" with open(config_path) as fid: try: config = json.load(fid) except ValueError: # No JSON object could be decoded --> corrupt file? msg = ( f"The MNE-Python config file ({config_path}) is not a valid JSON " "file and might be corrupted" ) if raise_error: raise RuntimeError(msg) warn(msg) config = dict() return config def get_config_path(home_dir=None): r"""Get path to standard mne-python config file. Parameters ---------- home_dir : str | None The folder that contains the .mne config folder. If None, it is found automatically. Returns ------- config_path : str The path to the mne-python configuration file. On windows, this will be '%USERPROFILE%\.mne\mne-python.json'. On every other system, this will be ~/.mne/mne-python.json. """ val = op.join(_get_extra_data_path(home_dir=home_dir), "mne-python.json") return val def get_config(key=None, default=None, raise_error=False, home_dir=None, use_env=True): """Read MNE-Python preferences from environment or config file. Parameters ---------- key : None | str The preference key to look for. The os environment is searched first, then the mne-python config file is parsed. If None, all the config parameters present in environment variables or the path are returned. If key is an empty string, a list of all valid keys (but not values) is returned. default : str | None Value to return if the key is not found. raise_error : bool If True, raise an error if the key is not found (instead of returning default). home_dir : str | None The folder that contains the .mne config folder. If None, it is found automatically. use_env : bool If True, consider env vars, if available. If False, only use MNE-Python configuration file values. .. versionadded:: 0.18 Returns ------- value : dict | str | None The preference key value. See Also -------- set_config """ _validate_type(key, (str, type(None)), "key", "string or None") if key == "": # These are str->str (immutable) so we should just copy the dict # itself, no need for deepcopy return _known_config_types.copy() # first, check to see if key is in env if use_env and key is not None and key in os.environ: return os.environ[key] # second, look for it in mne-python config file config_path = get_config_path(home_dir=home_dir) if not op.isfile(config_path): config = {} else: config = _load_config(config_path) if key is None: # update config with environment variables if use_env: env_keys = set(config).union(_known_config_types).intersection(os.environ) config.update({key: os.environ[key] for key in env_keys}) return config elif raise_error is True and key not in config: loc_env = "the environment or in the " if use_env else "" meth_env = ( (f'either os.environ["{key}"] = VALUE for a temporary solution, or ') if use_env else "" ) extra_env = ( " You can also set the environment variable before running python." if use_env else "" ) meth_file = ( f'mne.utils.set_config("{key}", VALUE, set_env=True) for a permanent one' ) raise KeyError( f'Key "{key}" not found in {loc_env}' f"the mne-python config file ({config_path}). " f"Try {meth_env}{meth_file}.{extra_env}" ) else: return config.get(key, default) def set_config(key, value, home_dir=None, set_env=True): """Set a MNE-Python preference key in the config file and environment. Parameters ---------- key : str The preference key to set. value : str | None The value to assign to the preference key. If None, the key is deleted. home_dir : str | None The folder that contains the .mne config folder. If None, it is found automatically. set_env : bool If True (default), update :data:`os.environ` in addition to updating the MNE-Python config file. See Also -------- get_config """ _validate_type(key, "str", "key") # While JSON allow non-string types, we allow users to override config # settings using env, which are strings, so we enforce that here _validate_type(value, (str, "path-like", type(None)), "value") if value is not None: value = str(value) if key not in _known_config_types and not any( key.startswith(k) for k in _known_config_wildcards ): warn(f'Setting non-standard config type: "{key}"') # Read all previous values config_path = get_config_path(home_dir=home_dir) if op.isfile(config_path): config = _load_config(config_path, raise_error=True) else: config = dict() logger.info( f"Attempting to create new mne-python configuration file:\n{config_path}" ) if value is None: config.pop(key, None) if set_env and key in os.environ: del os.environ[key] else: config[key] = value if set_env: os.environ[key] = value if key == "MNE_BROWSER_BACKEND": from ..viz._figure import set_browser_backend set_browser_backend(value) # Write all values. This may fail if the default directory is not # writeable. directory = op.dirname(config_path) if not op.isdir(directory): os.mkdir(directory) with open(config_path, "w") as fid: json.dump(config, fid, sort_keys=True, indent=0) def _get_extra_data_path(home_dir=None): """Get path to extra data (config, tables, etc.).""" global _temp_home_dir if home_dir is None: home_dir = os.environ.get("_MNE_FAKE_HOME_DIR") if home_dir is None: # this has been checked on OSX64, Linux64, and Win32 if "nt" == os.name.lower(): APPDATA_DIR = os.getenv("APPDATA") USERPROFILE_DIR = os.getenv("USERPROFILE") if APPDATA_DIR is not None and op.isdir( op.join(APPDATA_DIR, ".mne") ): # backward-compat home_dir = APPDATA_DIR elif USERPROFILE_DIR is not None: home_dir = USERPROFILE_DIR else: raise FileNotFoundError( "The USERPROFILE environment variable is not set, cannot " "determine the location of the MNE-Python configuration " "folder" ) del APPDATA_DIR, USERPROFILE_DIR else: # This is a more robust way of getting the user's home folder on # Linux platforms (not sure about OSX, Unix or BSD) than checking # the HOME environment variable. If the user is running some sort # of script that isn't launched via the command line (e.g. a script # launched via Upstart) then the HOME environment variable will # not be set. if os.getenv("MNE_DONTWRITE_HOME", "") == "true": if _temp_home_dir is None: _temp_home_dir = tempfile.mkdtemp() atexit.register( partial(shutil.rmtree, _temp_home_dir, ignore_errors=True) ) home_dir = _temp_home_dir else: home_dir = os.path.expanduser("~") if home_dir is None: raise ValueError( "mne-python config file path could " "not be determined, please report this " "error to mne-python developers" ) return op.join(home_dir, ".mne") def get_subjects_dir(subjects_dir=None, raise_error=False): """Safely use subjects_dir input to return SUBJECTS_DIR. Parameters ---------- subjects_dir : path-like | None If a value is provided, return subjects_dir. Otherwise, look for SUBJECTS_DIR config and return the result. raise_error : bool If True, raise a KeyError if no value for SUBJECTS_DIR can be found (instead of returning None). Returns ------- value : Path | None The SUBJECTS_DIR value. """ from_config = False if subjects_dir is None: subjects_dir = get_config("SUBJECTS_DIR", raise_error=raise_error) from_config = True if subjects_dir is not None: subjects_dir = Path(subjects_dir) if subjects_dir is not None: # Emit a nice error or warning if their config is bad try: subjects_dir = _check_fname( fname=subjects_dir, overwrite="read", must_exist=True, need_dir=True, name="subjects_dir", ) except FileNotFoundError: if from_config: msg = ( "SUBJECTS_DIR in your MNE-Python configuration or environment " "does not exist, consider using mne.set_config to fix it: " f"{subjects_dir}" ) if raise_error: raise FileNotFoundError(msg) from None else: warn(msg) elif raise_error: raise return subjects_dir @fill_doc def _get_stim_channel(stim_channel, info, raise_error=True): """Determine the appropriate stim_channel. First, 'MNE_STIM_CHANNEL', 'MNE_STIM_CHANNEL_1', 'MNE_STIM_CHANNEL_2', etc. are read. If these are not found, it will fall back to 'STI 014' if present, then fall back to the first channel of type 'stim', if present. Parameters ---------- stim_channel : str | list of str | None The stim channel selected by the user. %(info_not_none)s Returns ------- stim_channel : list of str The name of the stim channel(s) to use """ from .._fiff.pick import pick_types if stim_channel is not None: if not isinstance(stim_channel, list): _validate_type(stim_channel, "str", "Stim channel") stim_channel = [stim_channel] for channel in stim_channel: _validate_type(channel, "str", "Each provided stim channel") return stim_channel stim_channel = list() ch_count = 0 ch = get_config("MNE_STIM_CHANNEL") while ch is not None and ch in info["ch_names"]: stim_channel.append(ch) ch_count += 1 ch = get_config(f"MNE_STIM_CHANNEL_{ch_count}") if ch_count > 0: return stim_channel if "STI101" in info["ch_names"]: # combination channel for newer systems return ["STI101"] if "STI 014" in info["ch_names"]: # for older systems return ["STI 014"] stim_channel = pick_types(info, meg=False, ref_meg=False, stim=True) if len(stim_channel) == 0 and raise_error: raise ValueError( "No stim channels found. Consider specifying them " "manually using the 'stim_channel' parameter." ) stim_channel = [info["ch_names"][ch_] for ch_ in stim_channel] return stim_channel def _get_root_dir(): """Get as close to the repo root as possible.""" root_dir = Path(__file__).parents[1] up_dir = root_dir.parent if (up_dir / "setup.py").is_file() and all( (up_dir / x).is_dir() for x in ("mne", "examples", "doc") ): root_dir = up_dir return root_dir def _get_numpy_libs(): bad_lib = "unknown linalg bindings" try: from threadpoolctl import threadpool_info except Exception as exc: return bad_lib + f" (threadpoolctl module not found: {exc})" pools = threadpool_info() rename = dict( openblas="OpenBLAS", mkl="MKL", ) for pool in pools: if pool["internal_api"] in ("openblas", "mkl"): return ( f'{rename[pool["internal_api"]]} ' f'{pool["version"]} with ' f'{pool["num_threads"]} thread{_pl(pool["num_threads"])}' ) return bad_lib _gpu_cmd = """\ from pyvista import GPUInfo; \ gi = GPUInfo(); \ print(gi.version); \ print(gi.renderer)""" @lru_cache(maxsize=1) def _get_gpu_info(): # Once https://github.com/pyvista/pyvista/pull/2250 is merged and PyVista # does a release, we can triage based on version > 0.33.2 proc = subprocess.run( [sys.executable, "-c", _gpu_cmd], check=False, capture_output=True ) out = proc.stdout.decode().strip().replace("\r", "").split("\n") if proc.returncode or len(out) != 2: return None, None return out def sys_info( fid=None, show_paths=False, *, dependencies="user", unicode=True, check_version=True, ): """Print system information. This function prints system information useful when triaging bugs. Parameters ---------- fid : file-like | None The file to write to. Will be passed to :func:`print()`. Can be None to use :data:`sys.stdout`. show_paths : bool If True, print paths for each module. dependencies : 'user' | 'developer' Show dependencies relevant for users (default) or for developers (i.e., output includes additional dependencies). unicode : bool Include Unicode symbols in output. .. versionadded:: 0.24 check_version : bool | float If True (default), attempt to check that the version of MNE-Python is up to date with the latest release on GitHub. Can be a float to give a different timeout (in sec) from the default (2 sec). .. versionadded:: 1.6 """ _validate_type(dependencies, str) _check_option("dependencies", dependencies, ("user", "developer")) _validate_type(check_version, (bool, "numeric"), "check_version") ljust = 24 if dependencies == "developer" else 21 platform_str = platform.platform() out = partial(print, end="", file=fid) out("Platform".ljust(ljust) + platform_str + "\n") out("Python".ljust(ljust) + str(sys.version).replace("\n", " ") + "\n") out("Executable".ljust(ljust) + sys.executable + "\n") out("CPU".ljust(ljust) + f"{platform.processor()} ") out(f"({multiprocessing.cpu_count()} cores)\n") out("Memory".ljust(ljust)) try: import psutil except ImportError: out('Unavailable (requires "psutil" package)') else: out(f"{psutil.virtual_memory().total / float(2 ** 30):0.1f} GB\n") out("\n") ljust -= 3 # account for +/- symbols libs = _get_numpy_libs() unavailable = [] use_mod_names = ( "# Core", "mne", "numpy", "scipy", "matplotlib", "", "# Numerical (optional)", "sklearn", "numba", "nibabel", "nilearn", "dipy", "openmeeg", "cupy", "pandas", "h5io", "h5py", "", "# Visualization (optional)", "pyvista", "pyvistaqt", "vtk", "qtpy", "ipympl", "pyqtgraph", "mne-qt-browser", "ipywidgets", # "trame", # no version, see https://github.com/Kitware/trame/issues/183 "trame_client", "trame_server", "trame_vtk", "trame_vuetify", "", "# Ecosystem (optional)", "mne-bids", "mne-nirs", "mne-features", "mne-connectivity", "mne-icalabel", "mne-bids-pipeline", "neo", "eeglabio", "edfio", "mffpy", "pybv", "", ) if dependencies == "developer": use_mod_names += ( "# Testing", "pytest", "nbclient", "statsmodels", "numpydoc", "flake8", "pydocstyle", "nitime", "imageio", "imageio-ffmpeg", "snirf", "", "# Documentation", "sphinx", "sphinx-gallery", "pydata-sphinx-theme", "", "# Infrastructure", "decorator", "jinja2", # "lazy-loader", "packaging", "pooch", "tqdm", "", ) try: unicode = unicode and (sys.stdout.encoding.lower().startswith("utf")) except Exception: # in case someone overrides sys.stdout in an unsafe way unicode = False mne_version_good = True for mi, mod_name in enumerate(use_mod_names): # upcoming break if mod_name == "": # break if unavailable: out("└☐ " if unicode else " - ") out("unavailable".ljust(ljust)) out(f"{', '.join(unavailable)}\n") unavailable = [] if mi != len(use_mod_names) - 1: out("\n") continue elif mod_name.startswith("# "): # header mod_name = mod_name.replace("# ", "") out(f"{mod_name}\n") continue pre = "├" last = use_mod_names[mi + 1] == "" and not unavailable if last: pre = "└" try: mod = import_module(mod_name.replace("-", "_")) except Exception: unavailable.append(mod_name) else: mark = "☑" if unicode else "+" mne_extra = "" if mod_name == "mne" and check_version: timeout = 2.0 if check_version is True else float(check_version) mne_version_good, mne_extra = _check_mne_version(timeout) if mne_version_good is None: mne_version_good = True elif not mne_version_good: mark = "☒" if unicode else "X" out(f"{pre}{mark} " if unicode else f" {mark} ") out(f"{mod_name}".ljust(ljust)) if mod_name == "vtk": vtk_version = mod.vtkVersion() # 9.0 dev has VersionFull but 9.0 doesn't for attr in ("GetVTKVersionFull", "GetVTKVersion"): if hasattr(vtk_version, attr): version = getattr(vtk_version, attr)() if version != "": out(version) break else: out("unknown") else: out(mod.__version__.lstrip("v")) if mod_name == "numpy": out(f" ({libs})") elif mod_name == "qtpy": version, api = _check_qt_version(return_api=True) out(f" ({api}={version})") elif mod_name == "matplotlib": out(f" (backend={mod.get_backend()})") elif mod_name == "pyvista": version, renderer = _get_gpu_info() if version is None: out(" (OpenGL unavailable)") else: out(f" (OpenGL {version} via {renderer})") elif mod_name == "mne": out(f" ({mne_extra})") # Now comes stuff after the version if show_paths: if last: pre = " " elif unicode: pre = "│ " else: pre = " | " out(f'\n{pre}{" " * ljust}{op.dirname(mod.__file__)}') out("\n") if not mne_version_good: out( "\nTo update to the latest supported release version to get bugfixes and " "improvements, visit " "https://mne.tools/stable/install/updating.html\n" ) def _get_latest_version(timeout): # Bandit complains about urlopen, but we know the URL here url = "https://api.github.com/repos/mne-tools/mne-python/releases/latest" try: with urlopen(url, timeout=timeout) as f: # nosec response = json.load(f) except (URLError, TimeoutError) as err: # Triage error type if "SSL" in str(err): return "SSL error" elif "timed out" in str(err): return f"timeout after {timeout} sec" else: return f"unknown error: {err}" else: return response["tag_name"].lstrip("v") or "version unknown" def _check_mne_version(timeout): rel_ver = _get_latest_version(timeout) if not rel_ver[0].isnumeric(): return None, (f"unable to check for latest version on GitHub, {rel_ver}") rel_ver = parse(rel_ver) this_ver = parse(import_module("mne").__version__) if this_ver > rel_ver: return True, f"devel, latest release is {rel_ver}" if this_ver == rel_ver: return True, "latest release" else: return False, f"outdated, release {rel_ver} is available!"