diff --git a/doc/conf.py b/doc/conf.py index d1ca88c350e..432ad43f344 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -220,6 +220,7 @@ "path-like": ":term:`path-like`", "array-like": ":term:`array_like `", "Path": ":class:`python:pathlib.Path`", + "Sequence": ":class:`python:collections.abc.Sequence`", "bool": ":ref:`bool `", # Matplotlib "colormap": ":ref:`colormap `", diff --git a/mne/_fiff/constants.py b/mne/_fiff/constants.py index aced3454d57..683210d3250 100644 --- a/mne/_fiff/constants.py +++ b/mne/_fiff/constants.py @@ -2,7 +2,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -from ..utils._bunch import BunchConstNamed +from ..utils._bunch import BunchConstNamed, NamedInt FIFF = BunchConstNamed() @@ -1001,7 +1001,7 @@ FIFF.FIFF_UNITM_P = -12 FIFF.FIFF_UNITM_F = -15 FIFF.FIFF_UNITM_A = -18 -_ch_unit_mul_named = { +_ch_unit_mul_named: dict[NamedInt, NamedInt] = { key: key for key in ( FIFF.FIFF_UNITM_E, diff --git a/mne/_fiff/meas_info.py b/mne/_fiff/meas_info.py index d4b87be0aac..ec1dd7eb10d 100644 --- a/mne/_fiff/meas_info.py +++ b/mne/_fiff/meas_info.py @@ -9,14 +9,22 @@ import string import weakref from collections import Counter, OrderedDict -from collections.abc import Mapping +from collections.abc import Callable, Iterable, Mapping, Sequence from copy import deepcopy from functools import partial from io import BytesIO +from os import PathLike from textwrap import shorten +from typing import IO, TYPE_CHECKING, Annotated, Any, Literal, Self, cast import numpy as np +if TYPE_CHECKING: + from matplotlib.axes import Axes + from mpl_toolkits.mplot3d.axes3d import Axes3D + + from ..channels.montage import DigMontage + from ..defaults import _handle_default from ..html_templates import _get_html_template from ..utils import ( @@ -37,6 +45,7 @@ warn, ) from ..utils._bunch import NamedFloat, NamedInt +from ..utils._typing import CoordFrameStr, LogLevel, RaiseWarnIgnore from ._digitization import ( DigPoint, _dig_kind_ints, @@ -119,6 +128,12 @@ _MIN_CH_KEYS_SET = set(("kind", "cal", "unit", "loc", "ch_name")) +def _get_info_or_self(obj: Any) -> "Info": + """Get Info from MNE objects, unless `obj` itself is an Info object.""" + info = getattr(obj, "info", obj) + return info + + def _get_valid_units(): """Get valid units according to the International System of Units (SI). @@ -321,7 +336,7 @@ class MontageMixin: """Mixin for Montage getting and setting.""" @fill_doc - def get_montage(self): + def get_montage(self) -> "DigMontage | None": """Get a DigMontage from instance. Returns @@ -332,7 +347,7 @@ def get_montage(self): from ..channels.montage import make_dig_montage from ..transforms import _frame_to_str - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) if info["dig"] is None: return None # obtain coord_frame, and landmark coords @@ -385,12 +400,12 @@ def get_montage(self): @verbose def set_montage( self, - montage, - match_case=True, - match_alias=False, - on_missing="raise", - verbose=None, - ): + montage: "DigMontage | str | None", + match_case: bool = True, + match_alias: bool | dict[str, str] = False, + on_missing: RaiseWarnIgnore = "raise", + verbose: LogLevel = None, + ) -> Self: """Set %(montage_types)s channel positions and digitization points. Parameters @@ -431,12 +446,14 @@ def set_montage( from ..channels.montage import _set_montage - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) _set_montage(info, montage, match_case, match_alias, on_missing) return self -channel_type_constants = get_channel_type_constants(include_defaults=True) +channel_type_constants: dict[str, dict[str, NamedInt]] = get_channel_type_constants( + include_defaults=True +) _human2fiff = { k: v.get("kind", FIFF.FIFFV_COIL_NONE) for k, v in channel_type_constants.items() } @@ -472,6 +489,8 @@ def _check_set(ch, projs, ch_type): class SetChannelsMixin(MontageMixin): """Mixin class for Raw, Evoked, Epochs.""" + from ..bem import ConductorModel + def _get_channel_positions(self, picks=None): """Get channel locations from info. @@ -484,7 +503,7 @@ def _get_channel_positions(self, picks=None): ----- .. versionadded:: 0.9.0 """ - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) picks = _picks_to_idx(info, picks) chs = info["chs"] pos = np.array([chs[k]["loc"][:3] for k in picks]) @@ -509,7 +528,7 @@ def _set_channel_positions(self, pos, names): ----- .. versionadded:: 0.9.0 """ - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) if len(pos) != len(names): raise ValueError( "Number of channel positions not equal to the number of names given." @@ -521,15 +540,21 @@ def _set_channel_positions(self, pos, names): ) raise ValueError(msg) for name, p in zip(names, pos): - if name in self.ch_names: - idx = self.ch_names.index(name) + if name in info.ch_names: + idx = info.ch_names.index(name) info["chs"][idx]["loc"][:3] = p else: msg = f"{name} was not found in the info. Cannot be updated." raise ValueError(msg) @verbose - def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): + def set_channel_types( + self, + mapping: dict[str, str], + *, + on_unit_change: RaiseWarnIgnore = "warn", + verbose: LogLevel = None, + ) -> Self: """Specify the sensor types of channels. Parameters @@ -566,7 +591,7 @@ def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): .. versionadded:: 0.9.0 """ - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) ch_names = info["ch_names"] # first check and assemble clean mappings of index and name @@ -599,7 +624,7 @@ def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): unit_changes[this_change] = list() unit_changes[this_change].append(ch_name) # reset unit multiplication factor since the unit has now changed - info["chs"][c_ind]["unit_mul"] = _ch_unit_mul_named[0] + info["chs"][c_ind]["unit_mul"] = _ch_unit_mul_named[cast(NamedInt, 0)] info["chs"][c_ind]["unit"] = _human2unit[ch_type] if ch_type in ["eeg", "seeg", "ecog", "dbs"]: coil_type = FIFF.FIFFV_COIL_EEG @@ -635,8 +660,13 @@ def set_channel_types(self, mapping, *, on_unit_change="warn", verbose=None): @verbose def rename_channels( - self, mapping, allow_duplicates=False, *, on_missing="raise", verbose=None - ): + self, + mapping: dict[str, str] | Callable[[str], str], + allow_duplicates: bool = False, + *, + on_missing: RaiseWarnIgnore = "raise", + verbose: LogLevel = None, + ) -> Self: """Rename channels. Parameters @@ -662,7 +692,7 @@ def rename_channels( from ..channels.channels import rename_channels from ..io import BaseRaw - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) ch_names_orig = list(info["ch_names"]) rename_channels(info, mapping, allow_duplicates, on_missing=on_missing) @@ -683,24 +713,35 @@ def rename_channels( @verbose def plot_sensors( self, - kind="topomap", - ch_type=None, - title=None, - show_names=False, - ch_groups=None, - to_sphere=True, - axes=None, - block=None, - show=True, - sphere=None, + kind: Literal["topomap", "3d", "select"] = "topomap", + ch_type: Literal["mag", "grad", "eeg", "seeg", "dbs", "ecog", "all"] + | None = None, + title: str | None = None, + show_names: bool + | np.ndarray[tuple[int], np.dtype[np.str_]] # 1D array of str + | Sequence[str] = False, + ch_groups: Literal["position"] + | np.ndarray[tuple[int, int], np.dtype[np.integer]] + | None = None, + to_sphere: bool = True, + axes: "Axes | Axes3D | None" = None, + block: bool | None = None, + show: bool = True, + sphere: float # radius + | Annotated[Sequence[float], 4] # x, y, z, radius + | np.ndarray[tuple[Literal[4]], np.dtype[np.floating]] # x, y, z, radius + | ConductorModel + | Literal["auto", "cardinal", "eeg", "extra", "hpi", "eeglab"] + | list[Literal["cardinal", "eeg", "extra", "hpi"]] + | None = None, *, - verbose=None, - ): + verbose: LogLevel = None, + ) -> "Any": """Plot sensor positions. Parameters ---------- - kind : str + kind : 'topomap' | '3d' | 'select' Whether to plot the sensors as 3d, topomap or as an interactive sensor selection dialog. Available options 'topomap', '3d', 'select'. If 'select', a set of channels can be selected @@ -715,7 +756,7 @@ def plot_sensors( title : str | None Title for the figure. If None (default), equals to ``'Sensor positions (%%s)' %% ch_type``. - show_names : bool | array of str + show_names : bool | array-like of str, shape (n_names,) Whether to display all channel names. If an array, only the channel names in the array are shown. Defaults to False. ch_groups : 'position' | array of shape (n_ch_groups, n_picks) | None @@ -775,7 +816,7 @@ def plot_sensors( from ..viz.utils import plot_sensors return plot_sensors( - self if isinstance(self, Info) else self.info, + _get_info_or_self(self), kind=kind, ch_type=ch_type, title=title, @@ -790,7 +831,14 @@ def plot_sensors( ) @verbose - def anonymize(self, daysback=None, keep_his=False, verbose=None): + def anonymize( + self, + daysback: int | None = None, + keep_his: bool + | Literal["his_id", "sex", "hand"] + | Sequence[Literal["his_id", "sex", "hand"]] = False, + verbose: LogLevel = None, + ) -> Self: """Anonymize measurement information in place. Parameters @@ -810,12 +858,15 @@ def anonymize(self, daysback=None, keep_his=False, verbose=None): .. versionadded:: 0.13.0 """ - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) + assert isinstance(info, Info) anonymize_info(info, daysback=daysback, keep_his=keep_his, verbose=verbose) self.set_meas_date(info["meas_date"]) # unify annot update return self - def set_meas_date(self, meas_date): + def set_meas_date( + self, meas_date: datetime.datetime | float | tuple[int, int] | None + ) -> Self: """Set the measurement start date. Parameters @@ -851,7 +902,7 @@ def set_meas_date(self, meas_date): meas_date, (datetime.datetime, "numeric", tuple, None), "meas_date" ) - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) meas_date = _handle_meas_date(meas_date) with info._unlock(): @@ -872,14 +923,14 @@ def set_meas_date(self, meas_date): value["machid"] = _tmp if hasattr(self, "annotations"): - self.annotations._orig_time = meas_date + self.annotations._orig_time = meas_date # type: ignore (until annotations is typed) return self class ContainsMixin: """Mixin class for Raw, Evoked, Epochs and Info.""" - def __contains__(self, ch_type): + def __contains__(self, ch_type: str, /) -> bool: """Check channel type membership. Parameters @@ -906,22 +957,33 @@ def __contains__(self, ch_type): # dictionary and the 'key' in Info call is present all across MNE codebase, e.g. # to check for the presence of a key: # >>> 'bads' in info + info = _get_info_or_self(self) if ch_type == "meg": - has_ch_type = _contains_ch_type(self.info, "mag") or _contains_ch_type( - self.info, "grad" + has_ch_type = _contains_ch_type(info, "mag") or _contains_ch_type( + info, "grad" ) else: - has_ch_type = _contains_ch_type(self.info, ch_type) + has_ch_type = _contains_ch_type(info, ch_type) return has_ch_type @property - def compensation_grade(self): + def compensation_grade(self) -> int: """The current gradient compensation grade.""" - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) return get_current_comp(info) @fill_doc - def get_channel_types(self, picks=None, unique=False, only_data_chs=False): + def get_channel_types( + self, + picks: str + | np.ndarray[tuple[int], np.dtype[np.integer]] # 1D array of int + | Sequence[str] + | Sequence[int] + | slice + | None = None, + unique: bool = False, + only_data_chs: bool = False, + ) -> list[str]: """Get a list of channel type for each channel. Parameters @@ -937,7 +999,7 @@ def get_channel_types(self, picks=None, unique=False, only_data_chs=False): channel_types : list The channel types. """ - info = self if isinstance(self, Info) else self.info + info = _get_info_or_self(self) none = "data" if only_data_chs else "all" picks = _picks_to_idx(info, picks, none, (), allow_empty=False) ch_types = [channel_type(info, pick) for pick in picks] @@ -955,22 +1017,22 @@ def get_channel_types(self, picks=None, unique=False, only_data_chs=False): class ValidatedDict(dict): - _attributes = {} # subclasses should set this to validated attributes + _attributes: dict[str, Any] = {} # subclasses should set this to validated attrs - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: self._unlocked = True super().__init__(*args, **kwargs) self._unlocked = False - def __getstate__(self): + def __getstate__(self) -> dict[str, bool]: """Get state (for pickling).""" return {"_unlocked": self._unlocked} - def __setstate__(self, state): + def __setstate__(self, state: dict[str, Any]) -> None: """Set state (for pickling).""" self._unlocked = state["_unlocked"] - def __setitem__(self, key, val): + def __setitem__(self, key: str, val: Any) -> None: """Attribute setter.""" # During unpickling, the _unlocked attribute has not been set, so # let __setstate__ do it later and act unlocked now @@ -999,24 +1061,29 @@ def __setitem__(self, key, val): ) super().__setitem__(key, val) - def update(self, other=None, **kwargs): + def update( + self, + other: Mapping[str, Any] | Iterable[tuple[str, Any]] = (), + /, + **kwargs: Any, + ) -> None: # type: ignore ([invalid-method-override] we'd need overloads I think) """Update the instance, validating each key like ``__setitem__``. Parameters ---------- - other : dict | iterable of pair | None + other : mapping | iterable of {key, value pairs} The entries to set, as a mapping or as ``(key, value)`` pairs. **kwargs : dict Additional entries to set, as keyword arguments. """ iterable = other.items() if isinstance(other, Mapping) else other - if other is not None: - for key, val in iterable: - self[key] = val + for key, val in iterable: + assert isinstance(key, str) # type checking + self[key] = val for key, val in kwargs.items(): self[key] = val - def copy(self): + def copy(self) -> Self: """Copy the instance. Returns @@ -1026,7 +1093,7 @@ def copy(self): """ return deepcopy(self) - def __repr__(self): + def __repr__(self) -> str: """Return a string representation.""" mapping = ", ".join(f"{key}: {val}" for key, val in self.items()) return f"<{_camel_to_snake(self.__class__.__name__)} | {mapping}>" @@ -1077,7 +1144,7 @@ class SubjectInfo(ValidatedDict): ), } - def __init__(self, initial): + def __init__(self, initial: dict[str, Any]) -> None: _validate_type(initial, dict, "subject_info") super().__init__() for key, val in initial.items(): @@ -1114,7 +1181,7 @@ class HeliumInfo(ValidatedDict): ), } - def __init__(self, initial): + def __init__(self, initial: dict[str, Any]) -> None: _validate_type(initial, dict, "helium_info") super().__init__() for key, val in initial.items(): @@ -1165,13 +1232,13 @@ def _check_bads_info_compat(bads, info): class MNEBadsList(list): """Subclass of bads that checks inplace operations.""" - def __init__(self, *, bads, info): + def __init__(self, *, bads: Iterable[str], info: "Info") -> None: _check_bads_info_compat(bads, info) # avoid an info <-> bads reference cycle self._mne_info = weakref.ref(info) super().__init__(bads) - def extend(self, iterable): + def extend(self, iterable: Iterable[str]) -> None: if not isinstance(iterable, list): iterable = list(iterable) # info may be absent (during unpickling) or already gone (dead weakref) @@ -1181,14 +1248,14 @@ def extend(self, iterable): _check_bads_info_compat(iterable, info) return super().extend(iterable) - def append(self, x): + def append(self, x: str) -> None: return self.extend([x]) - def __iadd__(self, x): + def __iadd__(self, x: Iterable[str]) -> Self: self.extend(x) return self - def __reduce__(self): + def __reduce__(self) -> tuple[type, tuple[list[str]]]: # The weakref is not picklable, and the parent Info re-wraps it as an # MNEBadsList (via __setitem__) on load. return (list, (list(self),)) @@ -1807,12 +1874,12 @@ class Info(ValidatedDict, SetChannelsMixin, MontageMixin, ContainsMixin): "xplotter_layout": "xplotter_layout cannot be set directly.", } - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) with self._unlock(): _restore_mne_types(self) - def __setstate__(self, state): + def __setstate__(self, state: dict[str, Any]) -> None: """Set state (for pickling).""" super().__setstate__(state) self["bads"] = MNEBadsList(bads=self["bads"], info=self) @@ -1849,7 +1916,7 @@ def _skip_checks(self): finally: self._no_check = prev - def normalize_proj(self): + def normalize_proj(self) -> None: """(Re-)Normalize projection vectors after subselection. Applying projection after sub-selecting a set of channels that @@ -1865,7 +1932,7 @@ def normalize_proj(self): """ _normalize_proj(self) - def __repr__(self): + def __repr__(self) -> str: """Summarize info instead of printing all.""" from ..io.kit.constants import KIT_SYSNAMES from ..transforms import Transform, _coord_frame_name @@ -1959,7 +2026,7 @@ def __repr__(self): st %= non_empty return st - def __deepcopy__(self, memodict): + def __deepcopy__(self, memodict: dict[int, Any]) -> "Info": """Make a deepcopy.""" result = Info.__new__(Info) result._unlocked = True @@ -2076,7 +2143,7 @@ def _update_redundant(self): self["nchan"] = len(self["chs"]) @property - def ch_names(self): + def ch_names(self) -> list[str]: try: ch_names = self["ch_names"] except KeyError: @@ -2095,7 +2162,13 @@ def _repr_html_(self): return info_template.render(info=self) @verbose - def save(self, fname, *, overwrite=False, verbose=None): + def save( + self, + fname: str | PathLike, + *, + overwrite: bool = False, + verbose: LogLevel = None, + ) -> None: """Write measurement info in fif file. Parameters @@ -2113,7 +2186,7 @@ def save(self, fname, *, overwrite=False, verbose=None): """ write_info(fname, self, overwrite=overwrite) - def to_json_dict(self) -> dict: + def to_json_dict(self) -> dict[str, Any]: """Convert Info to a JSON-serializable dictionary. This method converts the Info object to a standard Python dictionary @@ -2145,7 +2218,7 @@ def to_json_dict(self) -> dict: return _make_serializable(self) @classmethod - def from_json_dict(cls, data_dict) -> "Info": + def from_json_dict(cls, data_dict: dict[str, Any]) -> "Info": """Reconstruct Info object from a dictionary. Parameters @@ -2175,7 +2248,7 @@ def from_json_dict(cls, data_dict) -> "Info": info = cls() with info._unlock(): - info.update(restored_dict) + info.update(restored_dict) # type: ignore (restored dict has nasty type) _restore_mne_types(info) return info @@ -2289,7 +2362,9 @@ def _simplify_info(info, *, keep=()): @verbose -def read_fiducials(fname, *, verbose=None): +def read_fiducials( + fname: str | PathLike, *, verbose: LogLevel = None +) -> tuple[list[dict[str, Any]], NamedInt]: """Read fiducials from a fiff file. Parameters @@ -2315,16 +2390,21 @@ def read_fiducials(fname, *, verbose=None): @verbose def write_fiducials( - fname, pts, coord_frame="unknown", *, overwrite=False, verbose=None -): + fname: str | PathLike, + pts: Iterable[dict[Literal["kind", "ident", "r"], Any]], + coord_frame: CoordFrameStr | int = "unknown", + *, + overwrite: bool = False, + verbose: LogLevel = None, +) -> None: """Write fiducials to a fiff file. Parameters ---------- fname : path-like Destination file name. - pts : iterator of dict - Iterator through digitizer points. Each point is a dictionary with + pts : iterable of dict + Iterable of digitizer points. Each point is a dictionary with the keys 'kind', 'ident' and 'r'. coord_frame : str | int The coordinate frame of the points. If a string, must be one of @@ -2342,7 +2422,7 @@ def write_fiducials( @verbose -def read_info(fname, verbose=None): +def read_info(fname: str | PathLike, verbose: LogLevel = None) -> "Info": """Read measurement info from a file. Parameters @@ -2363,24 +2443,6 @@ def read_info(fname, verbose=None): return info -def read_bad_channels(fid, node): - """Read bad channels. - - Parameters - ---------- - fid : file - The file descriptor. - node : dict - The node of the FIF tree that contains info on the bad channels. - - Returns - ------- - bads : list - A list of bad channel's names. - """ - return _read_bad_channels(fid, node) - - def _read_bad_channels(fid, node, ch_names_mapping): ch_names_mapping = {} if ch_names_mapping is None else ch_names_mapping nodes = dir_tree_find(node, FIFF.FIFFB_MNE_BAD_CHANNELS) @@ -2405,7 +2467,12 @@ def _write_bad_channels(fid, bads, ch_names_mapping): @verbose -def read_meas_info(fid, tree, clean_bads=False, verbose=None): +def read_meas_info( + fid: IO[bytes], + tree: dict[str, Any], + clean_bads: bool = False, + verbose: LogLevel = None, +) -> tuple["Info", Any]: """Read the measurement info. Parameters @@ -3004,7 +3071,12 @@ def _check_dates(info, prepend_error=""): @fill_doc -def write_meas_info(fid, info, data_type=None, reset_range=True): +def write_meas_info( + fid: IO[bytes], + info: Info, + data_type: Literal[4, 5, 16] | None = None, + reset_range: bool = True, +) -> None: """Write measurement info into a file id (from a fif file). Parameters @@ -3255,8 +3327,14 @@ def write_meas_info(fid, info, data_type=None, reset_range=True): @verbose def write_info( - fname, info, *, data_type=None, reset_range=True, overwrite=False, verbose=None -): + fname: str | PathLike, + info: Info, + *, + data_type: Literal[4, 5, 16] | None = None, + reset_range: bool = True, + overwrite: bool = False, + verbose: LogLevel = None, +) -> None: """Write measurement info in fif file. Parameters @@ -3509,17 +3587,22 @@ def _merge_info(infos, force_update_to_first=False, verbose=None): @verbose -def create_info(ch_names, sfreq, ch_types="misc", verbose=None): +def create_info( + ch_names: int | Iterable[str], + sfreq: float, + ch_types: str | Sequence[str] = "misc", + verbose: LogLevel = None, +) -> "Info": """Create a basic Info instance suitable for use with create_raw. Parameters ---------- - ch_names : list of str | int + ch_names : int | iterable of str Channel names. If an int, a list of channel names will be created from ``range(ch_names)``. sfreq : float Sample rate of the data. - ch_types : list of str | str + ch_types : str | sequence of str Channel types, default is ``'misc'`` which is a :term:`non-data channel `. Currently supported fields are 'bio', 'chpi', 'csd', 'dbs', 'dipole', @@ -3561,11 +3644,13 @@ def create_info(ch_names, sfreq, ch_types="misc", verbose=None): * AU: misc, stim, eyegaze, pupil """ try: - ch_names = operator.index(ch_names) # int-like + ch_names = operator.index(ch_names) # type: ignore (try-block makes it safe) except TypeError: pass else: ch_names = list(np.arange(ch_names).astype(str)) + if TYPE_CHECKING: + assert isinstance(ch_names, Sequence) _validate_type(ch_names, (list, tuple), "ch_names", ("list, tuple, or int")) sfreq = float(sfreq) if sfreq <= 0: @@ -3573,7 +3658,7 @@ def create_info(ch_names, sfreq, ch_types="misc", verbose=None): nchan = len(ch_names) if isinstance(ch_types, str): ch_types = [ch_types] * nchan - ch_types = np.atleast_1d(np.array(ch_types, np.str_)) + ch_types: np.ndarray = np.atleast_1d(np.array(ch_types, np.str_)) if ch_types.ndim != 1 or len(ch_types) != nchan: raise ValueError( f"ch_types and ch_names must be the same length ({len(ch_types)} != " @@ -3615,7 +3700,7 @@ def create_info(ch_names, sfreq, ch_types="misc", verbose=None): return info -RAW_INFO_FIELDS = ( +RAW_INFO_FIELDS: tuple[str, ...] = ( "acq_pars", "acq_stim", "bads", @@ -3745,7 +3830,14 @@ def _add_timedelta_to_stamp(meas_date_stamp, delta_t): @verbose -def anonymize_info(info, daysback=None, keep_his=False, verbose=None): +def anonymize_info( + info: Info, + daysback: int | None = None, + keep_his: bool + | Literal["his_id", "sex", "hand"] + | Sequence[Literal["his_id", "sex", "hand"]] = False, + verbose: LogLevel = None, +) -> "Info": """Anonymize measurement information in place. .. warning:: If ``info`` is part of an object like diff --git a/mne/io/hitachi/hitachi.py b/mne/io/hitachi/hitachi.py index cfb32a08c4c..76e59f00f63 100644 --- a/mne/io/hitachi/hitachi.py +++ b/mne/io/hitachi/hitachi.py @@ -4,6 +4,7 @@ import datetime as dt import re +from typing import cast import numpy as np @@ -311,7 +312,7 @@ def _get_hitachi_info(fname, S_offset, D_offset, ignore_names): info_extra["subject_info"] = subject_info # Create mne structure - info = create_info(ch_names, sfreq, ch_types=ch_types) + info = create_info(ch_names, cast(float, sfreq), ch_types=ch_types) with info._unlock(): info.update(info_extra) info["meas_date"] = meas_date diff --git a/mne/io/persyst/persyst.py b/mne/io/persyst/persyst.py index a860ee89c48..69d114e1ffe 100644 --- a/mne/io/persyst/persyst.py +++ b/mne/io/persyst/persyst.py @@ -198,7 +198,7 @@ def __init__(self, fname, preload=False, verbose=None): ch_names = [ch.upper().split("-REF")[0] for ch in ch_names] # get the sampling rate and default channel types to EEG - sfreq = fileinfo_dict.get("samplingrate") + sfreq = fileinfo_dict["samplingrate"] ch_types = "eeg" info = create_info(ch_names, sfreq, ch_types=ch_types) info.update(subject_info=subject_info) diff --git a/mne/tests/test_docstring_parameters.py b/mne/tests/test_docstring_parameters.py index dfb0bb4af93..02ff83df4f3 100644 --- a/mne/tests/test_docstring_parameters.py +++ b/mne/tests/test_docstring_parameters.py @@ -528,12 +528,23 @@ def _documented_callables(): yield obj, cls -def _annotation_to_str(ann): +def _annotation_to_str(ann, *, expand_literals=False): """Render a type annotation as a module-stripped string.""" origin = typing.get_origin(ann) # 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)) + lst = [] + if expand_literals and origin is typing.Literal: + lst.extend(list(set(type(x).__name__ for x in typing.get_args(ann)))) + return " | ".join( + [ + *lst, + *[ + _annotation_to_str(a, expand_literals=expand_literals) + 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) name = getattr(origin, "__name__", str(origin)) @@ -579,11 +590,15 @@ def _type_atoms(type_str): 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( # sequence of {'some', 'set', 'values'} -> sequence of str + r"\b(iterable|sequence) of \{[^}]*\}", r"\1", s, flags=re.I + ) 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 + r"\b(list|tuple|dict|set|sequence|iterable) 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(r"\bsequence\s+of\s+\w+", "sequence", 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 ) @@ -702,7 +717,14 @@ def _check_type_hints(func, *, cls): "``instance of X``)" ) continue - ann_atoms = {a.lower() for a in _type_atoms(_annotation_to_str(annotation))} + ann_atoms = { + a.lower() + for a in _type_atoms(_annotation_to_str(annotation, expand_literals=True)) + } + # skip for `verbose` parameters, where the type hint uses Literals extensively + # but it would be overkill to spell those out in the docstring + if target == "verbose": + continue # 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 diff --git a/mne/utils/_bunch.py b/mne/utils/_bunch.py index 87a33bb51e4..4489ed48c78 100644 --- a/mne/utils/_bunch.py +++ b/mne/utils/_bunch.py @@ -122,7 +122,7 @@ def __getattr__(self, attr: str) -> NamedInt | NamedFloat: f"{type(self).__name__!r} object has no attribute {attr!r}" ) - def __setattr__(self, attr, val): # noqa: D105 + def __setattr__(self, attr: str, val: "int | float | BunchConstNamed") -> None: # noqa: D105 assert isinstance(attr, str) if isinstance(val, int): val = NamedInt(attr, val) diff --git a/mne/utils/_typing.py b/mne/utils/_typing.py index a00d68b8380..a498cec161b 100644 --- a/mne/utils/_typing.py +++ b/mne/utils/_typing.py @@ -4,13 +4,45 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -from typing import IO, Self +from typing import IO, Literal, 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 + +# coordinate frame names +CoordFrameStr = Literal[ + "meg", + "mri", + "mri_voxel", + "head", + "mri_tal", + "ras", + "fs_tal", + "ctf_head", + "ctf_meg", + "unknown", +] + # 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"] +# valid arguments for `verbose` +LogLevel = ( + Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", 10, 20, 30, 40, 50] + | bool + | None +) + +# our standard on_missing args +RaiseWarnIgnore = Literal["raise", "warn", "ignore"] + +__all__ = [ + "Color", + "CoordFrameStr", + "FileLike", + "LogLevel", + "RaiseWarnIgnore", + "Self", +] diff --git a/pyproject.toml b/pyproject.toml index 525a45dca95..fc39853da0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -492,6 +492,7 @@ python-version = "3.11" # minimum supported, keep in sync with requires-python [tool.ty.src] exclude = ["mne/io/**/tests"] include = [ + "mne/_fiff/meas_info.py", "mne/annotations.py", "mne/epochs.py", "mne/evoked.py",