From ba2688c6ee801ed5d17c8c9963f9c836a881c54a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 14 Jul 2026 13:04:25 -0400 Subject: [PATCH 1/4] Type public Epochs, Evoked, and io members fully Co-Authored-By: Claude Opus 4.8 --- mne/defaults.py | 8 +- mne/epochs.py | 655 ++++++++++++++----------- mne/evoked.py | 612 ++++++++++++----------- mne/io/_read_raw.py | 8 +- mne/io/ant/ant.py | 18 +- mne/io/array/_array.py | 12 +- mne/io/artemis123/artemis123.py | 7 +- mne/io/base.py | 488 ++++++++++-------- mne/io/bci2k/bci2k.py | 7 +- mne/io/besa/besa.py | 6 +- mne/io/boxy/boxy.py | 7 +- mne/io/brainvision/brainvision.py | 18 +- mne/io/bti/bti.py | 25 +- mne/io/cnt/cnt.py | 25 +- mne/io/ctf/ctf.py | 7 +- mne/io/curry/curry.py | 5 +- mne/io/edf/edf.py | 67 +-- mne/io/eeglab/eeglab.py | 27 +- mne/io/egi/egi.py | 19 +- mne/io/egi/egimff.py | 21 +- mne/io/eximia/eximia.py | 7 +- mne/io/eyelink/eyelink.py | 12 +- mne/io/fieldtrip/fieldtrip.py | 18 +- mne/io/fiff/raw.py | 19 +- mne/io/fil/fil.py | 7 +- mne/io/hitachi/hitachi.py | 6 +- mne/io/kit/coreg.py | 2 +- mne/io/kit/kit.py | 45 +- mne/io/mef/mef.py | 9 +- mne/io/nedf/nedf.py | 7 +- mne/io/neuralynx/neuralynx.py | 7 +- mne/io/nicolet/nicolet.py | 12 +- mne/io/nihon/nihon.py | 6 +- mne/io/nirx/nirx.py | 8 +- mne/io/nsx/nsx.py | 11 +- mne/io/persyst/persyst.py | 7 +- mne/io/snirf/_snirf.py | 8 +- mne/tests/test_docstring_parameters.py | 184 +++++-- mne/tests/test_import_nesting.py | 12 + mne/utils/_typing.py | 10 +- mne/utils/docs.py | 6 +- pyproject.toml | 1 + 42 files changed, 1460 insertions(+), 986 deletions(-) diff --git a/mne/defaults.py b/mne/defaults.py index 126a21cf135..390b8b5395e 100644 --- a/mne/defaults.py +++ b/mne/defaults.py @@ -3,7 +3,7 @@ # Copyright the MNE-Python contributors. from copy import deepcopy -from typing import Any +from typing import Any, Final DEFAULTS: dict[str, Any] = dict( color=dict( @@ -401,6 +401,6 @@ def _handle_default(k, v=None): HEAD_SIZE_DEFAULT = 0.095 # in [m] -_BORDER_DEFAULT = "mean" -_INTERPOLATION_DEFAULT = "cubic" -_EXTRAPOLATE_DEFAULT = "auto" +_BORDER_DEFAULT: Final = "mean" +_INTERPOLATION_DEFAULT: Final = "cubic" +_EXTRAPOLATE_DEFAULT: Final = "auto" diff --git a/mne/epochs.py b/mne/epochs.py index 167b0198e65..ed2b1e51382 100644 --- a/mne/epochs.py +++ b/mne/epochs.py @@ -8,17 +8,21 @@ import operator import os.path as op from collections import Counter +from collections.abc import Callable, Iterable, Iterator from copy import deepcopy from functools import partial from inspect import getfullargspec from pathlib import Path +from typing import TYPE_CHECKING, Literal import numpy as np +from numpy.random import RandomState from scipy.interpolate import interp1d from ._fiff.constants import FIFF from ._fiff.meas_info import ( ContainsMixin, + Info, SetChannelsMixin, _ensure_infos_match, read_meas_info, @@ -55,16 +59,17 @@ write_string, ) from .annotations import ( + Annotations, EpochAnnotationsMixin, _read_annotations_fif, _write_annotations, events_from_annotations, ) from .baseline import _check_baseline, _log_rescale, rescale -from .bem import _check_origin +from .bem import ConductorModel, _check_origin from .channels.channels import InterpolationMixin, ReferenceMixin, UpdateChannelsMixin from .event import _read_events_fif, make_fixed_length_events, match_event_names -from .evoked import EvokedArray +from .evoked import Evoked, EvokedArray from .filter import FilterMixin, _check_fun, detrend from .fixes import _reshape_view, rng_uniform from .html_templates import _get_html_template @@ -105,10 +110,27 @@ verbose, warn, ) -from .utils._typing import Self +from .utils._typing import Color, FileLike, Self from .utils.docs import fill_doc from .viz import plot_drop_log, plot_epochs, plot_epochs_image, plot_topo_image_epochs +if TYPE_CHECKING: + # Heavy/optional deps kept out of the runtime import path (see + # mne/tests/test_import_nesting.py); referenced only via string annotations. + from matplotlib.axes import Axes + from matplotlib.colors import Colormap + from matplotlib.figure import Figure + from pandas import DataFrame + + from .channels.layout import Layout + from .cov import Covariance + from .io import BaseRaw + from .transforms import Transform + + # The optional ``mne_qt_browser`` window subclasses the first-party + # ``BrowserBase``, so alias it to annotate ``.plot()`` returns without the dep. + from .viz._figure import BrowserBase as MNEQtBrowser + def _pack_reject_params(epochs): reject_params = dict() @@ -441,34 +463,34 @@ class BaseEpochs( @verbose def __init__( self, - info, - data, - events, - event_id=None, - tmin=-0.2, - tmax=0.5, - baseline=(None, 0), - raw=None, - picks=None, - reject=None, - flat=None, - decim=1, - reject_tmin=None, - reject_tmax=None, - detrend=None, - proj=True, - on_missing="raise", - preload_at_end=False, - selection=None, - drop_log=None, - filename=None, - metadata=None, - event_repeated="error", + info: Info, + data: np.ndarray | None, + events: np.ndarray, + event_id: int | list[int] | dict | str | list[str] | None = None, + tmin: float = -0.2, + tmax: float = 0.5, + baseline: tuple[float | None, float | None] | None = (None, 0), + raw: "BaseRaw | list | None" = None, + picks: str | np.ndarray | slice | None = None, + reject: dict | None = None, + flat: dict | None = None, + decim: int = 1, + reject_tmin: float | None = None, + reject_tmax: float | None = None, + detrend: int | None = None, + proj: bool | Literal["delayed"] = True, + on_missing: Literal["raise", "warn", "ignore"] = "raise", + preload_at_end: bool = False, + selection: Iterable | None = None, + drop_log: tuple | None = None, + filename: Path | str | None = None, + metadata: "DataFrame | list | None" = None, + event_repeated: str = "error", *, - on_outside="warn", - raw_sfreq=None, - annotations=None, - verbose=None, + on_outside: Literal["warn", "raise", "ignore"] = "warn", + raw_sfreq: float | None = None, + annotations: Annotations | None = None, + verbose: bool | str | int | None = None, ): if events is not None: # RtEpochs can have events=None events = _ensure_events(events) @@ -728,7 +750,7 @@ def _check_consistency(self): assert all(isinstance(s, str) for log in self.drop_log for s in log) @legacy(alt="mne.Epochs.reset_index()") - def reset_drop_log_selection(self): + def reset_drop_log_selection(self) -> None: """Reset the drop_log and selection entries. This method will simplify ``self.drop_log`` and ``self.selection`` @@ -739,7 +761,7 @@ def reset_drop_log_selection(self): """ self.reset_index() - def reset_index(self): + def reset_index(self) -> None: """Reset the epochs index. This resets the epochs indexing and drop log so that ``self.selection`` becomes @@ -752,7 +774,7 @@ def reset_index(self): self.drop_log = (tuple(),) * len(self.events) self._check_consistency() - def load_data(self): + def load_data(self) -> Self: """Load the data if not already preloaded. Returns @@ -779,7 +801,12 @@ def load_data(self): return self @verbose - def apply_baseline(self, baseline=(None, 0), *, verbose=None): + def apply_baseline( + self, + baseline: tuple[float | None, float | None] | None = (None, 0), + *, + verbose: bool | str | int | None = None, + ) -> Self: """Baseline correct epochs. Parameters @@ -990,7 +1017,7 @@ def _detrend_offset_decim(self, epoch, picks, verbose=None): return epoch - def iter_evoked(self, copy=False): + def iter_evoked(self, copy: bool = False) -> Iterator[Evoked]: """Iterate over epochs as a sequence of Evoked objects. The Evoked objects yielded will each contain a single epoch (i.e., no @@ -1025,7 +1052,7 @@ def iter_evoked(self, copy=False): yield EvokedArray(data, info, tmin, comment=str(event_id)) - def subtract_evoked(self, evoked=None): + def subtract_evoked(self, evoked: Evoked | None = None) -> Self: """Subtract an evoked response from each epoch. Can be used to exclude the evoked response when analyzing induced @@ -1107,7 +1134,12 @@ def subtract_evoked(self, evoked=None): return self @fill_doc - def average(self, picks=None, method="mean", by_event_type=False): + def average( + self, + picks: str | np.ndarray | slice | None = None, + method: str | Callable = "mean", + by_event_type: bool = False, + ) -> Evoked | list[Evoked]: """Compute an average over epochs. Parameters @@ -1159,7 +1191,11 @@ def average(self, picks=None, method="mean", by_event_type=False): return evokeds @fill_doc - def standard_error(self, picks=None, by_event_type=False): + def standard_error( + self, + picks: str | np.ndarray | slice | None = None, + by_event_type: bool = False, + ) -> Evoked | list[Evoked]: """Compute standard error over epochs. Parameters @@ -1320,41 +1356,41 @@ def _evoked_from_epoch_data(self, data, info, picks, n_events, kind, comment): return evoked @property - def ch_names(self): + def ch_names(self) -> list[str]: """Channel names.""" return self.info["ch_names"] @copy_function_doc_to_method_doc(plot_epochs) def plot( self, - picks=None, - scalings=None, - n_epochs=20, - n_channels=20, - title=None, - events=False, - event_color=None, - order=None, - show=True, - block=False, - decim="auto", - noise_cov=None, - butterfly=False, - show_scrollbars=True, - show_scalebars=True, - show_zero_line=False, - epoch_colors=None, - event_id=None, - group_by="type", - precompute=None, - use_opengl=None, + picks: str | np.ndarray | slice | None = None, + scalings: Literal["auto"] | dict | None = None, + n_epochs: int = 20, + n_channels: int = 20, + title: str | None = None, + events: bool | np.ndarray = False, + event_color: Color | dict | None = None, + order: np.ndarray | None = None, + show: bool = True, + block: bool = False, + decim: int | Literal["auto"] = "auto", + noise_cov: "Covariance | str | None" = None, + butterfly: bool = False, + show_scrollbars: bool = True, + show_scalebars: bool = True, + show_zero_line: bool = False, + epoch_colors: list | None = None, + event_id: bool | dict | None = None, + group_by: str = "type", + precompute: bool | str | None = None, + use_opengl: bool | None = None, *, - theme=None, - overview_mode=None, - splash=True, - annotation_colors=None, - figure_class=None, - ): + theme: str | Path | None = None, + overview_mode: str | None = None, + splash: bool = True, + annotation_colors: dict | None = None, + figure_class: type | None = None, + ) -> "Figure | MNEQtBrowser": return plot_epochs( self, picks=picks, @@ -1388,23 +1424,23 @@ def plot( @copy_function_doc_to_method_doc(plot_topo_image_epochs) def plot_topo_image( self, - layout=None, - sigma=0.0, - vmin=None, - vmax=None, - colorbar=None, - order=None, - cmap="RdBu_r", - layout_scale=0.95, - title=None, - scalings=None, - border="none", - fig_facecolor="k", - fig_background=None, - font_color="w", - select=False, - show=True, - ): + layout: "Layout | None" = None, + sigma: float = 0.0, + vmin: float | None = None, + vmax: float | None = None, + colorbar: bool | None = None, + order: np.ndarray | Callable | None = None, + cmap: "str | Colormap" = "RdBu_r", + layout_scale: float = 0.95, + title: str | None = None, + scalings: dict | None = None, + border: str = "none", + fig_facecolor: Color = "k", + fig_background: np.ndarray | None = None, + font_color: Color = "w", + select: bool = False, + show: bool = True, + ) -> "Figure": return plot_topo_image_epochs( self, layout=layout, @@ -1426,7 +1462,12 @@ def plot_topo_image( ) @verbose - def drop_bad(self, reject="existing", flat="existing", verbose=None): + def drop_bad( + self, + reject: dict | str | None = "existing", + flat: dict | str | None = "existing", + verbose: bool | str | int | None = None, + ) -> Self: """Drop bad epochs without retaining the epochs data. Should be used before slicing operations. @@ -1460,7 +1501,7 @@ def drop_bad(self, reject="existing", flat="existing", verbose=None): """ if reject == "existing": if flat == "existing" and self._bad_dropped: - return + return self reject = self.reject if flat == "existing": flat = self.flat @@ -1470,7 +1511,7 @@ def drop_bad(self, reject="existing", flat="existing", verbose=None): self._get_data(out=False, verbose=verbose) return self - def drop_log_stats(self, ignore=("IGNORED",)): + def drop_log_stats(self, ignore: list | tuple = ("IGNORED",)) -> float: """Compute the channel stats based on a drop_log from Epochs. Parameters @@ -1492,14 +1533,14 @@ def drop_log_stats(self, ignore=("IGNORED",)): @copy_function_doc_to_method_doc(plot_drop_log) def plot_drop_log( self, - threshold=0, - n_max_plot=20, - subject=None, - color=(0.9, 0.9, 0.9), - width=0.8, - ignore=("IGNORED",), - show=True, - ): + threshold: float = 0, + n_max_plot: int = 20, + subject: str | None = None, + color: Color = (0.9, 0.9, 0.9), + width: float = 0.8, + ignore: list | tuple = ("IGNORED",), + show: bool = True, + ) -> "Figure": if not self._bad_dropped: raise ValueError( "You cannot use plot_drop_log since bad " @@ -1520,26 +1561,26 @@ def plot_drop_log( @copy_function_doc_to_method_doc(plot_epochs_image) def plot_image( self, - picks=None, - sigma=0.0, - vmin=None, - vmax=None, - colorbar=True, - order=None, - show=True, - units=None, - scalings=None, - cmap=None, - fig=None, - axes=None, - overlay_times=None, - combine=None, - group_by=None, - evoked=True, - ts_args=None, - title=None, - clear=False, - ): + picks: str | np.ndarray | slice | None = None, + sigma: float = 0.0, + vmin: float | Callable | None = None, + vmax: float | Callable | None = None, + colorbar: bool = True, + order: np.ndarray | Callable | None = None, + show: bool = True, + units: dict | None = None, + scalings: dict | None = None, + cmap: "str | Colormap | tuple | Literal['interactive'] | None" = None, + fig: "Figure | None" = None, + axes: "list[Axes] | dict | None" = None, + overlay_times: np.ndarray | None = None, + combine: Literal["mean", "median", "std", "gfp"] | Callable | None = None, + group_by: dict | None = None, + evoked: bool = True, + ts_args: dict | None = None, + title: str | None = None, + clear: bool = False, + ) -> "list[Figure]": return plot_epochs_image( self, picks=picks, @@ -1564,7 +1605,12 @@ def plot_image( ) @verbose - def drop(self, indices, reason="USER", verbose=None): + def drop( + self, + indices: np.ndarray | list, + reason: list | tuple | str = "USER", + verbose: bool | str | int | None = None, + ) -> Self: """Drop epochs based on indices or boolean mask. .. note:: The indices refer to the current set of undropped epochs @@ -1578,7 +1624,7 @@ def drop(self, indices, reason="USER", verbose=None): Parameters ---------- - indices : array of int or bool + indices : array-like of int | array-like of bool Set epochs to remove by specifying indices to remove or a boolean mask to apply (where True values get removed). Events are correspondingly modified. @@ -1928,15 +1974,15 @@ def _detrend_picks(self): @verbose def get_data( self, - picks=None, - item=None, - units=None, - tmin=None, - tmax=None, + picks: str | np.ndarray | slice | None = None, + item: slice | np.ndarray | str | list | None = None, + units: str | dict | None = None, + tmin: int | float | None = None, + tmax: int | float | None = None, *, - copy=True, - verbose=None, - ): + copy: bool = True, + verbose: bool | str | int | None = None, + ) -> np.ndarray: """Get all epochs as a 3D array. Parameters @@ -1992,14 +2038,14 @@ def get_data( @verbose def apply_function( self, - fun, - picks=None, - dtype=None, - n_jobs=None, - channel_wise=True, - verbose=None, + fun: Callable, + picks: str | np.ndarray | slice | None = None, + dtype: np.dtype | None = None, + n_jobs: int | None = None, + channel_wise: bool = True, + verbose: bool | str | int | None = None, **kwargs, - ): + ) -> Self: """Apply a function to a subset of channels. %(applyfun_summary_epochs)s @@ -2091,7 +2137,7 @@ def filename(self) -> Path | None: return self._filename @filename.setter - def filename(self, value): + def filename(self, value: Path | str | None) -> None: if value is not None: value = _check_fname(value, overwrite="read", must_exist=True) self._filename = value @@ -2162,7 +2208,13 @@ def _repr_html_(self): return t @verbose - def crop(self, tmin=None, tmax=None, include_tmax=True, verbose=None) -> Self: + def crop( + self, + tmin: float | None = None, + tmax: float | None = None, + include_tmax: bool = True, + verbose: bool | str | int | None = None, + ) -> Self: """Crop a time interval from the epochs. Parameters @@ -2230,13 +2282,13 @@ def __deepcopy__(self, memodict): @verbose def save( self, - fname, - split_size="2GB", - fmt="single", - overwrite=False, - split_naming="neuromag", - verbose=None, - ): + fname: Path | str, + split_size: str | int = "2GB", + fmt: str = "single", + overwrite: bool = False, + split_naming: Literal["neuromag", "bids"] = "neuromag", + verbose: bool | str | int | None = None, + ) -> list[Path]: """Save epochs in a fif file. Parameters @@ -2391,7 +2443,14 @@ def save( return split_fnames @verbose - def export(self, fname, fmt="auto", *, overwrite=False, verbose=None): + def export( + self, + fname: str, + fmt: Literal["auto", "eeglab"] = "auto", + *, + overwrite: bool = False, + verbose: bool | str | int | None = None, + ) -> None: """Export Epochs to external formats. %(export_fmt_support_epochs)s @@ -2420,8 +2479,12 @@ def export(self, fname, fmt="auto", *, overwrite=False, verbose=None): @fill_doc def equalize_event_counts( - self, event_ids=None, method="mintime", *, random_state=None - ): + self, + event_ids: list | dict | None = None, + method: Literal["truncate", "mintime", "random"] = "mintime", + *, + random_state: int | RandomState | None = None, + ) -> tuple: """Equalize the number of trials in each condition. It tries to make the remaining epochs occurring as close as possible in @@ -2576,20 +2639,20 @@ def equalize_event_counts( @verbose def compute_psd( self, - method="multitaper", - fmin=0, - fmax=np.inf, - tmin=None, - tmax=None, - picks=None, - proj=False, - remove_dc=True, - exclude=(), + method: Literal["welch", "multitaper"] = "multitaper", + fmin: float = 0, + fmax: float = np.inf, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + proj: bool = False, + remove_dc: bool = True, + exclude: list[str] | tuple[str, ...] | Literal["bads"] = (), *, - n_jobs=1, - verbose=None, + n_jobs: int | None = 1, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> EpochsSpectrum: """Perform spectral analysis on sensor data. Parameters @@ -2641,21 +2704,21 @@ def compute_psd( @verbose def compute_tfr( self, - method, - freqs, + method: Literal["morlet", "multitaper", "stockwell"] | None, + freqs: np.ndarray | Literal["auto"] | None, *, - tmin=None, - tmax=None, - picks=None, - proj=False, - output="power", - average=False, - return_itc=False, - decim=1, - n_jobs=None, - verbose=None, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + proj: bool = False, + output: str = "power", + average: bool = False, + return_itc: bool = False, + decim: int | slice = 1, + n_jobs: int | None = None, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> EpochsTFR | AverageTFR | tuple: """Compute a time-frequency representation of epoched data. Parameters @@ -2774,31 +2837,31 @@ def compute_tfr( @verbose def plot_psd( self, - fmin=0, - fmax=np.inf, - tmin=None, - tmax=None, - picks=None, - proj=False, + fmin: float = 0, + fmax: float = np.inf, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + proj: bool = False, *, - method="auto", - average=False, - dB=True, - estimate="power", - xscale="linear", - area_mode="std", - area_alpha=0.33, - color="black", - line_alpha=None, - spatial_colors=True, - sphere=None, - exclude="bads", - ax=None, - show=True, - n_jobs=1, - verbose=None, + method: Literal["welch", "multitaper", "auto"] = "auto", + average: bool = False, + dB: bool = True, + estimate: str = "power", + xscale: Literal["linear", "log"] = "linear", + area_mode: str | None = "std", + area_alpha: float = 0.33, + color: Color = "black", + line_alpha: float | None = None, + spatial_colors: bool = True, + sphere: float | np.ndarray | ConductorModel | str | list[str] | None = None, + exclude: list[str] | Literal["bads"] = "bads", + ax: "Axes | list[Axes] | None" = None, + show: bool = True, + n_jobs: int | None = 1, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> "Figure": """%(plot_psd_doc)s. Parameters @@ -2871,15 +2934,15 @@ def plot_psd( @verbose def to_data_frame( self, - picks=None, - index=None, - scalings=None, - copy=True, - long_format=False, - time_format=None, + picks: str | np.ndarray | slice | None = None, + index: str | list[str] | None = None, + scalings: dict | None = None, + copy: bool = True, + long_format: bool = False, + time_format: str | None = None, *, - verbose=None, - ): + verbose: bool | str | int | None = None, + ) -> "DataFrame": """Export data in tabular structure as a pandas DataFrame. Channels are converted to columns in the DataFrame. By default, @@ -2944,7 +3007,7 @@ def to_data_frame( ) return df - def as_type(self, ch_type="grad", mode="fast"): + def as_type(self, ch_type: str = "grad", mode: str = "fast") -> "EpochsArray": """Compute virtual epochs using interpolated fields. .. Warning:: Using virtual epochs to compute inverse can yield @@ -3007,15 +3070,15 @@ def _drop_log_stats(drop_log, ignore=("IGNORED",)): def make_metadata( - events, - event_id, - tmin, - tmax, - sfreq, - row_events=None, - keep_first=None, - keep_last=None, -): + events: np.ndarray, + event_id: dict, + tmin: float | str | list[str] | None, + tmax: float | str | list[str] | None, + sfreq: float, + row_events: list[str] | str | None = None, + keep_first: str | list[str] | None = None, + keep_last: list[str] | None = None, +) -> tuple: """Automatically generate metadata for use with `mne.Epochs` from events. This function mimics the epoching process (it constructs time windows @@ -3249,12 +3312,12 @@ def _diff_input_strings_vs_event_id(input_strings, input_name, event_id): # This follows the approach taken in mne.Epochs # For strings and None, we don't know the start and stop samples in advance as the # time window can vary. - if isinstance(tmin, type(None) | list): + if isinstance(tmin, str | list | type(None)): start_sample = None else: start_sample = int(round(tmin * sfreq)) - if isinstance(tmax, type(None) | list): + if isinstance(tmax, str | list | type(None)): stop_sample = None else: stop_sample = int(round(tmax * sfreq)) + 1 @@ -3599,28 +3662,28 @@ class Epochs(BaseEpochs): @verbose def __init__( self, - raw, - events=None, - event_id=None, - tmin=-0.2, - tmax=0.5, - baseline=(None, 0), - picks=None, - preload=False, - reject=None, - flat=None, - proj=True, - decim=1, - reject_tmin=None, - reject_tmax=None, - detrend=None, - on_missing="raise", - reject_by_annotation=True, - metadata=None, - event_repeated="error", + raw: "BaseRaw", + events: np.ndarray | None = None, + event_id: int | list[int] | dict | str | list[str] | None = None, + tmin: float = -0.2, + tmax: float = 0.5, + baseline: tuple[float | None, float | None] | None = (None, 0), + picks: str | np.ndarray | slice | None = None, + preload: bool = False, + reject: dict | None = None, + flat: dict | None = None, + proj: bool | Literal["delayed"] = True, + decim: int = 1, + reject_tmin: float | None = None, + reject_tmax: float | None = None, + detrend: int | None = None, + on_missing: Literal["raise", "warn", "ignore"] = "raise", + reject_by_annotation: bool = True, + metadata: "DataFrame | None" = None, + event_repeated: str = "error", *, - on_outside="warn", - verbose=None, + on_outside: Literal["warn", "raise", "ignore"] = "warn", + verbose: bool | str | int | None = None, ): from .io import BaseRaw @@ -3703,6 +3766,7 @@ def _get_epoch_from_raw(self, idx, verbose=None): "Please report this to the mne-python " "developers." ) + assert not isinstance(self._raw, list) # a single Raw, unlike EpochsFIF sfreq = self._raw.info["sfreq"] event_samp = self.events[idx, 0] # Read a data segment from "start" to "stop" in samples @@ -3797,25 +3861,25 @@ class EpochsArray(BaseEpochs): @verbose def __init__( self, - data, - info, - events=None, - tmin=0.0, - event_id=None, - reject=None, - flat=None, - reject_tmin=None, - reject_tmax=None, - baseline=None, - proj=True, - on_missing="raise", - metadata=None, - selection=None, + data: np.ndarray, + info: Info, + events: np.ndarray | None = None, + tmin: float = 0.0, + event_id: int | list[int] | dict | str | list[str] | None = None, + reject: dict | None = None, + flat: dict | None = None, + reject_tmin: float | None = None, + reject_tmax: float | None = None, + baseline: tuple[float | None, float | None] | None = None, + proj: bool | Literal["delayed"] = True, + on_missing: Literal["raise", "warn", "ignore"] = "raise", + metadata: "DataFrame | None" = None, + selection: Iterable | None = None, *, - on_outside="warn", - drop_log=None, - raw_sfreq=None, - verbose=None, + on_outside: Literal["warn", "raise", "ignore"] = "warn", + drop_log: tuple | None = None, + raw_sfreq: float | None = None, + verbose: bool | str | int | None = None, ): dtype = np.complex128 if np.any(np.iscomplex(data)) else np.float64 data = np.asanyarray(data, dtype=dtype) @@ -3869,16 +3933,21 @@ def __init__( self.drop_bad() -def combine_event_ids(epochs, old_event_ids, new_event_id, copy=True): +def combine_event_ids( + epochs: BaseEpochs, + old_event_ids: str | list, + new_event_id: dict | int, + copy: bool = True, +) -> BaseEpochs: """Collapse event_ids from an epochs instance into a new event_id. Parameters ---------- epochs : instance of Epochs The epochs to operate on. - old_event_ids : str, or list + old_event_ids : str | list Conditions to collapse together. - new_event_id : dict, or int + new_event_id : dict | int A one-element dict (or a single integer) for the new condition. Note that for safety, this cannot be any existing id (in epochs.event_id.values()). @@ -3900,7 +3969,7 @@ def combine_event_ids(epochs, old_event_ids, new_event_id, copy=True): 'Left' and 'Right' (combining their trials). """ epochs = epochs.copy() if copy else epochs - old_event_ids = np.asanyarray(old_event_ids) + old_event_id_arr = np.asanyarray(old_event_ids) if isinstance(new_event_id, int): new_event_id = {str(new_event_id): new_event_id} else: @@ -3914,7 +3983,7 @@ def combine_event_ids(epochs, old_event_ids, new_event_id, copy=True): raise ValueError("new_event_id value must not already exist") # could use .pop() here, but if a latter one doesn't exist, we're # in trouble, so run them all here and pop() later - old_event_nums = np.array([epochs.event_id[key] for key in old_event_ids]) + old_event_nums = np.array([epochs.event_id[key] for key in old_event_id_arr]) # find the ones to replace inds = np.any( epochs.events[:, 2][:, np.newaxis] == old_event_nums[np.newaxis, :], axis=1 @@ -3922,7 +3991,7 @@ def combine_event_ids(epochs, old_event_ids, new_event_id, copy=True): # replace the event numbers in the events list epochs.events[inds, 2] = new_event_num # delete old entries - for key in old_event_ids: + for key in old_event_id_arr: epochs.event_id.pop(key) # add the new entry epochs.event_id.update(new_event_id) @@ -3930,12 +3999,17 @@ def combine_event_ids(epochs, old_event_ids, new_event_id, copy=True): @fill_doc -def equalize_epoch_counts(epochs_list, method="mintime", *, random_state=None): +def equalize_epoch_counts( + epochs_list: list, + method: Literal["truncate", "mintime", "random"] = "mintime", + *, + random_state: int | RandomState | None = None, +) -> None: """Equalize the number of trials in multiple Epochs or EpochsTFR instances. Parameters ---------- - epochs_list : list of Epochs instances + epochs_list : list of Epochs The Epochs instances to equalize trial counts for. %(equalize_events_method)s %(random_state)s Used only if ``method='random'``. @@ -4297,7 +4371,12 @@ def _read_one_epoch_file(f, tree, preload): @verbose -def read_epochs(fname, proj=True, preload=True, verbose=None) -> "EpochsFIF": +def read_epochs( + fname: Path | str | FileLike, + proj: bool | Literal["delayed"] = True, + preload: bool = True, + verbose: bool | str | int | None = None, +) -> "EpochsFIF": """Read epochs from a fif file. Parameters @@ -4752,8 +4831,12 @@ def _concatenate_epochs( @verbose def concatenate_epochs( - epochs_list, add_offset=True, *, on_mismatch="raise", verbose=None -): + epochs_list: list, + add_offset: bool = True, + *, + on_mismatch: Literal["raise", "warn", "ignore"] = "raise", + verbose: bool | str | int | None = None, +) -> EpochsArray: """Concatenate a list of `~mne.Epochs` into one `~mne.Epochs` object. .. note:: Unlike `~mne.concatenate_raws`, this function does **not** @@ -4821,20 +4904,20 @@ def concatenate_epochs( @verbose def average_movements( - epochs, - head_pos=None, - orig_sfreq=None, - picks=None, - origin="auto", - weight_all=True, - int_order=8, - ext_order=3, - destination=None, - ignore_ref=False, - return_mapping=False, - mag_scale=100.0, - verbose=None, -): + epochs: BaseEpochs, + head_pos: np.ndarray | None = None, + orig_sfreq: float | None = None, + picks: str | np.ndarray | slice | None = None, + origin: np.ndarray | str = "auto", + weight_all: bool = True, + int_order: int = 8, + ext_order: int = 3, + destination: "Path | str | np.ndarray | Transform | None" = None, + ignore_ref: bool = False, + return_mapping: bool = False, + mag_scale: float | str = 100.0, + verbose: bool | str | int | None = None, +) -> Evoked | tuple: """Average data using Maxwell filtering, transforming using head positions. Parameters @@ -5027,15 +5110,15 @@ def average_movements( @verbose def make_fixed_length_epochs( - raw, - duration=1.0, - preload=False, - reject_by_annotation=True, - proj=True, - overlap=0.0, - id=1, # noqa: A002 - verbose=None, -): + raw: "BaseRaw", + duration: float = 1.0, + preload: bool = False, + reject_by_annotation: bool = True, + proj: bool | Literal["delayed"] = True, + overlap: float = 0.0, + id: int = 1, # noqa: A002 + verbose: bool | str | int | None = None, +) -> BaseEpochs: """Divide continuous raw data into equal-sized consecutive epochs. Parameters @@ -5044,7 +5127,9 @@ def make_fixed_length_epochs( Raw data to divide into segments. duration : float Duration of each epoch in seconds. Defaults to 1. - %(preload)s + preload : bool + Load all epochs from disk when creating the object or wait before + accessing each epoch (more memory efficient but can be slower). %(reject_by_annotation_epochs)s .. versionadded:: 0.21.0 diff --git a/mne/evoked.py b/mne/evoked.py index 4f932a7f62e..0eff00e0e38 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -2,15 +2,18 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from collections.abc import Callable from copy import deepcopy from inspect import getfullargspec from pathlib import Path +from typing import TYPE_CHECKING, Literal import numpy as np from ._fiff.constants import FIFF from ._fiff.meas_info import ( ContainsMixin, + Info, SetChannelsMixin, _ensure_infos_match, _read_extended_ch_info, @@ -36,7 +39,7 @@ ) from .baseline import _check_baseline, _log_rescale, rescale from .channels.channels import InterpolationMixin, ReferenceMixin, UpdateChannelsMixin -from .channels.layout import _merge_ch_data, _pair_grad_sensors +from .channels.layout import Layout, _merge_ch_data, _pair_grad_sensors from .defaults import _BORDER_DEFAULT, _EXTRAPOLATE_DEFAULT, _INTERPOLATION_DEFAULT from .filter import FilterMixin, _check_fun, detrend from .html_templates import _get_html_template @@ -65,7 +68,7 @@ verbose, warn, ) -from .utils._typing import Self +from .utils._typing import Color, Self from .viz import ( plot_evoked, plot_evoked_field, @@ -76,6 +79,19 @@ from .viz.evoked import plot_evoked_joint, plot_evoked_white from .viz.topomap import _topomap_animation +if TYPE_CHECKING: + # Heavy/optional deps kept out of the runtime import path (see + # mne/tests/test_import_nesting.py); referenced only via string annotations. + from matplotlib.animation import FuncAnimation + from matplotlib.axes import Axes + from matplotlib.colors import Colormap, Normalize + from matplotlib.figure import Figure + from pandas import DataFrame + + from .bem import ConductorModel + from .cov import Covariance + from .viz import Brain, EvokedField, Figure3D + _aspect_dict = { "average": FIFF.FIFFV_ASPECT_AVERAGE, "standard_error": FIFF.FIFFV_ASPECT_STD_ERR, @@ -109,10 +125,10 @@ class Evoked( Parameters ---------- - fname : path-like + fname : path-like | None Name of evoked/average FIF file to load. If None no data is loaded. - condition : int, or str + condition : int | str | None Dataset ID number (int) or comment/name (str). Optional if there is only one data set in file. proj : bool, optional @@ -165,13 +181,13 @@ class Evoked( @verbose def __init__( self, - fname, - condition=None, - proj=True, - kind="average", - allow_maxshield=False, + fname: Path | str | None, + condition: int | str | None = None, + proj: bool = True, + kind: str = "average", + allow_maxshield: bool | str = False, *, - verbose=None, + verbose: bool | str | int | None = None, ): _validate_type(proj, bool, "'proj'") # Read the requested data @@ -205,31 +221,37 @@ def filename(self) -> Path | None: return self._filename @filename.setter - def filename(self, value): + def filename(self, value: Path | str | None) -> None: self._filename = Path(value) if value is not None else value @property - def kind(self): + def kind(self) -> str: """The data kind.""" return _aspect_rev[self._aspect_kind] @kind.setter - def kind(self, kind): + def kind(self, kind: str) -> None: _check_option("kind", kind, list(_aspect_dict.keys())) self._aspect_kind = _aspect_dict[kind] @property - def data(self): + def data(self) -> np.ndarray: """The data matrix.""" return self._data @data.setter - def data(self, data): + def data(self, data: np.ndarray) -> None: """Set the data matrix.""" self._data = data @fill_doc - def get_data(self, picks=None, units=None, tmin=None, tmax=None): + def get_data( + self, + picks: str | np.ndarray | slice | None = None, + units: str | dict | None = None, + tmin: float | None = None, + tmax: float | None = None, + ) -> np.ndarray: """Get evoked data as 2D array. Parameters @@ -268,15 +290,15 @@ def get_data(self, picks=None, units=None, tmin=None, tmax=None): @verbose def apply_function( self, - fun, - picks=None, - dtype=None, - n_jobs=None, - channel_wise=True, + fun: Callable, + picks: str | np.ndarray | slice | None = None, + dtype: np.dtype | None = None, + n_jobs: int | None = None, + channel_wise: bool = True, *, - verbose=None, + verbose: bool | str | int | None = None, **kwargs, - ): + ) -> Self: """Apply a function to a subset of channels. %(applyfun_summary_evoked)s @@ -362,7 +384,12 @@ def apply_function( return self @verbose - def apply_baseline(self, baseline=(None, 0), *, verbose=None): + def apply_baseline( + self, + baseline: tuple[float | None, float | None] | None = (None, 0), + *, + verbose: bool | str | int | None = None, + ) -> Self: """Baseline correct evoked data. Parameters @@ -400,7 +427,13 @@ def apply_baseline(self, baseline=(None, 0), *, verbose=None): return self @verbose - def save(self, fname, *, overwrite=False, verbose=None): + def save( + self, + fname: Path | str, + *, + overwrite: bool = False, + verbose: bool | str | int | None = None, + ) -> None: """Save evoked data to a file. Parameters @@ -423,7 +456,14 @@ def save(self, fname, *, overwrite=False, verbose=None): write_evokeds(fname, self, overwrite=overwrite) @verbose - def export(self, fname, fmt="auto", *, overwrite=False, verbose=None): + def export( + self, + fname: str, + fmt: Literal["auto", "mff"] = "auto", + *, + overwrite: bool = False, + verbose: bool | str | int | None = None, + ) -> None: """Export Evoked to external formats. %(export_fmt_support_evoked)s @@ -483,37 +523,37 @@ def _repr_html_(self): return t @property - def ch_names(self): + def ch_names(self) -> list[str]: """Channel names.""" return self.info["ch_names"] @copy_function_doc_to_method_doc(plot_evoked) def plot( self, - picks=None, - exclude="bads", - unit=True, - show=True, - ylim=None, - xlim="tight", - proj=False, - hline=None, - units=None, - scalings=None, - titles=None, - axes=None, - gfp=False, - window_title=None, - spatial_colors="auto", - zorder="unsorted", - selectable=True, - noise_cov=None, - time_unit="s", - sphere=None, + picks: str | np.ndarray | slice | None = None, + exclude: list[str] | Literal["bads"] = "bads", + unit: bool = True, + show: bool = True, + ylim: dict | None = None, + xlim: Literal["tight"] | tuple | None = "tight", + proj: bool | Literal["interactive", "reconstruct"] = False, + hline: list[float] | None = None, + units: dict | None = None, + scalings: dict | None = None, + titles: dict | None = None, + axes: "Axes | list | None" = None, + gfp: bool | Literal["only"] = False, + window_title: str | None = None, + spatial_colors: bool | Literal["auto"] = "auto", + zorder: str | Callable = "unsorted", + selectable: bool = True, + noise_cov: "Covariance | str | None" = None, + time_unit: str = "s", + sphere: "float | np.ndarray | ConductorModel | str | list[str] | None" = None, *, - highlight=None, - verbose=None, - ): + highlight: np.ndarray | None = None, + verbose: bool | str | int | None = None, + ) -> "Figure": return plot_evoked( self, picks=picks, @@ -543,28 +583,28 @@ def plot( @copy_function_doc_to_method_doc(plot_evoked_image) def plot_image( self, - picks=None, - exclude="bads", - unit=True, - show=True, - clim=None, - xlim="tight", - proj=False, - units=None, - scalings=None, - titles=None, - axes=None, - cmap="RdBu_r", - colorbar=True, - mask=None, - mask_style=None, - mask_cmap="Greys", - mask_alpha=0.25, - time_unit="s", - show_names=None, - group_by=None, - sphere=None, - ): + picks: str | np.ndarray | slice | None = None, + exclude: list[str] | Literal["bads"] = "bads", + unit: bool = True, + show: bool = True, + clim: dict | None = None, + xlim: Literal["tight"] | tuple | None = "tight", + proj: bool | Literal["interactive"] = False, + units: dict | None = None, + scalings: dict | None = None, + titles: dict | None = None, + axes: "Axes | list | dict | None" = None, + cmap: "str | Colormap | tuple" = "RdBu_r", + colorbar: bool = True, + mask: np.ndarray | None = None, + mask_style: Literal["both", "contour", "mask"] | None = None, + mask_cmap: "str | Colormap | tuple" = "Greys", + mask_alpha: float = 0.25, + time_unit: str = "s", + show_names: bool | Literal["auto", "all"] | None = None, + group_by: dict | None = None, + sphere: "float | np.ndarray | ConductorModel | str | list[str] | None" = None, + ) -> "Figure": return plot_evoked_image( self, picks=picks, @@ -593,25 +633,25 @@ def plot_image( @copy_function_doc_to_method_doc(plot_evoked_topo) def plot_topo( self, - layout=None, - layout_scale=0.945, - color=None, - border="none", - ylim=None, - scalings=None, - title=None, - proj=False, - vline=(0.0,), - fig_background=None, - merge_grads=False, - legend=True, - axes=None, - background_color="w", - noise_cov=None, - exclude="bads", - select=False, - show=True, - ): + layout: Layout | None = None, + layout_scale: float = 0.945, + color: list[Color] | Color | None = None, + border: str = "none", + ylim: dict | None = None, + scalings: dict | None = None, + title: str | None = None, + proj: bool | Literal["interactive"] = False, + vline: list[float] | tuple[float, ...] | float | None = (0.0,), + fig_background: np.ndarray | None = None, + merge_grads: bool = False, + legend: bool | int | str | tuple = True, + axes: "Axes | None" = None, + background_color: Color = "w", + noise_cov: "Covariance | str | None" = None, + exclude: list[str] | Literal["bads"] = "bads", + select: bool = False, + show: bool = True, + ) -> "Figure": return plot_evoked_topo( self, layout=layout, @@ -637,37 +677,37 @@ def plot_topo( @copy_function_doc_to_method_doc(plot_evoked_topomap) def plot_topomap( self, - times="auto", + times: float | np.ndarray | Literal["auto", "peaks", "interactive"] = "auto", *, - average=None, - ch_type=None, - scalings=None, - proj=False, - sensors=True, - show_names=False, - mask=None, - mask_params=None, - contours=6, - outlines="head", - sphere=None, - image_interp=_INTERPOLATION_DEFAULT, - extrapolate=_EXTRAPOLATE_DEFAULT, - border=_BORDER_DEFAULT, - res=64, - size=1, - cmap=None, - vlim=(None, None), - cnorm=None, - colorbar=True, - cbar_fmt="%3.1f", - units=None, - axes=None, - time_unit="s", - time_format=None, - nrows=1, - ncols="auto", - show=True, - ): + average: float | np.ndarray | None = None, + ch_type: Literal["mag", "grad", "planar1", "planar2", "eeg"] | None = None, + scalings: dict | float | None = None, + proj: bool | Literal["interactive", "reconstruct"] = False, + sensors: bool | str = True, + show_names: bool | Callable = False, + mask: np.ndarray | None = None, + mask_params: dict | None = None, + contours: int | np.ndarray = 6, + outlines: Literal["head"] | dict | None = "head", + sphere: "float | np.ndarray | ConductorModel | str | list[str] | None" = None, + image_interp: str = _INTERPOLATION_DEFAULT, + extrapolate: str = _EXTRAPOLATE_DEFAULT, + border: float | Literal["mean"] = _BORDER_DEFAULT, + res: int = 64, + size: float = 1, + cmap: "str | Colormap | tuple | Literal['interactive'] | None" = None, + vlim: tuple | Literal["joint"] = (None, None), + cnorm: "Normalize | None" = None, + colorbar: bool = True, + cbar_fmt: str = "%3.1f", + units: dict | str | None = None, + axes: "Axes | list[Axes] | None" = None, + time_unit: str = "s", + time_format: str | None = None, + nrows: int | Literal["auto"] = 1, + ncols: int | Literal["auto"] = "auto", + show: bool = True, + ) -> "Figure": return plot_evoked_topomap( self, times=times, @@ -704,21 +744,21 @@ def plot_topomap( @copy_function_doc_to_method_doc(plot_evoked_field) def plot_field( self, - surf_maps, - time=None, - time_label="t = %0.0f ms", - n_jobs=None, - fig=None, - vmax=None, - n_contours=21, + surf_maps: list, + time: float | None = None, + time_label: str | None = "t = %0.0f ms", + n_jobs: int | None = None, + fig: "Figure3D | Brain | None" = None, + vmax: float | dict | None = None, + n_contours: int = 21, *, - show_density=True, - alpha=None, - interpolation="nearest", - interaction="terrain", - time_viewer="auto", - verbose=None, - ): + show_density: bool = True, + alpha: float | dict | None = None, + interpolation: str | None = "nearest", + interaction: Literal["trackball", "terrain"] = "terrain", + time_viewer: bool | str = "auto", + verbose: bool | str | int | None = None, + ) -> "Figure3D | EvokedField": return plot_evoked_field( self, surf_maps, @@ -739,16 +779,16 @@ def plot_field( @copy_function_doc_to_method_doc(plot_evoked_white) def plot_white( self, - noise_cov, - show=True, - rank=None, - time_unit="s", - sphere=None, - axes=None, + noise_cov: "list | Covariance | Path | str", + show: bool = True, + rank: Literal["info", "full"] | dict | None = None, + time_unit: str = "s", + sphere: "float | np.ndarray | ConductorModel | str | list[str] | None" = None, + axes: list | None = None, *, - spatial_colors="auto", - verbose=None, - ): + spatial_colors: bool | Literal["auto"] = "auto", + verbose: bool | str | int | None = None, + ) -> "Figure": return plot_evoked_white( self, noise_cov=noise_cov, @@ -764,14 +804,14 @@ def plot_white( @copy_function_doc_to_method_doc(plot_evoked_joint) def plot_joint( self, - times="peaks", - title="", - picks=None, - exclude="bads", - show=True, - ts_args=None, - topomap_args=None, - ): + times: float | np.ndarray | Literal["auto", "peaks"] = "peaks", + title: str | None = "", + picks: str | np.ndarray | slice | None = None, + exclude: list[str] | Literal["bads"] = "bads", + show: bool = True, + ts_args: dict | None = None, + topomap_args: dict | None = None, + ) -> "Figure | list": return plot_evoked_joint( self, times=times, @@ -787,40 +827,40 @@ def plot_joint( def animate_topomap( self, *, - times=None, - average=None, - ch_type=None, - scalings=None, - proj=False, - sensors=True, - show_names=False, - mask=None, - mask_params=None, - contours=6, - outlines="head", - sphere=None, - image_interp=_INTERPOLATION_DEFAULT, - extrapolate=_EXTRAPOLATE_DEFAULT, - border=_BORDER_DEFAULT, - res=64, - size=1.0, - cmap=None, - vlim=(None, None), - cnorm=None, - colorbar=True, - cbar_fmt="%3.1f", - units=None, - axes=None, - time_unit="s", - time_format=None, - frame_rate=None, - butterfly=False, - blit=True, - show=True, - vmin=None, - vmax=None, - verbose=None, - ): + times: np.ndarray | None = None, + average: float | np.ndarray | None = None, + ch_type: Literal["mag", "grad", "planar1", "planar2", "eeg"] | None = None, + scalings: dict | float | None = None, + proj: bool | Literal["interactive", "reconstruct"] = False, + sensors: bool | str = True, + show_names: bool | Callable = False, + mask: np.ndarray | None = None, + mask_params: dict | None = None, + contours: int | np.ndarray = 6, + outlines: Literal["head"] | dict | None = "head", + sphere: "float | np.ndarray | ConductorModel | str | list[str] | None" = None, + image_interp: str = _INTERPOLATION_DEFAULT, + extrapolate: str = _EXTRAPOLATE_DEFAULT, + border: float | Literal["mean"] = _BORDER_DEFAULT, + res: int = 64, + size: float = 1.0, + cmap: "str | Colormap | tuple | Literal['interactive'] | None" = None, + vlim: tuple | Literal["joint"] = (None, None), + cnorm: "Normalize | None" = None, + colorbar: bool = True, + cbar_fmt: str = "%3.1f", + units: dict | str | None = None, + axes: "list[Axes] | None" = None, + time_unit: str = "s", + time_format: str | None = None, + frame_rate: int | None = None, + butterfly: bool = False, + blit: bool = True, + show: bool = True, + vmin: float | None = None, + vmax: float | None = None, + verbose: bool | str | int | None = None, + ) -> tuple["Figure", "FuncAnimation"]: """Make animation of evoked data as topomap timeseries. The animation can be paused/resumed with left mouse button. @@ -934,7 +974,7 @@ def animate_topomap( show=show, ) - def as_type(self, ch_type="grad", mode="fast"): + def as_type(self, ch_type: str = "grad", mode: str = "fast") -> "Evoked": """Compute virtual evoked using interpolated fields. .. Warning:: Using virtual evoked to compute inverse can yield @@ -968,7 +1008,9 @@ def as_type(self, ch_type="grad", mode="fast"): return _as_meg_type_inst(self, ch_type=ch_type, mode=mode) @fill_doc - def detrend(self, order=1, picks=None): + def detrend( + self, order: int = 1, picks: str | np.ndarray | slice | None = None + ) -> Self: """Detrend data. This function operates in-place. @@ -1019,16 +1061,16 @@ def __neg__(self): def get_peak( self, - ch_type=None, - tmin=None, - tmax=None, - mode="abs", - time_as_index=False, - merge_grads=False, - return_amplitude=False, + ch_type: str | None = None, + tmin: float | None = None, + tmax: float | None = None, + mode: Literal["pos", "neg", "abs"] = "abs", + time_as_index: bool = False, + merge_grads: bool = False, + return_amplitude: bool = False, *, - strict=True, - ): + strict: bool = True, + ) -> tuple: """Get location and latency of peak amplitude. Parameters @@ -1170,20 +1212,20 @@ def get_peak( @verbose def compute_psd( self, - method="multitaper", - fmin=0, - fmax=np.inf, - tmin=None, - tmax=None, - picks=None, - proj=False, - remove_dc=True, - exclude=(), + method: Literal["welch", "multitaper"] = "multitaper", + fmin: float = 0, + fmax: float = np.inf, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + proj: bool = False, + remove_dc: bool = True, + exclude: list[str] | tuple[str, ...] | Literal["bads"] = (), *, - n_jobs=1, - verbose=None, + n_jobs: int | None = 1, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> Spectrum: """Perform spectral analysis on sensor data. Parameters @@ -1236,19 +1278,19 @@ def compute_psd( @verbose def compute_tfr( self, - method, - freqs, + method: Literal["morlet", "multitaper"] | None, + freqs: np.ndarray | None, *, - tmin=None, - tmax=None, - picks=None, - proj=False, - output="power", - decim=1, - n_jobs=None, - verbose=None, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + proj: bool = False, + output: str = "power", + decim: int | slice = 1, + n_jobs: int | None = None, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> AverageTFR: """Compute a time-frequency representation of evoked data. Parameters @@ -1296,31 +1338,31 @@ def compute_tfr( @verbose def plot_psd( self, - fmin=0, - fmax=np.inf, - tmin=None, - tmax=None, - picks=None, - proj=False, + fmin: float = 0, + fmax: float = np.inf, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + proj: bool = False, *, - method="auto", - average=False, - dB=True, - estimate="power", - xscale="linear", - area_mode="std", - area_alpha=0.33, - color="black", - line_alpha=None, - spatial_colors=True, - sphere=None, - exclude="bads", - ax=None, - show=True, - n_jobs=1, - verbose=None, + method: Literal["welch", "multitaper", "auto"] = "auto", + average: bool = False, + dB: bool = True, + estimate: str = "power", + xscale: Literal["linear", "log"] = "linear", + area_mode: str | None = "std", + area_alpha: float = 0.33, + color: str | tuple = "black", + line_alpha: float | None = None, + spatial_colors: bool = True, + sphere: "float | np.ndarray | ConductorModel | str | list[str] | None" = None, + exclude: list[str] | Literal["bads"] = "bads", + ax: "Axes | list[Axes] | None" = None, + show: bool = True, + n_jobs: int | None = 1, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> "Figure": """%(plot_psd_doc)s. Parameters @@ -1393,15 +1435,15 @@ def plot_psd( @verbose def to_data_frame( self, - picks=None, - index=None, - scalings=None, - copy=True, - long_format=False, - time_format=None, + picks: str | np.ndarray | slice | None = None, + index: Literal["time"] | None = None, + scalings: dict | None = None, + copy: bool = True, + long_format: bool = False, + time_format: str | None = None, *, - verbose=None, - ): + verbose: bool | str | int | None = None, + ) -> "DataFrame": """Export data in tabular structure as a pandas DataFrame. Channels are converted to columns in the DataFrame. By default, @@ -1494,15 +1536,15 @@ class EvokedArray(Evoked): @verbose def __init__( self, - data, - info, - tmin=0.0, - comment="", - nave=1, - kind="average", - baseline=None, + data: np.ndarray, + info: Info, + tmin: float = 0.0, + comment: str | None = "", + nave: int = 1, + kind: str = "average", + baseline: tuple[float | None, float | None] | None = None, *, - verbose=None, + verbose: bool | str | int | None = None, ): dtype = np.complex128 if np.iscomplexobj(data) else np.float64 data = np.asanyarray(data, dtype=dtype) @@ -1619,7 +1661,9 @@ def _check_evokeds_ch_names_times(all_evoked, inplace=False): return all_evoked -def combine_evoked(all_evoked, weights): +def combine_evoked( + all_evoked: list[Evoked], weights: list[float] | Literal["equal", "nave"] +) -> Evoked: """Merge evoked data by weighted addition or subtraction. Each `~mne.Evoked` in ``all_evoked`` should have the same channels and the @@ -1656,13 +1700,13 @@ def combine_evoked(all_evoked, weights): if isinstance(weights, str): _check_option("weights", weights, ["nave", "equal"]) if weights == "nave": - weights = naves / naves.sum() + weights_arr = naves / naves.sum() else: - weights = np.ones_like(naves) / len(naves) + weights_arr = np.ones_like(naves) / len(naves) else: - weights = np.array(weights, float) + weights_arr = np.array(weights, float) - if weights.ndim != 1 or weights.size != len(all_evoked): + if weights_arr.ndim != 1 or weights_arr.size != len(all_evoked): raise ValueError("weights must be the same size as all_evoked") # cf. https://en.wikipedia.org/wiki/Weighted_arithmetic_mean, section on @@ -1675,7 +1719,7 @@ def combine_evoked(all_evoked, weights): # σ² = w₁² / nave₁ + w₂² / nave₂ + ... + wₙ² / naveₙ # # And our resulting nave is the reciprocal of this: - new_nave = 1.0 / np.sum(weights**2 / naves) + new_nave = 1.0 / np.sum(weights_arr**2 / naves) # This general formula is equivalent to formulae in Matti's manual # (pp 128-129), where: # new_nave = sum(naves) when weights='nave' and @@ -1687,11 +1731,11 @@ def combine_evoked(all_evoked, weights): # use union of bad channels bads = list(set(b for e in all_evoked for b in e.info["bads"])) evoked.info["bads"] = bads - evoked.data = sum(w * e.data for w, e in zip(weights, all_evoked)) + evoked.data = sum(w * e.data for w, e in zip(weights_arr, all_evoked)) evoked.nave = new_nave comment = "" - for idx, (w, e) in enumerate(zip(weights, all_evoked)): + for idx, (w, e) in enumerate(zip(weights_arr, all_evoked)): # pick sign sign = "" if w >= 0 else "-" # format weight @@ -1715,13 +1759,13 @@ def combine_evoked(all_evoked, weights): @verbose def read_evokeds( - fname, - condition=None, - baseline=None, - kind="average", - proj=True, - allow_maxshield=False, - verbose=None, + fname: Path | str, + condition: int | str | list[int] | list[str] | None = None, + baseline: tuple[float | None, float | None] | None = None, + kind: str = "average", + proj: bool = True, + allow_maxshield: bool | str = False, + verbose: bool | str | int | None = None, ) -> list[Evoked] | Evoked: """Read evoked dataset(s). @@ -1729,7 +1773,7 @@ def read_evokeds( ---------- fname : path-like The filename, which should end with ``-ave.fif`` or ``-ave.fif.gz``. - condition : int or str | list of int or str | None + condition : int | str | list of int | list of str | None The index or list of indices of the evoked dataset to read. FIF files can contain multiple datasets. If None, all datasets are returned as a list. @@ -1780,15 +1824,18 @@ def read_evokeds( check_fname(fname, "evoked", ("-ave.fif", "-ave.fif.gz", "_ave.fif", "_ave.fif.gz")) logger.info(f"Reading {fname} ...") return_list = True + conditions: range | list if condition is None: evoked_node = _get_evoked_node(fname) - condition = range(len(evoked_node)) + conditions = range(len(evoked_node)) elif not isinstance(condition, list): - condition = [condition] + conditions = [condition] return_list = False + else: + conditions = condition out = [] - for c in condition: + for c in conditions: evoked = Evoked( fname, c, @@ -2013,7 +2060,14 @@ def _read_evoked(fname, condition=None, kind="average", allow_maxshield=False): @verbose -def write_evokeds(fname, evoked, *, on_mismatch="raise", overwrite=False, verbose=None): +def write_evokeds( + fname: Path | str, + evoked: Evoked | list[Evoked], + *, + on_mismatch: Literal["raise", "warn", "ignore"] = "raise", + overwrite: bool = False, + verbose: bool | str | int | None = None, +) -> None: """Write an evoked dataset to a file. Parameters diff --git a/mne/io/_read_raw.py b/mne/io/_read_raw.py index 2cc61e8142e..27cb8577e54 100644 --- a/mne/io/_read_raw.py +++ b/mne/io/_read_raw.py @@ -123,7 +123,13 @@ def split_name_ext(fname): @fill_doc -def read_raw(fname, *, preload=False, verbose=None, **kwargs) -> BaseRaw: +def read_raw( + fname: Path | str, + *, + preload: bool | str = False, + verbose: bool | str | int | None = None, + **kwargs, +) -> BaseRaw: """Read raw file. This function is a convenient wrapper for readers defined in `mne.io`. The diff --git a/mne/io/ant/ant.py b/mne/io/ant/ant.py index c3a2f1e94c1..8d42ec84e4d 100644 --- a/mne/io/ant/ant.py +++ b/mne/io/ant/ant.py @@ -33,7 +33,7 @@ class RawANT(BaseRaw): Parameters ---------- - fname : file-like + fname : path-like Path to the ANT raw file to load. The file should have the extension ``.cnt``. eog : str | None Regex pattern to find EOG channel labels. If None, no EOG channels are @@ -89,7 +89,7 @@ def __init__( impedance_annotation: str, *, encoding: str = "latin-1", - preload: bool | NDArray, + preload: bool | str | NDArray, verbose=None, ) -> None: logger.info("Reading ANT file %s", fname) @@ -299,15 +299,15 @@ def _scale_data(data: NDArray[np.float64], ch_units: list[str]) -> None: @copy_doc(RawANT) def read_raw_ant( - fname, - eog=None, - misc=r"BIP\d+", - bipolars=None, - impedance_annotation="impedance", + fname: Path | str, + eog: str | None = None, + misc: str | None = r"BIP\d+", + bipolars: list[str] | tuple[str, ...] | None = None, + impedance_annotation: str = "impedance", *, encoding: str = "latin-1", - preload=False, - verbose=None, + preload: bool | str = False, + verbose: bool | str | int | None = None, ) -> RawANT: """ Returns diff --git a/mne/io/array/_array.py b/mne/io/array/_array.py index f1c987cb006..ccb3062ca2b 100644 --- a/mne/io/array/_array.py +++ b/mne/io/array/_array.py @@ -4,8 +4,11 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from typing import Literal + import numpy as np +from ..._fiff.meas_info import Info from ...utils import _check_option, _validate_type, fill_doc, logger, verbose from ..base import BaseRaw @@ -51,7 +54,14 @@ class RawArray(BaseRaw): """ @verbose - def __init__(self, data, info, first_samp=0, copy="auto", verbose=None): + def __init__( + self, + data: np.ndarray, + info: Info, + first_samp: int = 0, + copy: Literal["data", "info", "both", "auto"] | None = "auto", + verbose: bool | str | int | None = None, + ): _validate_type(info, "info", "info") _check_option("copy", copy, ("data", "info", "both", "auto", None)) dtype = np.complex128 if np.any(np.iscomplex(data)) else np.float64 diff --git a/mne/io/artemis123/artemis123.py b/mne/io/artemis123/artemis123.py index 1a1c4003e92..bf61fa6c481 100644 --- a/mne/io/artemis123/artemis123.py +++ b/mne/io/artemis123/artemis123.py @@ -5,6 +5,7 @@ import calendar import datetime import os.path as op +from pathlib import Path from typing import Any import numpy as np @@ -22,7 +23,11 @@ @verbose def read_raw_artemis123( - input_fname, preload=False, verbose=None, pos_fname=None, add_head_trans=True + input_fname: Path | str, + preload: bool | str = False, + verbose: bool | str | int | None = None, + pos_fname: Path | str | None = None, + add_head_trans: bool = True, ) -> "RawArtemis123": """Read Artemis123 data as raw object. diff --git a/mne/io/base.py b/mne/io/base.py index 18ccac8f986..bd50991690b 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -5,13 +5,14 @@ import os import shutil from collections import defaultdict +from collections.abc import Callable, Sequence from contextlib import nullcontext from copy import deepcopy from dataclasses import dataclass, field -from datetime import timedelta +from datetime import datetime, timedelta from inspect import getfullargspec from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any, Literal import numpy as np @@ -103,8 +104,21 @@ verbose, warn, ) +from ..utils._typing import Color, Self from ..viz import _RAW_CLIP_DEF, plot_raw +if TYPE_CHECKING: + # Heavy/optional deps kept out of the runtime import path (see + # mne/tests/test_import_nesting.py); referenced only via string annotations. + from matplotlib.figure import Figure + from pandas import DataFrame + + from ..cov import Covariance + + # The optional ``mne_qt_browser`` window subclasses the first-party + # ``BrowserBase``, so alias it to annotate ``.plot()`` returns without the dep. + from ..viz._figure import BrowserBase as MNEQtBrowser + @fill_doc class BaseRaw( @@ -132,11 +146,11 @@ class BaseRaw( on the hard drive (slower, requires less memory). If preload is an ndarray, the data are taken from that array. If False, data are not read until save. - first_samps : iterable - Iterable of the first sample number from each raw file. For unsplit raw + first_samps : sequence + Sequence of the first sample number from each raw file. For unsplit raw files this should be a length-one list or tuple. - last_samps : iterable | None - Iterable of the last sample number from each raw file. For unsplit raw + last_samps : sequence | None + Sequence of the last sample number from each raw file. For unsplit raw files this should be a length-one list or tuple. If None, then preload must be an ndarray. filenames : tuple | None @@ -186,22 +200,23 @@ class BaseRaw( _extra_attributes = () _filenames: list[Path | None] + _data: np.ndarray | None @verbose def __init__( self, - info, - preload=False, - first_samps=(0,), - last_samps=None, - filenames=None, - raw_extras=(None,), - orig_format="double", - dtype=np.float64, - buffer_size_sec=1.0, - orig_units=None, + info: Info, + preload: bool | str | np.ndarray = False, + first_samps: Sequence | np.ndarray = (0,), + last_samps: Sequence | np.ndarray | None = None, + filenames: tuple | list | None = None, + raw_extras: list | tuple | None = (None,), + orig_format: str = "double", + dtype: np.dtype | type | None = np.float64, + buffer_size_sec: float = 1.0, + orig_units: dict | None = None, *, - verbose=None, + verbose: bool | str | int | None = None, ): # wait until the end to preload data, but triage here if isinstance(preload, np.ndarray): @@ -315,7 +330,9 @@ def __init__( self._init_kwargs = _get_argvalues() @verbose - def apply_gradient_compensation(self, grade, verbose=None): + def apply_gradient_compensation( + self, grade: int, verbose: bool | str | int | None = None + ) -> Self: """Apply CTF gradient compensation. .. warning:: The compensation matrices are stored with single @@ -575,7 +592,12 @@ def _check_bad_segment( return self._getitem((picks, slice(start, stop)), return_times=False) @verbose - def load_data(self, *, memmap=None, verbose=None): + def load_data( + self, + *, + memmap: Path | str | None = None, + verbose: bool | str | int | None = None, + ) -> Self: """Load raw data. Parameters @@ -633,7 +655,7 @@ def first_samp(self) -> int: return self._cropped_samp @property - def first_time(self): + def first_time(self) -> float: """The first time point (including first_samp but not meas_date).""" return self._first_time @@ -646,7 +668,12 @@ def last_samp(self) -> int: def _last_time(self): return self.last_samp / float(self.info["sfreq"]) - def time_as_index(self, times, use_rounding=False, origin=None): + def time_as_index( + self, + times: np.ndarray | float | int | list, + use_rounding: bool = False, + origin: datetime | float | int | None = None, + ) -> np.ndarray: """Convert time to indices. Parameters @@ -723,8 +750,13 @@ def filenames(self, value): @verbose def set_annotations( - self, annotations, emit_warning=True, on_missing="raise", *, verbose=None - ): + self, + annotations: Annotations | None, + emit_warning: bool = True, + on_missing: Literal["raise", "warn", "ignore"] = "raise", + *, + verbose: bool | str | int | None = None, + ) -> Self: """Setter for annotations. This setter checks if they are inside the data range. @@ -913,17 +945,17 @@ def __setitem__(self, item, value): @verbose def get_data( self, - picks=None, - start=0, - stop=None, - reject_by_annotation=None, - return_times=False, - units=None, + picks: str | np.ndarray | slice | None = None, + start: int = 0, + stop: int | None = None, + reject_by_annotation: Literal["omit", "NaN"] | None = None, + return_times: bool = False, + units: str | dict | None = None, *, - tmin=None, - tmax=None, - verbose=None, - ): + tmin: int | float | None = None, + tmax: int | float | None = None, + verbose: bool | str | int | None = None, + ) -> np.ndarray | tuple: """Get data in the given range. Parameters @@ -1081,14 +1113,14 @@ def get_data( @verbose def apply_function( self, - fun, - picks=None, - dtype=None, - n_jobs=None, - channel_wise=True, - verbose=None, + fun: Callable, + picks: str | np.ndarray | slice | None = None, + dtype: np.dtype | None = None, + n_jobs: int | None = None, + channel_wise: bool = True, + verbose: bool | str | int | None = None, **kwargs, - ): + ) -> Self: """Apply a function to a subset of channels. %(applyfun_summary_raw)s @@ -1175,22 +1207,25 @@ def apply_function( @copy_doc(FilterMixin.filter) def filter( self, - l_freq, - h_freq, - picks=None, - filter_length="auto", - l_trans_bandwidth="auto", - h_trans_bandwidth="auto", - n_jobs=None, - method="fir", - iir_params=None, - phase="zero", - fir_window="hamming", - fir_design="firwin", - skip_by_annotation=("edge", "bad_acq_skip"), - pad="reflect_limited", - verbose=None, - ): + l_freq: float | None, + h_freq: float | None, + picks: str | np.ndarray | slice | None = None, + filter_length: str | int = "auto", + l_trans_bandwidth: float | str = "auto", + h_trans_bandwidth: float | str = "auto", + n_jobs: int | str | None = None, + method: str = "fir", + iir_params: dict | None = None, + phase: str = "zero", + fir_window: str = "hamming", + fir_design: str = "firwin", + skip_by_annotation: str | list[str] | tuple[str, ...] = ( + "edge", + "bad_acq_skip", + ), + pad: str = "reflect_limited", + verbose: bool | str | int | None = None, + ) -> Self: return super().filter( l_freq, h_freq, @@ -1212,23 +1247,26 @@ def filter( @verbose def notch_filter( self, - freqs, - picks=None, - filter_length="auto", - notch_widths=None, - trans_bandwidth=1.0, - n_jobs=None, - method="fir", - iir_params=None, - mt_bandwidth=None, - p_value=0.05, - phase="zero", - fir_window="hamming", - fir_design="firwin", - pad="reflect_limited", - skip_by_annotation=("edge", "bad_acq_skip"), - verbose=None, - ): + freqs: float | np.ndarray | None, + picks: str | np.ndarray | slice | None = None, + filter_length: str | int = "auto", + notch_widths: float | np.ndarray | None = None, + trans_bandwidth: float = 1.0, + n_jobs: int | str | None = None, + method: str = "fir", + iir_params: dict | None = None, + mt_bandwidth: float | None = None, + p_value: float = 0.05, + phase: str = "zero", + fir_window: str = "hamming", + fir_design: str = "firwin", + pad: str = "reflect_limited", + skip_by_annotation: str | list[str] | tuple[str, ...] = ( + "edge", + "bad_acq_skip", + ), + verbose: bool | str | int | None = None, + ) -> Self: """Notch filter a subset of channels. Parameters @@ -1325,17 +1363,17 @@ def notch_filter( @verbose def resample( self, - sfreq, + sfreq: float, *, - npad="auto", - window="auto", - stim_picks=None, - n_jobs=None, - events=None, - pad="auto", - method="fft", - verbose=None, - ): + npad: int | str = "auto", + window: str | tuple = "auto", + stim_picks: list | None = None, + n_jobs: int | str | None = None, + events: np.ndarray | None = None, + pad: str = "auto", + method: str = "fft", + verbose: bool | str | int | None = None, + ) -> Self | tuple: """Resample all channels. If appropriate, an anti-aliasing filter is applied before resampling. @@ -1473,7 +1511,7 @@ def resample( else: # this will not be I/O efficient, but will be mem efficient for ci in range(len(self.ch_names)): data_chunk = self.get_data( - ci, offsets[ri], offsets[ri + 1], verbose="error" + np.array([ci]), offsets[ri], offsets[ri + 1], verbose="error" )[0] if ci == 0 and ri == 0: new_data = np.empty( @@ -1527,7 +1565,9 @@ def resample( return self, events @verbose - def rescale(self, scalings, *, verbose=None): + def rescale( + self, scalings: int | float | dict, *, verbose: bool | str | int | None = None + ) -> Self: """Rescale channels. .. warning:: @@ -1594,13 +1634,13 @@ def rescale(self, scalings, *, verbose=None): @verbose def crop( self, - tmin=0.0, - tmax=None, - include_tmax=True, + tmin: float = 0.0, + tmax: float | None = None, + include_tmax: bool = True, *, - reset_first_samp=False, - verbose=None, - ): + reset_first_samp: bool = False, + verbose: bool | str | int | None = None, + ) -> Self: """Crop raw data file. Limit the data from the raw file to go between specific times. Note @@ -1728,7 +1768,12 @@ def crop( return self @verbose - def crop_by_annotations(self, annotations=None, *, verbose=None): + def crop_by_annotations( + self, + annotations: Annotations | None = None, + *, + verbose: bool | str | int | None = None, + ) -> list: """Get crops of raw data file for selected annotations. Parameters @@ -1761,19 +1806,19 @@ def crop_by_annotations(self, annotations=None, *, verbose=None): @verbose def save( self, - fname, - picks=None, - tmin=0, - tmax=None, - buffer_size_sec=None, - drop_small_buffer=False, - proj=False, - fmt="single", - overwrite=False, - split_size="2GB", - split_naming="neuromag", - verbose=None, - ): + fname: Path | str, + picks: str | np.ndarray | slice | None = None, + tmin: float = 0, + tmax: float | None = None, + buffer_size_sec: float | None = None, + drop_small_buffer: bool = False, + proj: bool = False, + fmt: Literal["single", "double", "int", "short"] = "single", + overwrite: bool = False, + split_size: str | int = "2GB", + split_naming: Literal["neuromag", "bids"] = "neuromag", + verbose: bool | str | int | None = None, + ) -> list[Path]: """Save raw data to file. Parameters @@ -1920,14 +1965,14 @@ def save( @verbose def export( self, - fname, - fmt="auto", - physical_range="auto", - add_ch_type=False, + fname: str, + fmt: Literal["auto", "brainvision", "edf", "eeglab"] = "auto", + physical_range: str | tuple = "auto", + add_ch_type: bool = False, *, - overwrite=False, - verbose=None, - ): + overwrite: bool = False, + verbose: bool | str | int | None = None, + ) -> None: """Export Raw to external formats. %(export_fmt_support_raw)s @@ -1981,48 +2026,48 @@ def _tmin_tmax_to_start_stop(self, tmin, tmax): @copy_function_doc_to_method_doc(plot_raw) def plot( self, - events=None, - duration=10.0, - start=0.0, - n_channels=20, - bgcolor="w", - color=None, - bad_color="lightgray", - event_color="cyan", + events: np.ndarray | None = None, + duration: float = 10.0, + start: float = 0.0, + n_channels: int = 20, + bgcolor: Color = "w", + color: dict | Color | None = None, + bad_color: Color = "lightgray", + event_color: Color | dict | None = "cyan", *, - annotation_colors=None, - annotation_regex=".*", - scalings=None, - remove_dc=True, - order=None, - show_options=False, - title=None, - show=True, - block=False, - highpass=None, - lowpass=None, - filtorder=4, - clipping=_RAW_CLIP_DEF, - show_first_samp=False, - proj=True, - group_by="type", - butterfly=False, - decim="auto", - noise_cov=None, - event_id=None, - show_scrollbars=True, - show_scalebars=True, - show_zero_line=False, - time_format="float", - precompute=None, - use_opengl=None, - picks=None, - theme=None, - overview_mode=None, - splash=True, - verbose=None, - figure_class=None, - ): + annotation_colors: dict | None = None, + annotation_regex: str = ".*", + scalings: Literal["auto"] | dict | None = None, + remove_dc: bool = True, + order: np.ndarray | None = None, + show_options: bool = False, + title: str | None = None, + show: bool = True, + block: bool = False, + highpass: float | None = None, + lowpass: float | None = None, + filtorder: int = 4, + clipping: str | float | None = _RAW_CLIP_DEF, + show_first_samp: bool = False, + proj: bool = True, + group_by: str = "type", + butterfly: bool = False, + decim: int | Literal["auto"] = "auto", + noise_cov: "Covariance | str | None" = None, + event_id: dict | None = None, + show_scrollbars: bool = True, + show_scalebars: bool = True, + show_zero_line: bool = False, + time_format: Literal["float", "clock"] = "float", + precompute: bool | str | None = None, + use_opengl: bool | None = None, + picks: str | np.ndarray | slice | None = None, + theme: str | Path | None = None, + overview_mode: str | None = None, + splash: bool = True, + verbose: bool | str | int | None = None, + figure_class: type | None = None, + ) -> "Figure | MNEQtBrowser": return plot_raw( self, events, @@ -2068,24 +2113,24 @@ def plot( ) @property - def ch_names(self): + def ch_names(self) -> list[str]: """Channel names.""" return self.info["ch_names"] @property - def times(self): + def times(self) -> np.ndarray: """Time points.""" out = _arange_div(self.n_times, float(self.info["sfreq"])) out.flags["WRITEABLE"] = False return out @property - def n_times(self): + def n_times(self) -> int: """Number of time points.""" return self.last_samp - self.first_samp + 1 @property - def duration(self): + def duration(self) -> float: """Duration of the data in seconds. .. versionadded:: 1.9 @@ -2110,7 +2155,12 @@ def __len__(self): return self.n_times @verbose - def load_bad_channels(self, bad_file=None, force=False, verbose=None): + def load_bad_channels( + self, + bad_file: Path | str | None = None, + force: bool = False, + verbose: bool | str | int | None = None, + ) -> None: """Mark channels as bad from a text file. This function operates mostly in the style of the C function @@ -2156,7 +2206,9 @@ def load_bad_channels(self, bad_file=None, force=False, verbose=None): logger.info(f"No channels updated. Bads are: {prev_bads}") @fill_doc - def append(self, raws, preload=None): + def append( + self, raws: "BaseRaw | list[BaseRaw]", preload: bool | str | None = None + ) -> None: """Concatenate raw instances as if they were continuous. .. note:: Boundaries of the raw files are annotated bad. If you wish to @@ -2171,16 +2223,18 @@ def append(self, raws, preload=None): (in order), or a single raw instance to concatenate. %(preload_concatenate)s """ - if not isinstance(raws, list): - raws = [raws] + if isinstance(raws, BaseRaw): + raw_list: list[BaseRaw] = [raws] + else: + raw_list = raws # make sure the raws are compatible - all_raws = [self] - all_raws += raws + all_raws: list = [self] + all_raws += raw_list _check_raw_compatibility(all_raws) # deal with preloading data first (while files are separate) - all_preloaded = self.preload and all(r.preload for r in raws) + all_preloaded = self.preload and all(r.preload for r in raw_list) if preload is None: if all_preloaded: preload = True @@ -2194,7 +2248,7 @@ def append(self, raws, preload=None): else: # do the concatenation ourselves since preload might be a string nchan = self.info["nchan"] - c_ns = np.cumsum([rr.n_times for rr in ([self] + raws)]) + c_ns = np.cumsum([rr.n_times for rr in ([self] + raw_list)]) nsamp = c_ns[-1] if not self.preload: @@ -2207,13 +2261,13 @@ def append(self, raws, preload=None): _data = _allocate_data(preload, (nchan, nsamp), this_data.dtype) _data[:, 0 : c_ns[0]] = this_data - for ri in range(len(raws)): - if not raws[ri].preload: + for ri in range(len(raw_list)): + if not raw_list[ri].preload: # read the data directly into the buffer data_buffer = _data[:, c_ns[ri] : c_ns[ri + 1]] - raws[ri]._read_segment(data_buffer=data_buffer) + raw_list[ri]._read_segment(data_buffer=data_buffer) else: - _data[:, c_ns[ri] : c_ns[ri + 1]] = raws[ri]._data + _data[:, c_ns[ri] : c_ns[ri + 1]] = raw_list[ri]._data self._data = _data self.preload = True @@ -2221,7 +2275,7 @@ def append(self, raws, preload=None): annotations = self.annotations assert annotations.orig_time == self.info["meas_date"] edge_samps = list() - for ri, r in enumerate(raws): + for ri, r in enumerate(raw_list): edge_samps.append(self.last_samp - self.first_samp + 1) annotations = _combine_annotations( annotations, @@ -2260,7 +2314,7 @@ def append(self, raws, preload=None): ): raise RuntimeError("Append error") # should never happen - def close(self): + def close(self) -> None: """Clean up the object. Does nothing for objects that close their file descriptors. @@ -2268,7 +2322,7 @@ def close(self): """ pass # noqa - def copy(self): + def copy(self) -> Self: """Return copy of the instance. Returns @@ -2309,7 +2363,12 @@ def _get_duration_string(self): minutes, seconds = divmod(remainder, 60) return f"{hours:02.0f}:{minutes:02.0f}:{seconds:02.0f}" - def add_events(self, events, stim_channel=None, replace=False): + def add_events( + self, + events: np.ndarray, + stim_channel: str | None = None, + replace: bool = False, + ) -> None: """Add events to stim channel. Parameters @@ -2364,21 +2423,21 @@ def _get_buffer_size(self, buffer_size_sec=None): @verbose def compute_psd( self, - method="welch", - fmin=0, - fmax=np.inf, - tmin=None, - tmax=None, - picks=None, - exclude=(), - proj=False, - remove_dc=True, - reject_by_annotation=True, + method: Literal["welch", "multitaper"] = "welch", + fmin: float = 0, + fmax: float = np.inf, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + exclude: list[str] | tuple[str, ...] | Literal["bads"] = (), + proj: bool = False, + remove_dc: bool = True, + reject_by_annotation: bool = True, *, - n_jobs=1, - verbose=None, + n_jobs: int | None = 1, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> Spectrum: """Perform spectral analysis on sensor data. Parameters @@ -2434,20 +2493,20 @@ def compute_psd( @verbose def compute_tfr( self, - method, - freqs, + method: Literal["morlet", "multitaper"] | None, + freqs: np.ndarray | None, *, - tmin=None, - tmax=None, - picks=None, - proj=False, - output="power", - reject_by_annotation=True, - decim=1, - n_jobs=None, - verbose=None, + tmin: float | None = None, + tmax: float | None = None, + picks: str | np.ndarray | slice | None = None, + proj: bool = False, + output: str = "power", + reject_by_annotation: bool = True, + decim: int | slice = 1, + n_jobs: int | None = None, + verbose: bool | str | int | None = None, **method_kw, - ): + ) -> RawTFR: """Compute a time-frequency representation of sensor data. Parameters @@ -2497,17 +2556,17 @@ def compute_tfr( @verbose def to_data_frame( self, - picks=None, - index=None, - scalings=None, - copy=True, - start=None, - stop=None, - long_format=False, - time_format=None, + picks: str | np.ndarray | slice | None = None, + index: Literal["time"] | None = None, + scalings: dict | None = None, + copy: bool = True, + start: int | None = None, + stop: int | None = None, + long_format: bool = False, + time_format: str | None = None, *, - verbose=None, - ): + verbose: bool | str | int | None = None, + ) -> "DataFrame": """Export data in tabular structure as a pandas DataFrame. Channels are converted to columns in the DataFrame. By default, an @@ -2573,7 +2632,7 @@ def to_data_frame( ) return df - def describe(self, data_frame=False): + def describe(self, data_frame: bool = False) -> "DataFrame | None": """Describe channels (name, type, descriptive statistics). Parameters @@ -3244,8 +3303,13 @@ def _check_raw_compatibility(raw): @verbose def concatenate_raws( - raws, preload=None, events_list=None, *, on_mismatch="raise", verbose=None -): + raws: list, + preload: bool | str | None = None, + events_list: list | None = None, + *, + on_mismatch: Literal["raise", "warn", "ignore"] = "raise", + verbose: bool | str | int | None = None, +) -> "BaseRaw | tuple": """Concatenate `~mne.io.Raw` instances as if they were continuous. .. note:: If all ``raws`` have the same type, ``raws[0]`` is modified in-place to @@ -3310,7 +3374,7 @@ def concatenate_raws( @fill_doc -def match_channel_orders(insts, copy=True): +def match_channel_orders(insts: list, copy: bool = True) -> list: """Ensure consistent channel order across instances (Raw, Epochs, or Evoked). Parameters diff --git a/mne/io/bci2k/bci2k.py b/mne/io/bci2k/bci2k.py index 39cd709a11a..662a77c543a 100644 --- a/mne/io/bci2k/bci2k.py +++ b/mne/io/bci2k/bci2k.py @@ -4,6 +4,7 @@ import os import re +from pathlib import Path import numpy as np @@ -266,7 +267,11 @@ def __init__(self, input_fname, preload=False, verbose=None): ) -def read_raw_bci2k(input_fname, preload=False, verbose=None): +def read_raw_bci2k( + input_fname: Path | str, + preload: bool = False, + verbose: bool | str | int | None = None, +) -> "RawBCI2k": """Read a BCI2000 ``.dat`` file. Parameters diff --git a/mne/io/besa/besa.py b/mne/io/besa/besa.py index 2d3cb75cb86..921de64124a 100644 --- a/mne/io/besa/besa.py +++ b/mne/io/besa/besa.py @@ -8,13 +8,15 @@ import numpy as np from ..._fiff.meas_info import create_info -from ...evoked import EvokedArray +from ...evoked import Evoked, EvokedArray from ...utils import fill_doc, logger, verbose @fill_doc @verbose -def read_evoked_besa(fname, verbose=None): +def read_evoked_besa( + fname: Path | str, verbose: bool | str | int | None = None +) -> Evoked: """Reader function for BESA ``.avr`` or ``.mul`` files. When a ``.elp`` sidecar file is present, it will be used to determine diff --git a/mne/io/boxy/boxy.py b/mne/io/boxy/boxy.py index 26e301a61b8..4b26640b08a 100644 --- a/mne/io/boxy/boxy.py +++ b/mne/io/boxy/boxy.py @@ -3,6 +3,7 @@ # Copyright the MNE-Python contributors. import re as re +from pathlib import Path from typing import Any import numpy as np @@ -15,7 +16,11 @@ @fill_doc -def read_raw_boxy(fname, preload=False, verbose=None) -> "RawBOXY": +def read_raw_boxy( + fname: Path | str, + preload: bool | str = False, + verbose: bool | str | int | None = None, +) -> "RawBOXY": """Reader for an optical imaging recording. This function has been tested using the ISS Imagent I and II systems diff --git a/mne/io/brainvision/brainvision.py b/mne/io/brainvision/brainvision.py index de06e7aeaab..95714717975 100644 --- a/mne/io/brainvision/brainvision.py +++ b/mne/io/brainvision/brainvision.py @@ -10,6 +10,8 @@ import re from datetime import datetime, timezone from io import StringIO +from pathlib import Path +from typing import Literal import numpy as np @@ -1141,14 +1143,14 @@ def _get_hdr_info(hdr_fname, eog, misc, scale, overrides=None): @fill_doc def read_raw_brainvision( - vhdr_fname, - eog=("HEOGL", "HEOGR", "VEOGb"), - misc="auto", - scale=1.0, - ignore_marker_types=False, - overrides=None, - preload=False, - verbose=None, + vhdr_fname: Path | str, + eog: list | tuple = ("HEOGL", "HEOGR", "VEOGb"), + misc: list | tuple | Literal["auto"] = "auto", + scale: float = 1.0, + ignore_marker_types: bool = False, + overrides: dict | None = None, + preload: bool | str = False, + verbose: bool | str | int | None = None, ) -> RawBrainVision: """Reader for Brain Vision EEG file. diff --git a/mne/io/bti/bti.py b/mne/io/bti/bti.py index d74cdd19fb5..03408ede77b 100644 --- a/mne/io/bti/bti.py +++ b/mne/io/bti/bti.py @@ -6,6 +6,7 @@ import os.path as op from io import BytesIO from itertools import count +from pathlib import Path from typing import Any import numpy as np @@ -1333,18 +1334,18 @@ def _get_bti_info( @verbose def read_raw_bti( - pdf_fname, - config_fname="config", - head_shape_fname="hs_file", - rotation_x=0.0, - translation=(0.0, 0.02, 0.11), - convert=True, - rename_channels=True, - sort_by_ch_name=True, - ecg_ch="E31", - eog_ch=("E63", "E64"), - preload=False, - verbose=None, + pdf_fname: Path | str, + config_fname: Path | str = "config", + head_shape_fname: Path | str | None = "hs_file", + rotation_x: float = 0.0, + translation: np.ndarray | tuple | list = (0.0, 0.02, 0.11), + convert: bool = True, + rename_channels: bool = True, + sort_by_ch_name: bool = True, + ecg_ch: str | None = "E31", + eog_ch: tuple[str, ...] | None = ("E63", "E64"), + preload: bool | str = False, + verbose: bool | str | int | None = None, ) -> RawBTi: """Raw object from 4D Neuroimaging MagnesWH3600 data. diff --git a/mne/io/cnt/cnt.py b/mne/io/cnt/cnt.py index 5bc2506c7d3..03c90158438 100644 --- a/mne/io/cnt/cnt.py +++ b/mne/io/cnt/cnt.py @@ -4,6 +4,9 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from pathlib import Path +from typing import Literal + import numpy as np from ..._fiff._digitization import _make_dig_points @@ -167,18 +170,18 @@ def _update_bad_span_onset(accept_reject, onset, duration, description): @verbose def read_raw_cnt( - input_fname, - eog=(), - misc=(), - ecg=(), - emg=(), + input_fname: Path | str, + eog: list | tuple | Literal["auto", "header"] = (), + misc: list | tuple = (), + ecg: list | tuple | Literal["auto"] = (), + emg: list | tuple = (), *, - data_format="auto", - date_format="mm/dd/yy", - recompute_n_samples=None, - header="auto", - preload=False, - verbose=None, + data_format: Literal["auto", "int16", "int32"] = "auto", + date_format: Literal["mm/dd/yy", "dd/mm/yy"] = "mm/dd/yy", + recompute_n_samples: bool | None = None, + header: Literal["auto", "new", "old"] = "auto", + preload: bool | str = False, + verbose: bool | str | int | None = None, ) -> "RawCNT": """Read CNT data as raw object. diff --git a/mne/io/ctf/ctf.py b/mne/io/ctf/ctf.py index d8b2c96e1bd..3c280dc0c24 100644 --- a/mne/io/ctf/ctf.py +++ b/mne/io/ctf/ctf.py @@ -5,6 +5,7 @@ # Copyright the MNE-Python contributors. import os +from pathlib import Path import numpy as np @@ -31,7 +32,11 @@ @fill_doc def read_raw_ctf( - directory, system_clock="truncate", preload=False, clean_names=False, verbose=None + directory: Path | str, + system_clock: str = "truncate", + preload: bool | str = False, + clean_names: bool = False, + verbose: bool | str | int | None = None, ) -> "RawCTF": """Raw object from CTF directory. diff --git a/mne/io/curry/curry.py b/mne/io/curry/curry.py index 4d023a4ad69..956f60b6cc9 100644 --- a/mne/io/curry/curry.py +++ b/mne/io/curry/curry.py @@ -701,7 +701,10 @@ def _make_trans_dig( @verbose def read_raw_curry( - fname, preload=False, on_bad_hpi_match="warn", verbose=None + fname: Path | str, + preload: bool | str = False, + on_bad_hpi_match: str = "warn", + verbose: bool | str | int | None = None, ) -> "RawCurry": """Read raw data from Curry files. diff --git a/mne/io/edf/edf.py b/mne/io/edf/edf.py index 960f90d2134..89c044e3e21 100644 --- a/mne/io/edf/edf.py +++ b/mne/io/edf/edf.py @@ -9,7 +9,7 @@ from datetime import date, datetime, timedelta, timezone from enum import Enum from pathlib import Path -from typing import Any +from typing import Any, Literal import numpy as np from scipy.interpolate import interp1d @@ -29,6 +29,7 @@ verbose, warn, ) +from ...utils._typing import FileLike from ..base import BaseRaw, _get_scaling from ._open import _gdf_edf_get_fid @@ -1889,19 +1890,19 @@ def _check_args(input_fname, preload, target_ext): @fill_doc def read_raw_edf( - input_fname, - eog=None, - misc=None, - stim_channel="auto", - exclude=(), - infer_types=False, - include=None, - preload=False, - units=None, - encoding="utf8", - exclude_after_unique=False, + input_fname: Path | str | FileLike, + eog: list | tuple | None = None, + misc: list | tuple | None = None, + stim_channel: Literal["auto"] | str | list | int = "auto", + exclude: list[str] | str | tuple[str, ...] = (), + infer_types: bool = False, + include: list[str] | str | None = None, + preload: bool | str = False, + units: dict | str | None = None, + encoding: str = "utf8", + exclude_after_unique: bool = False, *, - verbose=None, + verbose: bool | str | int | None = None, ) -> RawEDF: """Reader function for EDF and EDF+ files. @@ -2028,19 +2029,19 @@ def read_raw_edf( @fill_doc def read_raw_bdf( - input_fname, - eog=None, - misc=None, - stim_channel="auto", - exclude=(), - infer_types=False, - include=None, - preload=False, - units=None, - encoding="utf8", - exclude_after_unique=False, + input_fname: Path | str | FileLike, + eog: list | tuple | None = None, + misc: list | tuple | None = None, + stim_channel: Literal["auto"] | str | list | int = "auto", + exclude: list[str] | str | tuple[str, ...] = (), + infer_types: bool = False, + include: list[str] | str | None = None, + preload: bool | str = False, + units: dict | str | None = None, + encoding: str = "utf8", + exclude_after_unique: bool = False, *, - verbose=None, + verbose: bool | str | int | None = None, ) -> RawBDF: """Reader function for BDF files. @@ -2167,14 +2168,14 @@ def read_raw_bdf( @fill_doc def read_raw_gdf( - input_fname, - eog=None, - misc=None, - stim_channel="auto", - exclude=(), - include=None, - preload=False, - verbose=None, + input_fname: Path | str | FileLike, + eog: list | tuple | None = None, + misc: list | tuple | None = None, + stim_channel: Literal["auto"] | str | list | int = "auto", + exclude: list[str] | str | tuple[str, ...] = (), + include: list[str] | str | None = None, + preload: bool | str = False, + verbose: bool | str | int | None = None, ) -> RawGDF: """Reader function for GDF files. diff --git a/mne/io/eeglab/eeglab.py b/mne/io/eeglab/eeglab.py index c9f3fb89e67..d0b21a1e7d9 100644 --- a/mne/io/eeglab/eeglab.py +++ b/mne/io/eeglab/eeglab.py @@ -5,6 +5,7 @@ import os.path as op from os import PathLike from pathlib import Path +from typing import Literal import numpy as np @@ -287,12 +288,12 @@ def _handle_montage_units(montage_units, mean_radius): @fill_doc def read_raw_eeglab( - input_fname, - eog=(), - preload=False, - uint16_codec=None, - montage_units="auto", - verbose=None, + input_fname: Path | str, + eog: list | tuple | Literal["auto"] = (), + preload: bool | str = False, + uint16_codec: str | None = None, + montage_units: str = "auto", + verbose: bool | str | int | None = None, ) -> "RawEEGLAB": r"""Read an EEGLAB .set file. @@ -339,14 +340,14 @@ def read_raw_eeglab( @fill_doc def read_epochs_eeglab( - input_fname, - events=None, - event_id=None, - eog=(), + input_fname: Path | str, + events: Path | str | np.ndarray | None = None, + event_id: int | list[int] | dict | None = None, + eog: list | tuple | Literal["auto"] = (), *, - uint16_codec=None, - montage_units="auto", - verbose=None, + uint16_codec: str | None = None, + montage_units: str = "auto", + verbose: bool | str | int | None = None, ) -> "EpochsEEGLAB": r"""Reader function for EEGLAB epochs files. diff --git a/mne/io/egi/egi.py b/mne/io/egi/egi.py index 73e93d39460..21bfc318a33 100644 --- a/mne/io/egi/egi.py +++ b/mne/io/egi/egi.py @@ -4,6 +4,7 @@ import datetime import time +from pathlib import Path import numpy as np @@ -92,16 +93,16 @@ def _read_events(fid, info): @verbose def read_raw_egi( - input_fname, - eog=None, - misc=None, - include=None, - exclude=None, - preload=False, - channel_naming="E%d", + input_fname: Path | str, + eog: list | tuple | None = None, + misc: list | tuple | None = None, + include: list | None = None, + exclude: list | None = None, + preload: bool | str = False, + channel_naming: str = "E%d", *, - events_as_annotations=True, - verbose=None, + events_as_annotations: bool = True, + verbose: bool | str | int | None = None, ) -> "RawEGI": """Read EGI simple binary as raw object. diff --git a/mne/io/egi/egimff.py b/mne/io/egi/egimff.py index 7a5f8df91e8..f875d021308 100644 --- a/mne/io/egi/egimff.py +++ b/mne/io/egi/egimff.py @@ -672,15 +672,19 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult): @verbose def read_evokeds_mff( - fname, condition=None, channel_naming="E%d", baseline=None, verbose=None -): + fname: Path | str, + condition: int | str | list[int] | list[str] | None = None, + channel_naming: str = "E%d", + baseline: tuple[float | None, float | None] | None = None, + verbose: bool | str | int | None = None, +) -> "EvokedArray | list[EvokedArray]": """Read averaged MFF file as EvokedArray or list of EvokedArray. Parameters ---------- fname : path-like File path to averaged MFF file. Should end in ``.mff``. - condition : int or str | list of int or str | None + condition : int | str | list of int | list of str | None The index (indices) or category (categories) from which to read in data. Averaged MFF files can contain separate averages for different categories. These can be indexed by the block number or the category @@ -746,18 +750,21 @@ def read_evokeds_mff( f"{fname} may not be an averaged MFF file." ) return_list = True + conditions: list if condition is None: categories = mff.categories.categories - condition = list(categories.keys()) + conditions = list(categories.keys()) elif not isinstance(condition, list): - condition = [condition] + conditions = [condition] return_list = False - logger.info(f"Reading {len(condition)} evoked datasets from {fname} ...") + else: + conditions = condition + logger.info(f"Reading {len(conditions)} evoked datasets from {fname} ...") output = [ _read_evoked_mff( fname, c, channel_naming=channel_naming, verbose=verbose ).apply_baseline(baseline) - for c in condition + for c in conditions ] return output if return_list else output[0] diff --git a/mne/io/eximia/eximia.py b/mne/io/eximia/eximia.py index 5d21879de32..dc5eec31532 100644 --- a/mne/io/eximia/eximia.py +++ b/mne/io/eximia/eximia.py @@ -3,6 +3,7 @@ # Copyright the MNE-Python contributors. import os.path as op +from pathlib import Path from ..._fiff.meas_info import create_info from ..._fiff.utils import _file_size, _read_segments_file @@ -11,7 +12,11 @@ @fill_doc -def read_raw_eximia(fname, preload=False, verbose=None) -> "RawEximia": +def read_raw_eximia( + fname: Path | str, + preload: bool | str = False, + verbose: bool | str | int | None = None, +) -> "RawEximia": """Reader for an eXimia EEG file. Parameters diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 192a5555465..2e4a689e30b 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -18,13 +18,13 @@ @fill_doc def read_raw_eyelink( - fname, + fname: Path | str, *, - create_annotations=True, - apply_offsets=False, - find_overlaps=False, - overlap_threshold=0.05, - verbose=None, + create_annotations: bool | list = True, + apply_offsets: bool = False, + find_overlaps: bool = False, + overlap_threshold: float = 0.05, + verbose: bool | str | int | None = None, ) -> "RawEyelink": """Reader for an Eyelink ``.asc`` file. diff --git a/mne/io/fieldtrip/fieldtrip.py b/mne/io/fieldtrip/fieldtrip.py index c8521722003..6133fae4db6 100644 --- a/mne/io/fieldtrip/fieldtrip.py +++ b/mne/io/fieldtrip/fieldtrip.py @@ -2,6 +2,8 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from pathlib import Path + import numpy as np from ...epochs import EpochsArray @@ -17,7 +19,9 @@ ) -def read_raw_fieldtrip(fname, info, data_name="data") -> RawArray: +def read_raw_fieldtrip( + fname: Path | str, info: dict | None, data_name: str = "data" +) -> RawArray: """Load continuous (raw) data from a FieldTrip preprocessing structure. This function expects to find single trial raw data (FT_DATATYPE_RAW) in @@ -81,7 +85,10 @@ def read_raw_fieldtrip(fname, info, data_name="data") -> RawArray: def read_epochs_fieldtrip( - fname, info, data_name="data", trialinfo_column=0 + fname: Path | str, + info: dict | None, + data_name: str = "data", + trialinfo_column: int = 0, ) -> EpochsArray: """Load epoched data from a FieldTrip preprocessing structure. @@ -140,7 +147,12 @@ def read_epochs_fieldtrip( return epochs -def read_evoked_fieldtrip(fname, info, comment=None, data_name="data"): +def read_evoked_fieldtrip( + fname: Path | str, + info: dict | None, + comment: str | None = None, + data_name: str = "data", +) -> "EvokedArray": """Load evoked data from a FieldTrip timelocked structure. This function expects to find timelocked data in the structure data_name is diff --git a/mne/io/fiff/raw.py b/mne/io/fiff/raw.py index 480a312a210..8e76d16b3d6 100644 --- a/mne/io/fiff/raw.py +++ b/mne/io/fiff/raw.py @@ -29,6 +29,7 @@ verbose, warn, ) +from ...utils._typing import FileLike, Self from ..base import ( BaseRaw, _check_maxshield, @@ -92,11 +93,11 @@ class Raw(BaseRaw): @verbose def __init__( self, - fname, - allow_maxshield=False, - preload=False, - on_split_missing="raise", - verbose=None, + fname: Path | str | FileLike | None, + allow_maxshield: bool | str = False, + preload: bool | str = False, + on_split_missing: str = "raise", + verbose: bool | str | int | None = None, ): raws = [] do_check_ext = not _file_like(fname) @@ -449,7 +450,7 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult): ) assert offset == stop - start - def fix_mag_coil_types(self): + def fix_mag_coil_types(self) -> Self: """Fix Elekta magnetometer coil types. Returns @@ -501,7 +502,11 @@ def _check_entry(first, nent): @fill_doc def read_raw_fif( - fname, allow_maxshield=False, preload=False, on_split_missing="raise", verbose=None + fname: Path | str | FileLike, + allow_maxshield: bool | str = False, + preload: bool | str = False, + on_split_missing: str = "raise", + verbose: bool | str | int | None = None, ) -> Raw: """Reader function for Raw FIF data. diff --git a/mne/io/fil/fil.py b/mne/io/fil/fil.py index a7dd157049a..0f5abace580 100644 --- a/mne/io/fil/fil.py +++ b/mne/io/fil/fil.py @@ -4,6 +4,7 @@ import json import pathlib +from pathlib import Path import numpy as np @@ -25,7 +26,11 @@ @verbose def read_raw_fil( - binfile, precision="single", preload=False, *, verbose=None + binfile: Path | str, + precision: str = "single", + preload: bool | str = False, + *, + verbose: bool | str | int | None = None, ) -> "RawFIL": """Raw object from FIL-OPMEG formatted data. diff --git a/mne/io/hitachi/hitachi.py b/mne/io/hitachi/hitachi.py index efadfd92754..fcc4c0df717 100644 --- a/mne/io/hitachi/hitachi.py +++ b/mne/io/hitachi/hitachi.py @@ -16,7 +16,11 @@ @fill_doc -def read_raw_hitachi(fname, preload=False, verbose=None) -> "RawHitachi": +def read_raw_hitachi( + fname: list | str, + preload: bool | str = False, + verbose: bool | str | int | None = None, +) -> "RawHitachi": """Reader for a Hitachi fNIRS recording. Parameters diff --git a/mne/io/kit/coreg.py b/mne/io/kit/coreg.py index 8e6698d6f78..aeda69f3cb0 100644 --- a/mne/io/kit/coreg.py +++ b/mne/io/kit/coreg.py @@ -31,7 +31,7 @@ FLOAT64 = " np.ndarray: r"""Marker Point Extraction in MEG space directly from sqd. Parameters diff --git a/mne/io/kit/kit.py b/mne/io/kit/kit.py index 4a5f94caf2f..a9eb9d0a3a3 100644 --- a/mne/io/kit/kit.py +++ b/mne/io/kit/kit.py @@ -12,6 +12,7 @@ from os import SEEK_CUR, PathLike from os import path as op from pathlib import Path +from typing import Literal import numpy as np @@ -911,20 +912,20 @@ def _read_name(fid, ch_type=None, n=None): @fill_doc def read_raw_kit( - input_fname, - mrk=None, - elp=None, - hsp=None, - stim=">", - slope="-", - stimthresh=1, - preload=False, - stim_code="binary", - allow_unknown_format=False, - standardize_names=False, + input_fname: Path | str, + mrk: Path | str | np.ndarray | list | None = None, + elp: Path | str | np.ndarray | None = None, + hsp: Path | str | np.ndarray | None = None, + stim: list[int] | Literal["<", ">"] | None = ">", + slope: Literal["+", "-"] = "-", + stimthresh: float | None = 1, + preload: bool | str = False, + stim_code: Literal["binary", "channel"] = "binary", + allow_unknown_format: bool = False, + standardize_names: bool = False, *, - bad_coils=(), - verbose=None, + bad_coils: np.ndarray | tuple | None = (), + verbose: bool | str | int | None = None, ) -> RawKIT: r"""Reader function for Ricoh/KIT conversion to FIF. @@ -986,15 +987,15 @@ def read_raw_kit( @fill_doc def read_epochs_kit( - input_fname, - events, - event_id=None, - mrk=None, - elp=None, - hsp=None, - allow_unknown_format=False, - standardize_names=False, - verbose=None, + input_fname: Path | str, + events: np.ndarray | Path | str, + event_id: int | list[int] | dict | str | list[str] | None = None, + mrk: Path | str | np.ndarray | list | None = None, + elp: Path | str | np.ndarray | None = None, + hsp: Path | str | np.ndarray | None = None, + allow_unknown_format: bool = False, + standardize_names: bool = False, + verbose: bool | str | int | None = None, ) -> EpochsKIT: """Reader function for Ricoh/KIT epochs files. diff --git a/mne/io/mef/mef.py b/mne/io/mef/mef.py index d61590c631b..46ab9fbc6c7 100644 --- a/mne/io/mef/mef.py +++ b/mne/io/mef/mef.py @@ -5,6 +5,7 @@ """Read MEF3 files.""" import datetime as dt +from pathlib import Path import numpy as np @@ -257,7 +258,13 @@ def _read_segment_file(self, data, idx, fi, start, stop, cals, mult): @verbose -def read_raw_mef(fname, *, password="", preload=False, verbose=None) -> RawMEF: +def read_raw_mef( + fname: Path | str, + *, + password: str | bytes | None = "", + preload: bool | str = False, + verbose: bool | str | int | None = None, +) -> RawMEF: """Read raw data from MEF3 files. Parameters diff --git a/mne/io/nedf/nedf.py b/mne/io/nedf/nedf.py index 031a8480435..2d3404ecb75 100644 --- a/mne/io/nedf/nedf.py +++ b/mne/io/nedf/nedf.py @@ -6,6 +6,7 @@ from copy import deepcopy from datetime import datetime, timezone +from pathlib import Path from typing import Any import numpy as np @@ -205,7 +206,11 @@ def _convert_eeg(chunks, n_eeg, n_tot): @verbose -def read_raw_nedf(filename, preload=False, verbose=None) -> RawNedf: +def read_raw_nedf( + filename: Path | str, + preload: bool | str = False, + verbose: bool | str | int | None = None, +) -> RawNedf: """Read NeuroElectrics .nedf files. NEDF file versions starting from 1.3 are supported. diff --git a/mne/io/neuralynx/neuralynx.py b/mne/io/neuralynx/neuralynx.py index 10c6b134ab3..b084eecfa5c 100644 --- a/mne/io/neuralynx/neuralynx.py +++ b/mne/io/neuralynx/neuralynx.py @@ -6,6 +6,7 @@ import glob import inspect import os +from pathlib import Path import numpy as np @@ -18,7 +19,11 @@ @fill_doc def read_raw_neuralynx( - fname, *, preload=False, exclude_fname_patterns=None, verbose=None + fname: Path | str, + *, + preload: bool | str = False, + exclude_fname_patterns: list[str] | None = None, + verbose: bool | str | int | None = None, ) -> "RawNeuralynx": """Reader for Neuralynx files. diff --git a/mne/io/nicolet/nicolet.py b/mne/io/nicolet/nicolet.py index fc2f0a45e25..651f255221d 100644 --- a/mne/io/nicolet/nicolet.py +++ b/mne/io/nicolet/nicolet.py @@ -5,7 +5,8 @@ import calendar import datetime from os import path -from typing import Any +from pathlib import Path +from typing import Any, Literal import numpy as np @@ -18,7 +19,14 @@ @fill_doc def read_raw_nicolet( - input_fname, ch_type, eog=(), ecg=(), emg=(), misc=(), preload=False, verbose=None + input_fname: Path | str, + ch_type: str, + eog: list | tuple | Literal["auto"] = (), + ecg: list | tuple | Literal["auto"] = (), + emg: list | tuple | Literal["auto"] = (), + misc: list | tuple = (), + preload: bool | str = False, + verbose: bool | str | int | None = None, ) -> "RawNicolet": """Read Nicolet data as raw object. diff --git a/mne/io/nihon/nihon.py b/mne/io/nihon/nihon.py index 2ea10c1d123..c1a98b3f313 100644 --- a/mne/io/nihon/nihon.py +++ b/mne/io/nihon/nihon.py @@ -25,7 +25,11 @@ def _ensure_path(fname): @fill_doc def read_raw_nihon( - fname, preload=False, *, encoding="utf-8", verbose=None + fname: Path | str, + preload: bool = False, + *, + encoding: str = "utf-8", + verbose: bool | str | int | None = None, ) -> "RawNihon": """Reader for an Nihon Kohden EEG file. diff --git a/mne/io/nirx/nirx.py b/mne/io/nirx/nirx.py index 905d810e64f..daa9640ebb0 100644 --- a/mne/io/nirx/nirx.py +++ b/mne/io/nirx/nirx.py @@ -8,6 +8,7 @@ import os.path as op import re as re from configparser import ConfigParser, RawConfigParser +from pathlib import Path import numpy as np from scipy.io import loadmat @@ -35,7 +36,12 @@ @fill_doc def read_raw_nirx( - fname, saturated="annotate", *, preload=False, encoding="latin-1", verbose=None + fname: Path | str, + saturated: str = "annotate", + *, + preload: bool | str = False, + encoding: str = "latin-1", + verbose: bool | str | int | None = None, ) -> "RawNIRX": """Reader for a NIRX fNIRS recording. diff --git a/mne/io/nsx/nsx.py b/mne/io/nsx/nsx.py index 59e7808ad6e..dc7b19f31da 100644 --- a/mne/io/nsx/nsx.py +++ b/mne/io/nsx/nsx.py @@ -4,7 +4,8 @@ import os from datetime import datetime, timezone -from typing import Any +from pathlib import Path +from typing import Any, Literal import numpy as np @@ -88,7 +89,13 @@ @fill_doc def read_raw_nsx( - input_fname, stim_channel=True, eog=None, misc=None, preload=False, *, verbose=None + input_fname: Path | str, + stim_channel: str | int | list | Literal["auto"] | bool = True, + eog: list | tuple | None = None, + misc: list | tuple | None = None, + preload: bool | str = False, + *, + verbose: bool | str | int | None = None, ) -> "RawNSX": """Reader function for NSx (Blackrock Microsystems) files. diff --git a/mne/io/persyst/persyst.py b/mne/io/persyst/persyst.py index 68cefd00628..c33e70799b8 100644 --- a/mne/io/persyst/persyst.py +++ b/mne/io/persyst/persyst.py @@ -6,6 +6,7 @@ import os.path as op from collections import OrderedDict from datetime import datetime, timezone +from pathlib import Path from typing import Any import numpy as np @@ -19,7 +20,11 @@ @fill_doc -def read_raw_persyst(fname, preload=False, verbose=None) -> "RawPersyst": +def read_raw_persyst( + fname: Path | str, + preload: bool | str = False, + verbose: bool | str | int | None = None, +) -> "RawPersyst": """Reader for a Persyst (.lay/.dat) recording. Parameters diff --git a/mne/io/snirf/_snirf.py b/mne/io/snirf/_snirf.py index 4ee83ba36ea..be89f90c16a 100644 --- a/mne/io/snirf/_snirf.py +++ b/mne/io/snirf/_snirf.py @@ -4,6 +4,7 @@ import datetime import re +from pathlib import Path import numpy as np @@ -70,7 +71,12 @@ @fill_doc def read_raw_snirf( - fname, optode_frame="unknown", *, sfreq=None, preload=False, verbose=None + fname: Path | str, + optode_frame: str = "unknown", + *, + sfreq: float | None = None, + preload: bool | str = False, + verbose: bool | str | int | None = None, ) -> "RawSNIRF": """Reader for a continuous wave SNIRF data. diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index b63efc0be20..2ce6b4bc24a 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -14,6 +14,7 @@ import mne from mne.utils import _pl, _record_warnings +from mne.utils._typing import Color, FileLike public_modules = [ # the list of modules users need to access for all functionality @@ -365,10 +366,55 @@ def test_documented(): ) +def _documented_public_names(): + """Return the names documented in ``doc/api/*.rst`` autosummary blocks.""" + from sphinx.ext.autosummary.generate import find_autosummary_in_files + + api_dir = Path(__file__).parents[2] / "doc" / "api" + files = [str(path) for path in sorted(api_dir.glob("*.rst"))] + return {entry.name for entry in find_autosummary_in_files(files)} + + +def _resolve_dotted(name): + """Resolve a documented dotted name (e.g. ``mne.io.read_raw_edf``) by dot-access.""" + assert name.startswith("mne."), name # we should only document our own code + obj = mne + for part in name.split(".")[1:]: + obj = getattr(obj, part) + return obj + + +def _in_typed_module(obj): + """Whether ``obj`` is defined in one of the strictly-typed modules.""" + name = inspect.getmodule(obj).__name__ + return any(name == m or name.startswith(f"{m}.") for m in typed_modules) + + +def _documented_callables(): + """Yield ``(callable, cls)`` for documented public API in typed modules.""" + seen = set() + for dotted in sorted(_documented_public_names()): + obj = _resolve_dotted(dotted) + if not _in_typed_module(obj): + continue + if inspect.isclass(obj): + yield obj, None # constructor signature vs class docstring + for mname, method in inspect.getmembers(obj, inspect.isfunction): + if mname.startswith("_") or not _in_typed_module(method): + continue + if method not in seen: + seen.add(method) + yield method, obj + elif inspect.isfunction(obj) and obj not in seen: + seen.add(obj) + yield obj, None + + def _annotation_to_str(ann): """Render a type annotation as a module-stripped string.""" origin = typing.get_origin(ann) - if origin in (typing.Union, getattr(types, "UnionType", None)): + # unions and ``Literal["a", "b"]`` both flatten to their ``a | b`` members + if origin in (typing.Union, types.UnionType, typing.Literal): return " | ".join(_annotation_to_str(a) for a in typing.get_args(ann)) if origin is not None: # e.g. list[Evoked], dict[str, int], tuple[int, ...] args = typing.get_args(ann) @@ -383,41 +429,105 @@ def _annotation_to_str(ann): return getattr(ann, "__name__", str(ann)) +# numpydoc pseudo-types (informal type words) mapped to real MNE type aliases, +# so a documented ``color`` validates against a ``Color``-annotated parameter. +# Populated (below, once the helpers exist) with each alias's atom expansion. +_PSEUDO_ALIASES = {"color": Color, "color object": Color, "file-like": FileLike} +_PSEUDO_ALIAS_ATOMS: dict[str, set[str]] = {} + + +def _split_union(type_str): + """Split on top-level ``|`` only, so ``tuple[int | None, str]`` stays whole.""" + parts, depth, current = [], 0, "" + for char in type_str: + if char in "[(": + depth += 1 + elif char in "])": + depth = max(depth - 1, 0) + if char == "|" and depth == 0: + parts.append(current) + current = "" + else: + current += char + parts.append(current) + return parts + + def _type_atoms(type_str): - """Reduce a type string to a canonical set of union members. - - Handles both numpydoc docstring types (``instance of X``, ``A or B``, - ``list of X``) and annotation strings (``A | B``, ``list[X]``) so the two - can be compared, stripping any module qualifiers (``mne.evoked.Evoked`` and - ``numpy.ndarray`` become ``Evoked`` and ``ndarray``). - """ - s = re.sub(r"\binstance of\b", "", type_str) - s = re.sub(r"\blist of (\w+)\b", r"list[\1]", s) # -> same shape as list[X] + """Reduce a numpydoc or annotation type string to a canonical set of atoms.""" + s = type_str.replace("``", "").replace("`", "") # drop reST inline literals + s = re.sub(r":\w+:", "", s) # drop sphinx roles, e.g. :class: + s = s.replace("~", "") # drop the sphinx "abbreviate" marker + s = re.sub(r"\s*\(\s*default[^)]*\)", "", s, flags=re.I) # (default X) + s = re.sub(r"\s*,\s*default[:=]?\s*[^,|]+", "", s, flags=re.I) # , default X + s = re.sub(r"\s*,?\s*optional\b", "", s, flags=re.I) # , optional + s = re.sub(r"\binstance of\b", "", s) + s = re.sub(r",?\s*(?:of )?shape\s*\(?[^)|]*\)?", "", s) # shape (n, m) suffixes + s = re.sub(r"\btuple of length \d+\b", "tuple", s, flags=re.I) + s = re.sub( + r"\b(list|tuple|dict|set) of [\w.]+", r"\1", s, flags=re.I + ) # list of X -> list + s = re.sub(r"\barray(?:-?like)?\s+of\s+\w+", "array", s, flags=re.I) + s = re.sub(r"\barray-?like\b", "array", s, flags=re.I) + s = re.sub( # textual Literal["a", "b"] (from string annotations) -> a | b + r"\bLiteral\[([^\]]*)\]", lambda m: m.group(1).replace(",", "|"), s + ) + s = re.sub( + r"\{([^}]*)\}", lambda m: m.group(1).replace(",", "|"), s + ) # {a, b} -> a|b s = s.replace(" or ", "|") atoms = set() - for part in s.split("|"): + for part in _split_union(s): part = re.sub(r"[\w\.]*\.(\w+)", r"\1", part.strip()) # drop module paths - part = part.strip(" .,;:") # drop stray surrounding punctuation - if part: - atoms.add(part) + part = part.split("[", 1)[0] # reduce generics to container: list[str] -> list + part = part.strip(" .,;:'\"") # drop stray surrounding punctuation/quotes + if not part: + continue + low = part.lower() + if len(low) > 4 and low.startswith("base"): + low = low[ + 4: + ] # MNE ``BaseEpochs``/``BaseRaw`` are documented ``Epochs``/``Raw`` + if low in _PSEUDO_ALIAS_ATOMS: + atoms |= _PSEUDO_ALIAS_ATOMS[low] + elif low in ("path-like", "pathlike", "path_like"): + atoms |= {"path", "str"} + elif "array" in low or low == "ndarray": + atoms.add("array") + elif low == "class": # the ``class`` pseudo-type is a Python ``type`` object + atoms.add("type") + else: + atoms.add(low) return atoms -def _defined_under(obj, name): - """Return whether ``obj`` is defined in the ``name`` module or a submodule.""" - module_name = inspect.getmodule(obj).__name__ - return module_name == name or module_name.startswith(f"{name}.") +def _is_type_like(atom): + """Whether a normalized atom looks like a comparable type (not free prose).""" + return re.fullmatch(r"[a-z0-9_]+(\[[a-z0-9_,.\s\[\]]*\])?", atom) is not None + + +_PSEUDO_ALIAS_ATOMS.update( + { + name: {a.lower() for a in _type_atoms(_annotation_to_str(alias))} + for name, alias in _PSEUDO_ALIASES.items() + } +) -def _check_type_hints(func, *, cls, where, incorrect): +def _check_type_hints(func, *, cls, incorrect): """Compare a callable's type hints against its numpydoc docstring types.""" from numpydoc.docscrape import FunctionDoc name = _func_name(func, cls) sig = inspect.signature(func) doc = FunctionDoc(func) - # documented types, keyed by parameter name (and "return") - doc_types = {p.name: p.type for p in doc["Parameters"] if p.name and p.type} + # documented types, keyed by parameter name (and "return"); numpydoc keeps + # combined entries like "fmin, fmax : float" under one key, so split them + doc_types = {} + for p in doc["Parameters"]: + if p.name and p.type: + for pname in p.name.split(", "): + doc_types[pname.strip()] = p.type returns = [r.type for r in doc["Returns"] if r.type] if len(returns) == 1: # skip multi-value (tuple) returns for simplicity doc_types["return"] = returns[0] @@ -438,12 +548,20 @@ def _check_type_hints(func, *, cls, where, incorrect): # ``Self`` describes the (sub)class, which docstrings spell out concretely if _annotation_to_str(annotation) == "Self": continue - ann_atoms = {a.lower() for a in _type_atoms(_annotation_to_str(annotation))} doc_atoms = {d.lower() for d in _type_atoms(doc_types[target])} - if ann_atoms != doc_atoms: + # free-form docstring types (e.g. "matplotlib colormap | (colormap, bool)") + # cannot be matched mechanically, so only validate comparable ones + if not doc_atoms or not all(_is_type_like(d) for d in doc_atoms): + continue + ann_atoms = {a.lower() for a in _type_atoms(_annotation_to_str(annotation))} + # The annotation must cover every documented type, but may be broader: + # ty rejects ``= None``/``= ()`` defaults unless the annotation admits + # them, so an accurate hint sometimes adds ``None``/``tuple`` that the + # docstring omits. A missing documented atom, though, is a real mismatch. + if not ann_atoms >= doc_atoms: incorrect.append( - f"{where} : {name} : {target} : type hint " - f"{sorted(ann_atoms)} != docstring {sorted(doc_atoms)}" + f"{name} : {target} : type hint " + f"{sorted(ann_atoms)} does not cover docstring {sorted(doc_atoms)}" ) @@ -451,22 +569,12 @@ def _check_type_hints(func, *, cls, where, incorrect): def test_type_hints_match_docstrings(): """Test that type hints agree with numpydoc-rendered docstring types.""" pytest.importorskip("numpydoc") + pytest.importorskip("sphinx") pytest.importorskip("sklearn") incorrect = [] - for name in typed_modules: - module = __import__(name, globals()) - for submod in name.split(".")[1:]: - module = getattr(module, submod) - for cname, cls in inspect.getmembers(module, inspect.isclass): - if cname.startswith("_") or not _defined_under(cls, name): - continue - for mname, method in inspect.getmembers(cls, inspect.isfunction): - if not mname.startswith("_"): - _check_type_hints(method, cls=cls, where=name, incorrect=incorrect) - for fname, func in inspect.getmembers(module, inspect.isfunction): - if not fname.startswith("_") and _defined_under(func, name): - _check_type_hints(func, cls=None, where=name, incorrect=incorrect) + for func, cls in _documented_callables(): + _check_type_hints(func, cls=cls, incorrect=incorrect) incorrect = sorted(set(incorrect)) if incorrect: diff --git a/mne/tests/test_import_nesting.py b/mne/tests/test_import_nesting.py index 2d1dfdb35ee..5c1528b5012 100644 --- a/mne/tests/test_import_nesting.py +++ b/mne/tests/test_import_nesting.py @@ -136,6 +136,18 @@ def generic_visit(self, node): ) super().generic_visit(node) + def visit_If(self, node): + # Imports guarded by ``if TYPE_CHECKING:`` never execute at runtime, + # so they are exempt from the import-nesting hierarchy. + test = node.test + if (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( + isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" + ): + for child in node.orelse: # only the (runtime) else branch + self.visit(child) + return + self.generic_visit(node) + ignores = ( # File, statement, kind (omit line number because this can change) ("mne/utils/docs.py", " import mne", "non-relative mne import"), diff --git a/mne/utils/_typing.py b/mne/utils/_typing.py index e1f0f7de79c..5a047e6070c 100644 --- a/mne/utils/_typing.py +++ b/mne/utils/_typing.py @@ -5,6 +5,7 @@ # Copyright the MNE-Python contributors. import sys +from typing import IO if sys.version_info >= (3, 11): from typing import Self @@ -12,4 +13,11 @@ # TODO VERSION: Remove this when Python 3.11+ is required (use typing.Self) from typing_extensions import Self -__all__ = ["Self"] +# A Matplotlib color: a named/hex string, or an RGB(A) tuple of floats. This is +# the runtime meaning of the ``color`` numpydoc pseudo-type. +Color = str | tuple +# An open file-like object (a readable/writable stream) rather than a path; the +# runtime meaning of the ``file-like`` numpydoc pseudo-type. +FileLike = IO + +__all__ = ["Color", "FileLike", "Self"] diff --git a/mne/utils/docs.py b/mne/utils/docs.py index b8377103c6a..24d75585c4d 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -809,7 +809,7 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): """ _cmap_template = """ -cmap : matplotlib colormap | str{allowed} +cmap : str | matplotlib.colors.Colormap{allowed} The :class:`~matplotlib.colors.Colormap` to use. If a :class:`str`, must be a valid Matplotlib colormap name. Default is {default}. """ @@ -821,7 +821,7 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): allowed="", default='``"RdBu_r"``' ) docdict["cmap_topomap"] = """\ -cmap : matplotlib colormap | (colormap, bool) | 'interactive' | None +cmap : str | matplotlib.colors.Colormap | tuple | 'interactive' | None Colormap to use. If :class:`tuple`, the first value indicates the colormap to use and the second value is a boolean defining interactivity. In interactive mode the colors are adjustable by clicking and dragging the @@ -838,7 +838,7 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): """ docdict["cmap_topomap_simple"] = """ -cmap : matplotlib colormap | None +cmap : str | matplotlib.colors.Colormap | None Colormap to use. If None, 'Reds' is used for all positive data, otherwise defaults to 'RdBu_r'. """ diff --git a/pyproject.toml b/pyproject.toml index 23c768b60c5..4aed0249f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ test_extra_ft = [ "neo", "pybv", "snirf", + "sphinx", "sphinx-gallery", {include-group = "test"}, ] From 477c0697658385b642d09b5102f714d57c66a60c Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 14 Jul 2026 13:54:21 -0400 Subject: [PATCH 2/4] FIX: Log --- doc/changes/dev/14056.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14056.newfeature.rst diff --git a/doc/changes/dev/14056.newfeature.rst b/doc/changes/dev/14056.newfeature.rst new file mode 100644 index 00000000000..6978751cc45 --- /dev/null +++ b/doc/changes/dev/14056.newfeature.rst @@ -0,0 +1 @@ +Added type annotations to public members of ``mne/io``, :class:`mne.Evoked`, and :class:`mne.Epochs`, by `Eric Larson`_. From f9189a8c2cafa98da87316ea343ce9ff90180ec1 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 14 Jul 2026 14:51:14 -0400 Subject: [PATCH 3/4] FIX: autodoc typehints are a pain --- doc/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 6f0c4740d30..6cec4f97214 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -738,6 +738,9 @@ def fix_sklearn_inherited_docstrings(app, what, name, obj, options, lines): # autodoc / autosummary autosummary_generate = True autodoc_default_options = {"inherited-members": None} +# Types are documented (in human-readable numpydoc form) in the docstrings +# themselves, so don't also render the annotations into the signatures. +autodoc_typehints = "none" # sphinxcontrib-bibtex bibtex_bibfiles = ["./references.bib"] From 43883328d846900cf74b5686b80eddaa906fb873 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 23 Jul 2026 19:06:29 -0400 Subject: [PATCH 4/4] FIX: Comments --- mne/io/fieldtrip/fieldtrip.py | 22 +++++--- mne/tests/test_docstring_parameters.py | 76 +++++++++++++++++++++----- mne/tests/test_import_nesting.py | 2 + 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/mne/io/fieldtrip/fieldtrip.py b/mne/io/fieldtrip/fieldtrip.py index 6133fae4db6..0c4bb66efa4 100644 --- a/mne/io/fieldtrip/fieldtrip.py +++ b/mne/io/fieldtrip/fieldtrip.py @@ -6,6 +6,7 @@ import numpy as np +from ..._fiff.meas_info import Info from ...epochs import EpochsArray from ...evoked import EvokedArray from ...utils import _check_fname, _import_pymatreader_funcs @@ -20,7 +21,7 @@ def read_raw_fieldtrip( - fname: Path | str, info: dict | None, data_name: str = "data" + fname: Path | str, info: Info | None, data_name: str = "data" ) -> RawArray: """Load continuous (raw) data from a FieldTrip preprocessing structure. @@ -38,8 +39,9 @@ def read_raw_fieldtrip( ---------- fname : path-like Path and filename of the ``.mat`` file containing the data. - info : dict or None - The info dict of the raw data file corresponding to the data to import. + info : mne.Info | None + The :class:`mne.Info` of the raw data file corresponding to the data to + import. If this is set to None, limited information is extracted from the FieldTrip structure. data_name : str @@ -86,7 +88,7 @@ def read_raw_fieldtrip( def read_epochs_fieldtrip( fname: Path | str, - info: dict | None, + info: Info | None, data_name: str = "data", trialinfo_column: int = 0, ) -> EpochsArray: @@ -109,8 +111,9 @@ def read_epochs_fieldtrip( ---------- fname : path-like Path and filename of the ``.mat`` file containing the data. - info : dict or None - The info dict of the raw data file corresponding to the data to import. + info : mne.Info | None + The :class:`mne.Info` of the raw data file corresponding to the data to + import. If this is set to None, limited information is extracted from the FieldTrip structure. data_name : str @@ -149,7 +152,7 @@ def read_epochs_fieldtrip( def read_evoked_fieldtrip( fname: Path | str, - info: dict | None, + info: Info | None, comment: str | None = None, data_name: str = "data", ) -> "EvokedArray": @@ -169,8 +172,9 @@ def read_evoked_fieldtrip( ---------- fname : path-like Path and filename of the ``.mat`` file containing the data. - info : dict or None - The info dict of the raw data file corresponding to the data to import. + info : mne.Info | None + The :class:`mne.Info` of the raw data file corresponding to the data to + import. If this is set to None, limited information is extracted from the FieldTrip structure. comment : str diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index 2ce6b4bc24a..e5d2d97d9c8 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -458,23 +458,26 @@ def _type_atoms(type_str): s = type_str.replace("``", "").replace("`", "") # drop reST inline literals s = re.sub(r":\w+:", "", s) # drop sphinx roles, e.g. :class: s = s.replace("~", "") # drop the sphinx "abbreviate" marker + # TODO: neither ``default X`` nor ``optional`` belongs in a numpydoc *type* -- + # MNE style puts the default in the parameter description prose instead. Drop + # these three normalizations to find (and then fix) the docstrings doing it. s = re.sub(r"\s*\(\s*default[^)]*\)", "", s, flags=re.I) # (default X) s = re.sub(r"\s*,\s*default[:=]?\s*[^,|]+", "", s, flags=re.I) # , default X s = re.sub(r"\s*,?\s*optional\b", "", s, flags=re.I) # , optional s = re.sub(r"\binstance of\b", "", s) s = re.sub(r",?\s*(?:of )?shape\s*\(?[^)|]*\)?", "", s) # shape (n, m) suffixes s = re.sub(r"\btuple of length \d+\b", "tuple", s, flags=re.I) - s = re.sub( - r"\b(list|tuple|dict|set) of [\w.]+", r"\1", s, flags=re.I - ) # list of X -> list + s = re.sub( # list of X -> list ("X" may be hyphenated, e.g. "list of path-like") + r"\b(list|tuple|dict|set) of [\w.-]+", r"\1", s, flags=re.I + ) s = re.sub(r"\barray(?:-?like)?\s+of\s+\w+", "array", s, flags=re.I) s = re.sub(r"\barray-?like\b", "array", s, flags=re.I) s = re.sub( # textual Literal["a", "b"] (from string annotations) -> a | b - r"\bLiteral\[([^\]]*)\]", lambda m: m.group(1).replace(",", "|"), s + r"\bLiteral\[([^\]]*)\]", lambda m: m.group(1).replace(",", " | "), s ) s = re.sub( - r"\{([^}]*)\}", lambda m: m.group(1).replace(",", "|"), s - ) # {a, b} -> a|b + r"\{([^}]*)\}", lambda m: m.group(1).replace(",", " | "), s + ) # {a, b} -> a | b s = s.replace(" or ", "|") atoms = set() for part in _split_union(s): @@ -492,6 +495,8 @@ def _type_atoms(type_str): atoms |= _PSEUDO_ALIAS_ATOMS[low] elif low in ("path-like", "pathlike", "path_like"): atoms |= {"path", "str"} + elif low == "list-like": + atoms |= {"list", "array"} elif "array" in low or low == "ndarray": atoms.add("array") elif low == "class": # the ``class`` pseudo-type is a Python ``type`` object @@ -502,8 +507,32 @@ def _type_atoms(type_str): def _is_type_like(atom): - """Whether a normalized atom looks like a comparable type (not free prose).""" - return re.fullmatch(r"[a-z0-9_]+(\[[a-z0-9_,.\s\[\]]*\])?", atom) is not None + """Whether a normalized atom is a comparable type/value rather than free prose. + + A single token (``int``, ``'+'``, ``'mm/dd/yy'``) can be compared; anything + with an internal space ("matplotlib colormap", "Raw object") is prose. + """ + return bool(atom) and not re.search(r"\s", atom) + + +# Malformed numpydoc types (comma-unions, prose, parenthesized sub-unions) that +# can't be compared to annotations. The test fails on both unlisted prose and stale +# entries, so this can only shrink. +# TODO: whittle down by fixing the docstrings. +unparseable_docstring_types = { + "Evoked instance, or list of Evoked instances", + "None | colormap | (colormap, bool) | 'interactive'", + "Raw object", + "bool, str, or None (default None)", + "instance of matplotlib Axes | None", + "list of (int | str) | tuple of (int | str)", + "list of (int | str) | tuple of (int | str) | ``'auto'``", + "list of (n_epochs) list (of n_channels) | None", + "list of Axes | dict of list of Axes | None", + "list, or Raw instance", + "matplotlib colormap | (colormap, bool) | 'interactive'", + "str, {'power', 'amplitude'}", +} _PSEUDO_ALIAS_ATOMS.update( @@ -514,10 +543,11 @@ def _is_type_like(atom): ) -def _check_type_hints(func, *, cls, incorrect): - """Compare a callable's type hints against its numpydoc docstring types.""" +def _check_type_hints(func, *, cls): + """Compare type hints against docstring types; return (errors, allowlist hits).""" from numpydoc.docscrape import FunctionDoc + incorrect, allowed = [], set() name = _func_name(func, cls) sig = inspect.signature(func) doc = FunctionDoc(func) @@ -549,9 +579,17 @@ def _check_type_hints(func, *, cls, incorrect): if _annotation_to_str(annotation) == "Self": continue doc_atoms = {d.lower() for d in _type_atoms(doc_types[target])} - # free-form docstring types (e.g. "matplotlib colormap | (colormap, bool)") - # cannot be matched mechanically, so only validate comparable ones + # Prose types cannot be matched mechanically. They are docstring bugs, so + # only the known (allowlisted) ones are tolerated; anything new is an error. if not doc_atoms or not all(_is_type_like(d) for d in doc_atoms): + if doc_types[target] in unparseable_docstring_types: + allowed.add(doc_types[target]) + else: + incorrect.append( + f"{name} : {target} : docstring type {doc_types[target]!r} is not " + "machine-checkable; fix it to MNE style (``a | b | None``, " + "``instance of X``)" + ) continue ann_atoms = {a.lower() for a in _type_atoms(_annotation_to_str(annotation))} # The annotation must cover every documented type, but may be broader: @@ -563,6 +601,7 @@ def _check_type_hints(func, *, cls, incorrect): f"{name} : {target} : type hint " f"{sorted(ann_atoms)} does not cover docstring {sorted(doc_atoms)}" ) + return incorrect, allowed @pytest.mark.slowtest @@ -572,9 +611,11 @@ def test_type_hints_match_docstrings(): pytest.importorskip("sphinx") pytest.importorskip("sklearn") - incorrect = [] + incorrect, allowed = [], set() for func, cls in _documented_callables(): - _check_type_hints(func, cls=cls, incorrect=incorrect) + errors, hits = _check_type_hints(func, cls=cls) + incorrect.extend(errors) + allowed |= hits incorrect = sorted(set(incorrect)) if incorrect: @@ -582,6 +623,13 @@ def test_type_hints_match_docstrings(): f"{len(incorrect)} type hint / docstring mismatch{_pl(incorrect)} " f"found:\n" + "\n".join(incorrect) ) + # keep the allowlist honest: a fixed docstring must be removed from it + stale = sorted(unparseable_docstring_types - allowed) + if stale: + raise AssertionError( + f"{len(stale)} entr{'y is' if len(stale) == 1 else 'ies are'} no longer " + "needed in unparseable_docstring_types; remove:\n" + "\n".join(stale) + ) def test_docdict_order(): diff --git a/mne/tests/test_import_nesting.py b/mne/tests/test_import_nesting.py index 5c1528b5012..2a2110e85f9 100644 --- a/mne/tests/test_import_nesting.py +++ b/mne/tests/test_import_nesting.py @@ -137,6 +137,8 @@ def generic_visit(self, node): super().generic_visit(node) def visit_If(self, node): + # The capital "I" is intentional: ``ast.NodeVisitor`` dispatches on the + # node class name (``ast.If``), so the method must be ``visit_If``. # Imports guarded by ``if TYPE_CHECKING:`` never execute at runtime, # so they are exempt from the import-nesting hierarchy. test = node.test