diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee3be59bb1c..f09c6c47929 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,6 +95,17 @@ repos: language: python entry: ./tools/hooks/sort_contributor_names.py files: '^(doc/changes/names.inc|.mailmap)$' + - id: check-static-docs + name: Check statically filled docstrings against docdict + language: python + entry: ./tools/hooks/check_static_docs.py + args: [--fix] + # the hook edits files beyond those passed (docs.py, other users of an + # edited entry), so parallel batched invocations would race + require_serial: true + additional_dependencies: [numpy, scipy, matplotlib, decorator, lazy_loader, packaging, jinja2, pooch, tqdm] + files: '^mne/.*\.py$' + exclude: '^mne/.*/tests/' # zizmor - repo: https://github.com/woodruffw/zizmor-pre-commit diff --git a/AGENTS.md b/AGENTS.md index 1707644cee1..f778f5ebd18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,11 +99,17 @@ cropping, projections, export) lives in mixins under `mne/channels/`, `mne/filte `mne/utils/mixin.py`, etc. and is composed via multiple inheritance rather than duplicated per class. -### Shared/templated docstrings -Common parameter descriptions live in a central dict in `mne/utils/docs.py` and are spliced into -function/method docstrings via the `@fill_doc` decorator + `%(param_name)s` placeholders — grep -for `docdict[` / `@fill_doc` before writing out a parameter docstring by hand, it's likely already -defined. +### Shared docstrings (`docdict`) +Common parameter descriptions live in a central dict in `mne/utils/docs.py`; grep for `docdict[` +before writing out a parameter docstring by hand, it's likely already defined. New code should use +`@fill_doc_static("key", ...)` / `@verbose_static("key", ...)` with the full text written out in +the docstring (and `@copy_function_doc_to_method_doc_static("func:...")` for methods that copy a +function's docstring); `tools/hooks/check_static_docs.py --fix FILE` expands `%(key)s` +placeholders and keeps the expanded text in sync with `docdict`. Shared text can be edited either +in `docdict` or in any one expanded copy; the pre-commit hook (which runs with `--fix`) +propagates the edit to the other side and fails the commit once so the changes can be reviewed +and staged. The legacy `@fill_doc`/`@verbose` decorators substitute `%(key)s` at import time +(IDEs can't see the result); don't add new uses. ### Changelog is per-PR fragment files (towncrier), not a single hand-edited file User-facing changes need a file `doc/changes/dev/..rst` (types: `notable`, diff --git a/doc/changes/dev/14195.bugfix.rst b/doc/changes/dev/14195.bugfix.rst new file mode 100644 index 00000000000..378e68b8d19 --- /dev/null +++ b/doc/changes/dev/14195.bugfix.rst @@ -0,0 +1 @@ +Make docstrings resolve properly in IDEs by fully writing them out in the Python code, by `Eric Larson`_. diff --git a/doc/development/contributing.rst b/doc/development/contributing.rst index 991c2c5acc4..32d2a8a9cf7 100644 --- a/doc/development/contributing.rst +++ b/doc/development/contributing.rst @@ -846,6 +846,82 @@ relatively complex. To run some basic tests on documentation, you can use:: $ make ruff +Shared parameter descriptions (the ``docdict``) +----------------------------------------------- + +Many parameters (``picks``, ``n_jobs``, ``verbose``, ``baseline``, ...) and some +longer passages (e.g., the notes on plotting backends) recur across the API. +Their text is written once, in the ``docdict`` dictionary in +:file:`mne/utils/docs.py`, and reused everywhere. Before writing a parameter +description by hand, ``git grep`` the parameter name in :file:`mne/utils/docs.py` +--- it is probably already there. + +There are two ways a docstring can use ``docdict`` entries: + +**Static (preferred for new code):** the docstring contains the *complete* +text, and the function is decorated with ``@fill_doc_static(...)`` (or +``@verbose_static(...)`` if it takes a ``verbose`` argument) listing the +``docdict`` keys it contains:: + + @verbose_static("picks_all", "n_jobs") + def my_function(inst, picks=None, n_jobs=None, verbose=None): + """Do something. + + Parameters + ---------- + inst : instance of Raw + The data. + picks : str | array-like | slice | None + Channels to include. ... <-- full text of docdict["picks_all"] + n_jobs : int | None + The number of jobs to run in parallel. ... + verbose : bool | str | int | None + Control verbosity of the logging output. ... + """ + +Nothing is substituted at import time, so the text you see in the source is +exactly what ``help()``, Sphinx, and your IDE's hover tooltips show. Methods +whose docstring is copied from a function (e.g., :meth:`mne.io.Raw.plot` from +:func:`mne.viz.plot_raw`) work the same way with +``@copy_function_doc_to_method_doc_static("func:mne.viz.plot_raw")`` (or +``@copy_doc_static("meth:...")``) above the fully written-out docstring. + +A pre-commit hook (``tools/hooks/check_static_docs.py``) keeps every copy in +sync with its source. It runs with ``--fix``, so it *rewrites* files and fails +the commit when it had to; review the changes, ``git add`` them, and commit +again. You can also run it by hand:: + + $ python tools/hooks/check_static_docs.py --fix mne/some_module.py + +In practice this means: + +- **Adding a shared parameter to a function:** write ``%(key)s`` on its own + line in the parameter list; the hook expands it and adds the key to the + decorator. +- **Changing shared text:** edit it *either* in :file:`mne/utils/docs.py` *or* + in any one docstring that uses it --- the hook detects which side changed + (by comparing ``docdict`` with ``git HEAD``) and propagates the edit to + ``docdict`` and every other docstring. Entries that are built from templates + in :file:`mne/utils/docs.py` rather than written as plain strings can only be + edited there; the hook tells you when that is the case. +- **Site-specific additions** (a ``.. versionadded::`` note, an extra + sentence) may follow the shared text within the same parameter block or + paragraph; the hook only manages the shared part. When adding such text, + start it with a blank line --- lines added directly after the shared text are + taken to be shared, and propagated everywhere. +- **Text that is nearly but not quite shared:** add a new ``docdict`` entry + (e.g., ``picks_good_data`` next to ``picks_all``) rather than editing one + copy. + +**Dynamic (legacy):** the docstring contains ``%(key)s`` placeholders and the +function is decorated with ``@fill_doc`` (or ``@verbose``), which substitutes +them at import time. This keeps the source short, but static analysis tools +only ever see the placeholders (see :gh:`8218`). Existing uses are being +migrated; please do not add new ones. ``@fill_doc``, ``@verbose``, +``@copy_doc`` and ``@copy_function_doc_to_method_doc`` remain available (and +unchanged) for downstream packages. + + Cross-reference everywhere -------------------------- diff --git a/doc/development/roadmap.rst b/doc/development/roadmap.rst index 075980e80f4..aaef0ff7839 100644 --- a/doc/development/roadmap.rst +++ b/doc/development/roadmap.rst @@ -39,7 +39,11 @@ dictionary called the ``docdict``). There are two major downsides: hover-tooltips in IDEs are less useful than they would be if the docstrings were complete in-place. -A possible route forward: +The route now being taken (see :ref:`the contributing guide `): +keep the ``docdict`` as the single place shared text is *authored*, but expand it +into the source code (``@fill_doc_static`` / ``@verbose_static``) and enforce +consistency with a pre-commit hook that can also apply ``docdict`` changes to every +docstring. An earlier alternative that was considered: - Convert all docstrings to be fully spelled out in the source code. - Instead of maintaining the ``docdict``, maintain a registry of sets of diff --git a/mne/baseline.py b/mne/baseline.py index 4e73ed0ce95..9f608260b87 100644 --- a/mne/baseline.py +++ b/mne/baseline.py @@ -6,7 +6,7 @@ import numpy as np -from .utils import _check_option, _validate_type, logger, verbose +from .utils import _check_option, _validate_type, logger, verbose_static def _log_rescale(baseline, mode="mean"): @@ -23,7 +23,7 @@ def _log_rescale(baseline, mode="mean"): return msg -@verbose +@verbose_static("baseline_rescale") def rescale(data, times, baseline, mode="mean", copy=True, picks=None, verbose=None): """Rescale (baseline correct) data. @@ -34,7 +34,18 @@ def rescale(data, times, baseline, mode="mean", copy=True, picks=None, verbose=N dimension should be time. times : 1D array Time instants is seconds. - %(baseline_rescale)s + baseline : None | tuple of length 2 + The time interval to consider as "baseline" when applying baseline + correction. If ``None``, do not apply baseline correction. + If a tuple ``(a, b)``, the interval is between ``a`` and ``b`` + (in seconds), including the endpoints. + If ``a`` is ``None``, the **beginning** of the data is used; and if ``b`` + is ``None``, it is set to the **end** of the data. + If ``(None, None)``, the entire time interval is used. + + .. note:: + The baseline ``(a, b)`` includes both endpoints, i.e. all timepoints ``t`` + such that ``a <= t <= b``. mode : 'mean' | 'ratio' | 'logratio' | 'percent' | 'zscore' | 'zlogratio' Perform baseline correction by @@ -54,7 +65,11 @@ def rescale(data, times, baseline, mode="mean", copy=True, picks=None, verbose=N Whether to return a new instance or modify in place. picks : list of int | None Data to process along the axis=-2 (None, default, processes all). - %(verbose)s + verbose : bool | str | int | None + Control verbosity of the logging output. If ``None``, use the default + verbosity level. See the :ref:`logging documentation ` and + :func:`mne.verbose` for details. Should only be passed as a keyword + argument. Returns ------- diff --git a/mne/cov.py b/mne/cov.py index 5870fb94ffd..bac065f2d47 100644 --- a/mne/cov.py +++ b/mne/cov.py @@ -71,6 +71,7 @@ fill_doc, logger, verbose, + verbose_static, warn, ) @@ -2266,7 +2267,7 @@ def _regularized_covariance( return cov -@verbose +@verbose_static("info", "picks_good_data_noref", "rank_none", "on_rank_mismatch") def compute_whitener( noise_cov, info=None, @@ -2285,10 +2286,63 @@ def compute_whitener( ---------- noise_cov : Covariance The noise covariance. - %(info)s Can be None if ``noise_cov`` has already been + info : mne.Info | None + The :class:`mne.Info` object with information about the sensors and methods of measurement. + Can be None if ``noise_cov`` has already been prepared with :func:`prepare_noise_cov`. - %(picks_good_data_noref)s - %(rank_none)s + picks : str | array-like | slice | None + Channels to include. Slices and lists of integers will be interpreted as + channel indices. In lists, channel *type* strings (e.g., ``['meg', + 'eeg']``) will pick channels of those types, channel *name* strings (e.g., + ``['MEG0111', 'MEG2623']`` will pick the given channels. Can also be the + string values ``'all'`` to pick all channels, or ``'data'`` to pick + :term:`data channels`. None (default) will pick good data channels + (excluding reference MEG channels). Note that channels in ``info['bads']`` + *will be included* if their names or indices are explicitly provided. + rank : None | 'info' | 'full' | dict + This controls the rank computation that can be read from the + measurement info or estimated from the data. When a noise covariance + is used for whitening, this should reflect the rank of that covariance, + otherwise amplification of noise components can occur in whitening (e.g., + often during source localization). + + :data:`python:None` + The rank will be estimated from the data after proper scaling of + different channel types. + ``'info'`` + The rank is inferred from ``info``. If data have been processed + with Maxwell filtering, the Maxwell filtering header is used. + Otherwise, the channel counts themselves are used. + In both cases, the number of projectors is subtracted from + the (effective) number of channels in the data. + For example, if Maxwell filtering reduces the rank to 68, with + two projectors the returned value will be 66. + ``'full'`` + The rank is assumed to be full, i.e. equal to the + number of good channels. If a `~mne.Covariance` is passed, this can + make sense if it has been (possibly improperly) regularized without + taking into account the true data rank. + :class:`dict` + Calculate the rank only for a subset of channel types, and explicitly + specify the rank for the remaining channel types. This can be + extremely useful if you already **know** the rank of (part of) your + data, for instance in case you have calculated it earlier. + + This parameter must be a dictionary whose **keys** correspond to + channel types in the data (e.g. ``'meg'``, ``'mag'``, ``'grad'``, + ``'eeg'``), and whose **values** are integers representing the + respective ranks. For example, ``{'mag': 90, 'eeg': 45}`` will assume + a rank of ``90`` and ``45`` for magnetometer data and EEG data, + respectively. + + The ranks for all channel types present in the data, but + **not** specified in the dictionary will be estimated empirically. + That is, if you passed a dataset containing magnetometer, gradiometer, + and EEG data together with the dictionary from the previous example, + only the gradiometer rank would be determined, while the specified + magnetometer and EEG ranks would be taken for granted. + + The default is ``None``. .. versionadded:: 0.18 Support for 'info' mode. @@ -2315,8 +2369,18 @@ def compute_whitener( .. versionadded:: 0.18 return_colorer : bool If True, return the colorer as well. - %(on_rank_mismatch)s - %(verbose)s + on_rank_mismatch : str + If an explicit MEG value is passed, what to do when it does not match + an empirically computed rank (only used for covariances). + Can be 'raise' to raise an error, 'warn' (default) to emit a warning, or + 'ignore' to ignore. + + .. versionadded:: 0.23 + verbose : bool | str | int | None + Control verbosity of the logging output. If ``None``, use the default + verbosity level. See the :ref:`logging documentation ` and + :func:`mne.verbose` for details. Should only be passed as a keyword + argument. Returns ------- diff --git a/mne/evoked.py b/mne/evoked.py index 01fac80a46e..9020b3533de 100644 --- a/mne/evoked.py +++ b/mne/evoked.py @@ -65,6 +65,7 @@ repr_html, sizeof_fmt, verbose, + verbose_static, warn, ) from .utils._typing import Color, Self @@ -446,7 +447,14 @@ def save( """ write_evokeds(fname, self, overwrite=overwrite) - @verbose + @verbose_static( + "export_fmt_support_evoked", + "export_warning", + "fname_export_params", + "export_fmt_params_evoked", + "overwrite", + "export_warning_note_evoked", + ) def export( self, fname: str, @@ -457,22 +465,41 @@ def export( ) -> None: """Export Evoked to external formats. - %(export_fmt_support_evoked)s + Supported formats: + + - MFF (``.mff``, uses :func:`mne.export.export_evokeds_mff`) - %(export_warning)s + .. warning:: + Since we are exporting to external formats, there's no guarantee that all + the info will be preserved in the external format. See Notes for details. Parameters ---------- - %(fname_export_params)s - %(export_fmt_params_evoked)s - %(overwrite)s - %(verbose)s + fname : str + Name of the output file. + fmt : 'auto' | 'mff' + Format of the export. Defaults to ``'auto'``, which will infer the format + from the filename extension. See supported formats above for more + information. + overwrite : bool + If True (default False), overwrite the destination file if it + exists. + verbose : bool | str | int | None + Control verbosity of the logging output. If ``None``, use the default + verbosity level. See the :ref:`logging documentation ` and + :func:`mne.verbose` for details. Should only be passed as a keyword + argument. Notes ----- .. versionadded:: 1.1 - %(export_warning_note_evoked)s + Export to external format may not preserve all the information from the + instance. To save in native MNE format (``.fif``) without information loss, + use :meth:`mne.Evoked.save` instead. + Export does not apply projector(s). Unapplied projector(s) will be lost. + Consider applying projector(s) before exporting with + :meth:`mne.Evoked.apply_proj`. """ from .export import export_evokeds diff --git a/mne/io/base.py b/mne/io/base.py index 624b9350229..754aa82ca2e 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -95,7 +95,7 @@ _validate_type, check_fname, copy_doc, - copy_function_doc_to_method_doc, + copy_function_doc_to_method_doc_static, fill_doc, logger, repr_html, @@ -2022,7 +2022,7 @@ def _tmin_tmax_to_start_stop(self, tmin, tmax): raise ValueError(f"tmin ({tmin}) and tmax ({tmax}) yielded no samples") return start, stop - @copy_function_doc_to_method_doc("func:mne.viz.plot_raw") + @copy_function_doc_to_method_doc_static("func:mne.viz.plot_raw") def plot( self, events: np.ndarray | None = None, @@ -2067,6 +2067,303 @@ def plot( verbose: bool | str | int | None = None, figure_class: type | None = None, ) -> "Figure | MNEQtBrowser": + """Plot raw data. + + Parameters + ---------- + events : array | None + Events to show with vertical bars. + duration : float + Time window (s) to plot. The lesser of this value and the duration + of the raw file will be used. + start : float + Initial time to show (can be changed dynamically once plotted). If + show_first_samp is True, then it is taken relative to + ``raw.first_samp``. + n_channels : int + Number of channels to plot at once. Defaults to 20. The lesser of + ``n_channels`` and ``len(raw.ch_names)`` will be shown. + Has no effect if ``order`` is 'position', 'selection' or 'butterfly'. + bgcolor : color object + Color of the background. + color : dict | color object | None + Color for the data traces. If None, defaults to:: + + dict(mag='darkblue', grad='b', eeg='k', eog='k', ecg='m', + emg='k', ref_meg='steelblue', misc='k', stim='k', + resp='k', chpi='k') + + If a dict, keys can be channel *types* (e.g., ``'eeg'``) and/or + channel *names* (e.g., ``'SFG, Left'``); name-based entries + take precedence over type-based ones. + + bad_color : color object + Color to make bad channels. + event_color : color object | dict | None + Color(s) to use for :term:`events`. To show all :term:`events` in the same + color, pass any matplotlib-compatible color. To color events differently, + pass a `dict` that maps event names or integer event numbers to colors + (must include entries for *all* events, or include a "fallback" entry with + key ``-1``). If ``None``, colors are chosen from the current Matplotlib + color cycle. + annotation_colors : dict | None + A dictionary mapping annotation description strings to colors. Use this to + override the default color assigned to specific annotation types (e.g., + ``dict(bad_segment='orange')``). Colors can be any valid Matplotlib color + specification. Keys that do not match any annotation description in the data + will trigger a warning. If ``None`` (default), automatic colors are used. + + .. versionadded:: 1.12.1 + annotation_regex : str + A regex pattern applied to each annotation's label. + Matching labels remain visible, non-matching labels are hidden. + + .. versionadded:: 1.11 + scalings : 'auto' | dict | None + Scaling factors for the traces. If a dictionary where any + value is ``'auto'``, the scaling factor is set to match the 99.5th + percentile of the respective data. If ``'auto'``, all scalings (for all + channel types) are set to ``'auto'``. If any values are ``'auto'`` and the + data is not preloaded, a subset up to 100 MB will be loaded. If ``None``, + defaults to:: + + dict(mag=1e-12, grad=4e-11, eeg=20e-6, eog=150e-6, ecg=5e-4, + emg=1e-3, ref_meg=1e-12, misc=1e-3, stim=1, + resp=1, chpi=1e-4, whitened=1e2) + + .. note:: + A particular scaling value ``s`` corresponds to half of the visualized + signal range around zero (i.e. from ``0`` to ``+s`` or from ``0`` to + ``-s``). For example, the default scaling of ``20e-6`` (20µV) for EEG + signals means that the visualized range will be 40 µV (20 µV in the + positive direction and 20 µV in the negative direction). + remove_dc : bool + If True remove DC component when plotting data. + order : array of int | None + Order in which to plot data. If the array is shorter than the number of + channels, only the given channels are plotted. If None (default), all + channels are plotted. If ``group_by`` is ``'position'`` or + ``'selection'``, the ``order`` parameter is used only for selecting the + channels to be plotted. + show_options : bool + If True, a dialog for options related to projection is shown. + title : str | None + The title of the window. If None, the filename of the raw object is + used; for in-memory instances without a filename (e.g., + `~mne.io.RawArray`), the class name and approximate size are used. + show : bool + Show figure if True. + block : bool + Whether to halt program execution until the figure is closed. + Useful for setting bad channels on the fly by clicking on a line. + May not work on all systems / platforms. + (Only Qt) If you run from a script, this needs to + be ``True`` or a Qt-eventloop needs to be started somewhere + else in the script (e.g. if you want to implement the browser + inside another Qt-Application). + highpass : float | None + Highpass to apply when displaying data. + lowpass : float | None + Lowpass to apply when displaying data. + If highpass > lowpass, a bandstop rather than bandpass filter + will be applied. + filtorder : int + Filtering order. 0 will use FIR filtering with MNE defaults. + Other values will construct an IIR filter of the given order + and apply it with :func:`~scipy.signal.filtfilt` (making the effective + order twice ``filtorder``). Filtering may produce some edge artifacts + (at the left and right edges) of the signals during display. + + .. versionchanged:: 0.18 + Support for ``filtorder=0`` to use FIR filtering. + clipping : str | float | None + If None, channels are allowed to exceed their designated bounds in + the plot. If "clamp", then values are clamped to the appropriate + range for display, creating step-like artifacts. If "transparent", + then excessive values are not shown, creating gaps in the traces. + If float, clipping occurs for values beyond the ``clipping`` multiple + of their dedicated range, so ``clipping=1.`` is an alias for + ``clipping='transparent'``. + + .. versionchanged:: 0.21 + Support for float, and default changed from None to 1.5. + show_first_samp : bool + If True, show time axis relative to the ``raw.first_samp``. + proj : bool + Whether to apply projectors prior to plotting (default is ``True``). + Individual projectors can be enabled/disabled interactively (see + Notes). This argument only affects the plot; use ``raw.apply_proj()`` + to modify the data stored in the Raw object. + group_by : str + How to group channels. ``'type'`` groups by channel type, + ``'original'`` plots in the order of ch_names, ``'selection'`` uses + Elekta's channel groupings (only works for Neuromag data), + ``'position'`` groups the channels by the positions of the sensors. + ``'selection'`` and ``'position'`` modes allow custom selections by + using a lasso selector on the topomap. In butterfly mode, ``'type'`` + and ``'original'`` group the channels by type, whereas ``'selection'`` + and ``'position'`` use regional grouping. ``'type'`` and ``'original'`` + modes are ignored when ``order`` is not ``None``. Defaults to ``'type'``. + butterfly : bool + Whether to start in butterfly mode. Defaults to False. + decim : int | 'auto' + Amount to decimate the data during display for speed purposes. + You should only decimate if the data are sufficiently low-passed, + otherwise aliasing can occur. The 'auto' mode (default) uses + the decimation that results in a sampling rate least three times + larger than ``min(info['lowpass'], lowpass)`` (e.g., a 40 Hz lowpass + will result in at least a 120 Hz displayed sample rate). + noise_cov : instance of Covariance | str | None + Noise covariance used to whiten the data while plotting. + Whitened data channels are scaled by ``scalings['whitened']``, + and their channel names are shown in italic. + Can be a string to load a covariance from disk. + See also :meth:`mne.Evoked.plot_white` for additional inspection + of noise covariance properties when whitening evoked data. + For data processed with SSS, the effective dependence between + magnetometers and gradiometers may introduce differences in scaling, + consider using :meth:`mne.Evoked.plot_white`. + + .. versionadded:: 0.16.0 + event_id : dict | None + Event IDs used to show at event markers (default None shows + the event numbers). + + .. versionadded:: 0.16.0 + show_scrollbars : bool + Whether to show scrollbars when the plot is initialized. Can be toggled + after initialization by pressing :kbd:`z` ("zen mode") while the plot + window is focused. Default is ``True``. + + .. versionadded:: 0.19.0 + show_scalebars : bool + Whether to show scale bars when the plot is initialized. Can be toggled + after initialization by pressing :kbd:`s` while the plot window is focused. + Default is ``True``. + show_zero_line : bool + Whether to show the zero line for each channel trace when the plot is + initialized. The line always marks the true zero of the channel, even + if the currently-visible window's mean has been subtracted for display + (see ``remove_dc``). Can be toggled after initialization by pressing + :kbd:`0` while the plot window is focused. Default is ``False``. + + .. versionadded:: 1.13 + time_format : 'float' | 'clock' + Style of time labels on the horizontal axis. If ``'float'``, labels will be + number of seconds from the start of the recording. If ``'clock'``, + labels will show "clock time" (hours/minutes/seconds) inferred from + ``raw.info['meas_date']``. Default is ``'float'``. + + .. versionadded:: 0.24 + precompute : bool | str + Whether to load all data (not just the visible portion) into RAM and + apply preprocessing (e.g., projectors) to the full data array in a separate + processor thread, instead of window-by-window during scrolling. The default + None uses the ``MNE_BROWSER_PRECOMPUTE`` variable, which defaults to + ``'auto'``. ``'auto'`` compares available RAM space to the expected size of + the precomputed data, and precomputes only if enough RAM is available. + This is only used with the Qt backend. + + .. versionadded:: 0.24 + .. versionchanged:: 1.0 + Support for the ``MNE_BROWSER_PRECOMPUTE`` config variable. + use_opengl : bool | None + Whether to use OpenGL when rendering the plot (requires ``pyopengl``). + May increase performance, but effect is dependent on system CPU and + graphics hardware. Only works if using the Qt backend. Default is + None, which will use False unless the user configuration variable + ``MNE_BROWSER_USE_OPENGL`` is set to ``'true'``, + see :func:`mne.set_config`. + + .. versionadded:: 0.24 + picks : str | array-like | slice | None + Channels to include. Slices and lists of integers will be interpreted as + channel indices. In lists, channel *type* strings (e.g., ``['meg', + 'eeg']``) will pick channels of those types, channel *name* strings (e.g., + ``['MEG0111', 'MEG2623']`` will pick the given channels. Can also be the + string values ``'all'`` to pick all channels, or ``'data'`` to pick + :term:`data channels`. None (default) will pick all channels. Bad channels + are included by default. Note that channels in ``info['bads']`` *will be + included* if their names or indices are explicitly provided. + theme : str | path-like + Can be "auto", "light", or "dark" or a path-like to a + custom stylesheet. For Dark-Mode and automatic Dark-Mode-Detection, + `qdarkstyle `__ and + `darkdetect `__, + respectively, are required. + If None (default), the config option MNE_BROWSER_THEME will be used, + defaulting to "auto" if it's not found. + + For the ``"matplotlib"`` backend, only ``"light"``, ``"dark"``, and + ``"auto"`` are supported. For the ``"qt"`` backend, a path-like to a + custom stylesheet is also accepted. + overview_mode : str | None + Can be "channels", "empty", or "hidden" to set the overview bar mode + for the ``'qt'`` backend. If None (default), the config option + ``MNE_BROWSER_OVERVIEW_MODE`` will be used, defaulting to "channels" + if it's not found. + splash : bool + If True (default), a splash screen is shown during the application + startup. Only applicable to the ``qt`` backend. + verbose : bool | str | int | None + Control verbosity of the logging output. If ``None``, use the default + verbosity level. See the :ref:`logging documentation ` and + :func:`mne.verbose` for details. Should only be passed as a keyword + argument. + figure_class : class + The backend specific ``MNEBrowseFigure`` class to use. This is typically + used to pass a subclass in order to customize the plot. This parameter + requires cooperation from the backend, and is currently only supported by + the ``matplotlib`` backend. + + Returns + ------- + fig : matplotlib.figure.Figure | mne_qt_browser.figure.MNEQtBrowser + Browser instance. + + Notes + ----- + The arrow keys (up/down/left/right) can typically be used to navigate + between channels and time ranges, but this depends on the backend + matplotlib is configured to use (e.g., mpl.use('TkAgg') should work). The + left/right arrows will scroll by 25%% of ``duration``, whereas + shift+left/shift+right will scroll by 100%% of ``duration``. The scaling + can be adjusted with - and + (or =) keys. The viewport dimensions can be + adjusted with page up/page down and home/end keys. Full screen mode can be + toggled with the F11 key, and scrollbars can be hidden/shown by pressing + 'z'. Right-click a channel label to view its location. To mark or un-mark a + channel as bad, click on a channel label or a channel trace. The changes + will be reflected immediately in the raw object's ``raw.info['bads']`` + entry. + + If projectors are present, a button labelled "Prj" in the lower right + corner of the plot window opens a secondary control window, which allows + enabling/disabling specific projectors individually. This provides a means + of interactively observing how each projector would affect the raw data if + it were applied. + + Annotation mode is toggled by pressing 'a', butterfly mode by pressing + 'b', and whitening mode (when ``noise_cov is not None``) by pressing 'w'. + By default, the channel means are removed when ``remove_dc`` is set to + ``True``. This flag can be toggled by pressing 'd'. + + MNE-Python provides two different backends for browsing plots (i.e., + :meth:`raw.plot()`, :meth:`epochs.plot()`, + and :meth:`ica.plot_sources()`). One is + based on :mod:`matplotlib`, and the other is based on + :doc:`PyQtGraph`. You can set the backend temporarily with the + context manager :func:`mne.viz.use_browser_backend`, you can set it for the + duration of a Python session using :func:`mne.viz.set_browser_backend`, and you + can set the default for your computer via + :func:`mne.set_config('MNE_BROWSER_BACKEND', 'matplotlib')` + (or ``'qt'``). + + .. note:: For the PyQtGraph backend to run in IPython with ``block=False`` + you must run the magic command ``%gui qt5`` first. + .. note:: To report issues with the PyQtGraph backend, please use the + `issues `_ + of ``mne-qt-browser``. + """ from ..viz import plot_raw return plot_raw( diff --git a/mne/time_frequency/tfr.py b/mne/time_frequency/tfr.py index 16c6d0fc85c..717861cb6a1 100644 --- a/mne/time_frequency/tfr.py +++ b/mne/time_frequency/tfr.py @@ -48,6 +48,7 @@ copy_doc, copy_function_doc_to_method_doc, fill_doc, + fill_doc_static, legacy, logger, object_diff, @@ -61,9 +62,9 @@ from .spectrum import EpochsSpectrum -@fill_doc +@fill_doc_static("morlet_reference", "fwhm_morlet_notes") def morlet(sfreq, freqs, n_cycles=7.0, sigma=None, zero_mean=False): - """Compute Morlet wavelets for the given frequency range. + r"""Compute Morlet wavelets for the given frequency range. Parameters ---------- @@ -97,8 +98,35 @@ def morlet(sfreq, freqs, n_cycles=7.0, sigma=None, zero_mean=False): Notes ----- - %(morlet_reference)s - %(fwhm_morlet_notes)s + The Morlet wavelets follow the formulation in :footcite:t:`Tallon-BaudryEtAl1997`. + Convolution of a signal with a Morlet wavelet will impose temporal smoothing + that is determined by the duration of the wavelet. In MNE-Python, the duration + of the wavelet is determined by the ``sigma`` parameter, which gives the + standard deviation of the wavelet's Gaussian envelope (our wavelets extend to + ±5 standard deviations to ensure values very close to zero at the endpoints). + Some authors (e.g., :footcite:t:`Cohen2019`) recommend specifying and reporting + wavelet duration in terms of the full-width half-maximum (FWHM) of the + wavelet's Gaussian envelope. The FWHM is related to ``sigma`` by the following + identity: :math:`\mathrm{FWHM} = \sigma \times 2 \sqrt{2 \ln{2}}` (or the + equivalent in Python code: ``fwhm = sigma * 2 * np.sqrt(2 * np.log(2))``). + If ``sigma`` is not provided, it is computed from ``n_cycles`` as + :math:`\frac{\mathtt{n\_cycles}}{2 \pi f}` where :math:`f` is the frequency of + the wavelet oscillation (given by ``freqs``). Thus when ``sigma=None`` the FWHM + will be given by + + .. math:: + + \mathrm{FWHM} = \frac{\mathtt{n\_cycles} \times \sqrt{2 \ln{2}}}{\pi \times f} + + (cf. eq. 4 in :footcite:`Cohen2019`). To create wavelets with a chosen FWHM, + one can compute:: + + n_cycles = desired_fwhm * np.pi * np.array(freqs) / np.sqrt(2 * np.log(2)) + + to get an array of values for ``n_cycles`` that yield the desired FWHM at each + frequency in ``freqs``. If you want different FWHM values at each frequency, + do the same computation with ``desired_fwhm`` as an array of the same shape as + ``freqs``. References ---------- @@ -134,7 +162,7 @@ def morlet(sfreq, freqs, n_cycles=7.0, sigma=None, zero_mean=False): color='k', linestyle='-', label='FWHM', zorder=6) ax.legend(loc='upper right') ax.set(xlabel='Time (s)', ylabel='Amplitude') - """ # noqa: E501 + """ Ws = list() n_cycles = np.array(n_cycles, float).ravel() @@ -1293,11 +1321,21 @@ def __abs__(self): tfr.data = np.abs(tfr.data) return tfr - @fill_doc + @fill_doc_static("__add__tfr") def __add__(self, other): """Add two TFR instances. - %(__add__tfr)s + Parameters + ---------- + other : instance of RawTFR | instance of EpochsTFR | instance of AverageTFR + The TFR instance to add. Must have the same type as ``self``, and matching + ``.times`` and ``.freqs`` attributes. + + + Returns + ------- + tfr : instance of RawTFR | instance of EpochsTFR | instance of AverageTFR + A new TFR instance, of the same type as ``self``. """ self._check_compatibility(other) out = self.copy() diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index 9d70ef0ba57..611521c061c 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -137,13 +137,16 @@ __all__ = [ "check_version", "compute_corr", "copy_doc", + "copy_doc_static", "copy_function_doc_to_method_doc", + "copy_function_doc_to_method_doc_static", "copytree_rw", "create_slices", "deprecated", "deprecated_alias", "eigh", "fill_doc", + "fill_doc_static", "filter_out_warnings", "get_config", "get_config_path", @@ -186,6 +189,7 @@ __all__ = [ "sys_info", "use_log_level", "verbose", + "verbose_static", "warn", "wrapped_stdout", ] @@ -203,6 +207,7 @@ from ._logging import ( set_log_level, use_log_level, verbose, + verbose_static, warn, wrapped_stdout, ) @@ -309,10 +314,13 @@ from .dataframe import ( from .docs import ( _doc_special_members, copy_doc, + copy_doc_static, copy_function_doc_to_method_doc, + copy_function_doc_to_method_doc_static, deprecated, deprecated_alias, fill_doc, + fill_doc_static, legacy, linkcode_resolve, open_docs, diff --git a/mne/utils/_logging.py b/mne/utils/_logging.py index cf876eb8918..0c8a1dd9386 100644 --- a/mne/utils/_logging.py +++ b/mne/utils/_logging.py @@ -103,7 +103,40 @@ def verbose(function: _FuncT) -> _FuncT: fill_doc(function) except TypeError: # nothing to add pass + return _wrap_verbose(function) + +def verbose_static(*keys: str) -> Callable[[_FuncT], _FuncT]: + """Verbose decorator for functions whose docstrings are statically filled. + + Behaves like :func:`mne.verbose` for log-level handling, but does **not** + modify the docstring at import time. The docstring must already contain the + fully expanded text of ``docdict["verbose"]`` (and of every key in ``keys``); + this is enforced by ``tools/hooks/check_static_docs.py`` (run via + pre-commit), which can also update the docstring with ``--fix`` when the + ``docdict`` entry changes. + + Parameters + ---------- + *keys : str + Additional ``docdict`` keys whose expanded text this docstring contains + (``"verbose"`` is implied). + + Returns + ------- + dec : callable + The decorator. + """ + + def dec(function: _FuncT) -> _FuncT: + out = _wrap_verbose(function) + out._static_doc_keys = ("verbose", *keys) + return out + + return dec + + +def _wrap_verbose(function: _FuncT) -> _FuncT: # Anything using verbose should have `verbose=None` in the signature. # This code path will raise an error if this is not the case. body = """\ diff --git a/mne/utils/docs.py b/mne/utils/docs.py index e9a87d0cd47..b3a39af9112 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -44,7 +44,8 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): merged = " ".join( line.strip() for line in docstring.rsplit("\n", maxsplit=maxsplit) ) - reflowed = "\n ".join(re.findall(rf".{{1,{width}}}(?:\s+|$)", merged)) + chunks = re.findall(rf".{{1,{width}}}(?:\s+|$)", merged) + reflowed = "\n ".join(chunk.rstrip() for chunk in chunks) if has_first_line: reflowed = reflowed.replace("\n \n", "\n", 1) return reflowed @@ -1724,10 +1725,10 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): docdict["figure_class"] = """ figure_class : class - The backend specific ``MNEBrowseFigure`` class to use. This is typically used - to pass a subclass in order to customize the plot. This parameter requires - cooperation from the backend, and is currently only supported by the - ``matplotlib`` backend. + The backend specific ``MNEBrowseFigure`` class to use. This is typically + used to pass a subclass in order to customize the plot. This parameter + requires cooperation from the backend, and is currently only supported by + the ``matplotlib`` backend. """ docdict["filter_length"] = """ @@ -4412,8 +4413,8 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): docdict["splash"] = """ splash : bool - If True (default), a splash screen is shown during the application startup. Only - applicable to the ``qt`` backend. + If True (default), a splash screen is shown during the application + startup. Only applicable to the ``qt`` backend. """ docdict["split_naming"] = """ @@ -4694,9 +4695,9 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): custom stylesheet. For Dark-Mode and automatic Dark-Mode-Detection, `qdarkstyle `__ and `darkdetect `__, - respectively, are required.\ + respectively, are required. If None (default), the config option {config_option} will be used, - defaulting to "auto" if it's not found.\ + defaulting to "auto" if it's not found. """ docdict["theme_3d"] = """ @@ -4705,9 +4706,9 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75): docdict["theme_pg"] = """ {theme} - For the ``"matplotlib"`` backend, only ``"light"``, ``"dark"``, - and ``"auto"`` are supported. For the ``"qt"`` backend, a path-like to a custom - stylesheet is also accepted. + For the ``"matplotlib"`` backend, only ``"light"``, ``"dark"``, and + ``"auto"`` are supported. For the ``"qt"`` backend, a path-like to a + custom stylesheet is also accepted. """.format(theme=_theme.format(config_option="MNE_BROWSER_THEME")) docdict["thresh"] = """ @@ -5323,6 +5324,87 @@ def fill_doc(f): return f +def fill_doc_static(*keys): + """Mark a docstring as containing statically expanded docdict entries. + + Unlike :func:`fill_doc`, this does not touch ``__doc__`` at import time, so + static analysis tools (IDEs, language servers) see the complete docstring. + The docstring must contain the expanded text of ``docdict[key]`` for every + ``key``; ``tools/hooks/check_static_docs.py`` (run via pre-commit) verifies + this and can rewrite the docstring with ``--fix`` when ``docdict`` changes. + Edits to shared text must be made in ``docdict``, not in the docstring. + + Parameters + ---------- + *keys : str + The ``docdict`` keys whose expanded text this docstring contains. + + Returns + ------- + dec : callable + The decorator, which returns its argument unchanged apart from a + ``_static_doc_keys`` attribute. + """ + + def dec(f): + f._static_doc_keys = tuple(keys) + return f + + return dec + + +def copy_doc_static(source): + """Mark a docstring as a static copy of another (see :func:`copy_doc`). + + Parameters + ---------- + source : str + The source, as ``"meth:mne.time_frequency.tfr.BaseTFR.plot"``. The + docstring must already contain its (cleaned) docstring, followed by any + text of its own; ``tools/hooks/check_static_docs.py`` enforces this. + + Returns + ------- + dec : callable + The decorator, which returns its argument unchanged apart from a + ``_static_doc_copy`` attribute. + """ + _check_lazy_doc_source(source, "meth") + + def dec(f): + f._static_doc_copy = source + return f + + return dec + + +def copy_function_doc_to_method_doc_static(source): + """Mark a method docstring as a static copy of a function's docstring. + + See :func:`copy_function_doc_to_method_doc` for the transformation applied. + + Parameters + ---------- + source : str + The source function, as ``"func:mne.viz.plot_raw"``. The docstring must + already contain its transformed docstring, followed by any text of its + own; ``tools/hooks/check_static_docs.py`` enforces this. + + Returns + ------- + dec : callable + The decorator, which returns its argument unchanged apart from a + ``_static_doc_copy`` attribute. + """ + _check_lazy_doc_source(source, "func") + + def dec(f): + f._static_doc_copy = source + return f + + return dec + + ############################################################################## # Utilities for docstring manipulation. diff --git a/mne/utils/tests/test_docs.py b/mne/utils/tests/test_docs.py index fb88ff7110f..28a4d87fa9e 100644 --- a/mne/utils/tests/test_docs.py +++ b/mne/utils/tests/test_docs.py @@ -2,7 +2,10 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import inspect import webbrowser +from pathlib import Path +from types import SimpleNamespace import pytest @@ -10,11 +13,15 @@ from mne.utils import ( catch_logging, copy_doc, + copy_doc_static, copy_function_doc_to_method_doc, + copy_function_doc_to_method_doc_static, deprecated, deprecated_alias, + fill_doc_static, legacy, linkcode_resolve, + verbose_static, ) @@ -100,6 +107,50 @@ def test_deprecated_and_legacy(msg, func, klass): assert msg.upper() in func.__doc__ +def test_static_doc_markers(): + """Test that the static docstring decorators leave docstrings untouched.""" + + @verbose_static("picks_all") + def func(verbose=None): + """Do a thing. + + Parameters + ---------- + verbose : bool | str | int | None + Control verbosity. + """ + from mne.utils import logger + + logger.info("static hello") + + # the docstring is exactly what was written (modulo compile-time dedenting) + assert func.__doc__.splitlines()[0] == "Do a thing." + assert "Control verbosity." in func.__doc__ + assert func._static_doc_keys == ("verbose", "picks_all") + with catch_logging() as log: + func(verbose=True) + assert "static hello" in log.getvalue() + with catch_logging() as log: + func(verbose=False) + assert log.getvalue() == "" + + @fill_doc_static("picks_all") + def filled(): + """Unchanged %(picks_all)s.""" + + assert filled.__doc__ == "Unchanged %(picks_all)s." + assert filled._static_doc_keys == ("picks_all",) + + @copy_function_doc_to_method_doc_static("func:mne.viz.plot_raw") + def copied(): + """Unchanged.""" + + assert copied.__doc__ == "Unchanged." + assert copied._static_doc_copy == "func:mne.viz.plot_raw" + with pytest.raises(ValueError, match="must look like"): + copy_doc_static("func:mne.viz.plot_raw") + + def test_copy_doc(): """Test decorator for copying docstrings.""" @@ -258,3 +309,237 @@ def test_linkcode_resolve(): "py", dict(module="mne", fullname="datasets.sample.data_path") ) assert "/mne/datasets/sample/sample.py" + ex in url + + +def _load_hook(): + import importlib.util + + import mne + + path = Path(mne.__file__).parents[1] / "tools" / "hooks" / "check_static_docs.py" + if not path.is_file(): + pytest.skip("not running from a source checkout") + spec = importlib.util.spec_from_file_location("check_static_docs", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_HOOK_DOCDICT = { + "alpha": "\nalpha : int\n The alpha. It is shared.\n", + "notes_shared": "\nFirst shared paragraph.\nLine two.\n\nSecond paragraph.\n", + "verbose": "\nverbose : bool | str | int | None\n Control verbosity.\n", +} +_HOOK_MODULE = '''\ +from mne.utils import fill_doc_static, verbose_static +from mne.utils import copy_function_doc_to_method_doc_static + + +@verbose_static() +def func(alpha, verbose=None): + """Do a thing. + + Parameters + ---------- + %(alpha)s + %(verbose)s + + Notes + ----- + %(notes_shared)s + This line is specific to func. + """ + + +class Klass: + @copy_function_doc_to_method_doc_static("func:mne.baseline.rescale") + def rescale(self, times, baseline, mode="mean", copy=True, picks=None): + pass +''' + + +def test_check_static_docs(tmp_path, monkeypatch): + """Test the static docstring pre-commit hook.""" + hook = _load_hook() + docs_py = tmp_path / "docs.py" + docs_py.write_text( + "docdict = {}\n" + + "".join(f'docdict["{k}"] = """{v}"""\n' for k, v in _HOOK_DOCDICT.items()) + ) + monkeypatch.setattr(hook, "docdict", dict(_HOOK_DOCDICT)) + monkeypatch.setattr(hook, "DOCS_PY", docs_py) + monkeypatch.setattr(hook, "_old_docdict", lambda: dict(_HOOK_DOCDICT)) + path = tmp_path / "mod.py" + path.write_text(_HOOK_MODULE) + + # 1. migration: placeholders are expanded and keys added to the decorator + assert hook.process_file(path, True, {}) == [] + assert hook.process_file(path, False, {}) == [] # now in sync + source = path.read_text() + assert '@verbose_static("alpha", "notes_shared")' in source + assert " alpha : int\n The alpha. It is shared.\n" in source + assert " Second paragraph.\n This line is specific to func.\n" in source + # the copied docstring was inserted (first parameter dropped) + assert '"""Rescale (baseline correct) data.' in source + assert " data : array" not in source and " times : 1D array" in source + + # 2. forward sync: docdict changed, the docstring (and only the shared part) + # is updated + hook.docdict["alpha"] = "\nalpha : int\n The alpha. It changed.\n" + hook.docdict["notes_shared"] = "\nFirst shared paragraph, edited.\n\nSecond.\n" + errors = hook.process_file(path, False, {}) + assert len(errors) == 1 and "out of sync" in errors[0] + assert hook.process_file(path, True, {}) == [] + source = path.read_text() + assert "It changed." in source and "It is shared." not in source + assert " Second.\n This line is specific to func.\n" in source + + # 3. reverse sync: docdict unchanged since HEAD but a docstring copy edited + monkeypatch.setattr(hook, "_old_docdict", lambda: dict(hook.docdict)) + path.write_text(path.read_text().replace("Control verbosity.", "Be loud.")) + reverse = {} + assert hook.process_file(path, False, reverse) == [] + assert reverse == {"verbose": ["verbose : bool | str | int | None", " Be loud."]} + written, errors = hook._write_docdict_entries(reverse) + assert written == ["verbose"] and errors == [] + want = 'docdict["verbose"] = """\nverbose : bool | str | int | None\n Be loud.' + assert want + '\n"""' in docs_py.read_text() + # a templated entry cannot be written back + docs_py.write_text( + docs_py.read_text().replace( + 'docdict["alpha"] = """', 'docdict["alpha"] = "" + """' + ) + ) + _, errors = hook._write_docdict_entries({"alpha": ["alpha : int", " x"]}) + assert len(errors) == 1 and "by hand" in errors[0] + + # 4. site-specific text after a shared block: the previous version of the + # docstring (git HEAD) tells the two apart even when the shared part + # changes length + own = " This line is specific to func." + source = path.read_text() + assert " Second.\n" + own in source + # pretend the current file is what git HEAD has + monkeypatch.setattr(hook, "REPO", tmp_path) + monkeypatch.setattr( + hook.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(stdout=source) + ) + hook._old_bodies.cache_clear() + # (a) a line added right after the shared text is shared (pushed to docdict) + path.write_text(source.replace(" Second.\n", " Second.\n Third.\n")) + reverse = {} + assert hook.process_file(path, False, reverse) == [] + want = ["First shared paragraph, edited.", "", "Second.", "Third."] + assert reverse["notes_shared"] == want + # (b) ... unless it starts with a blank line, the site-specific convention + alpha = " alpha : int\n The alpha. It changed.\n" + assert alpha in source + path.write_text(source.replace(alpha, alpha + "\n .. versionadded:: 1.0\n")) + reverse = {} + assert hook.process_file(path, False, reverse) == [] and reverse == {} + path.write_text(source.replace(alpha, alpha + " More alpha.\n")) + reverse = {} + assert hook.process_file(path, False, reverse) == [] + assert reverse == { + "alpha": ["alpha : int", " The alpha. It changed.", " More alpha."] + } + # (c) a line removed from the shared text does not swallow the site's own line + path.write_text(source.replace(" Second.\n", "")) + reverse = {} + assert hook.process_file(path, False, reverse) == [] + assert reverse["notes_shared"] == ["First shared paragraph, edited."] + assert own in path.read_text() + # (d) editing the site-specific text alone is not a shared edit + path.write_text(source.replace(own, " Specific, edited.")) + reverse = {} + assert hook.process_file(path, False, reverse) == [] and reverse == {} + # (e) editing both is refused rather than guessed + path.write_text(source.replace(" Second.\n", "").replace(own, " Both.")) + errors = hook.process_file(path, False, {}) + assert len(errors) == 1 and "both the shared text" in errors[0] + # (f) forward sync keeps the site's own text when docdict grows + path.write_text(source) + snapshot = dict(hook.docdict) + monkeypatch.setattr(hook, "_old_docdict", lambda: snapshot) + hook.docdict["notes_shared"] = "\nFirst shared paragraph, edited.\n\nSecond.\nMore." + assert hook.process_file(path, True, {}) == [] + assert " Second.\n More.\n" + own in path.read_text() + + # 5. the E501 suppression comment follows the need for it + source = path.read_text() + assert '""" # noqa: E501' in source # the copied rescale docstring is wide + stale = source.replace( + ' """\n\n\nclass Klass', ' """ # noqa: E501\n\n\nclass Klass' + ) + assert stale != source + path.write_text(stale) + errors = hook.process_file(path, False, {}) + assert len(errors) == 1 and "no longer needs" in errors[0] + assert hook.process_file(path, True, {}) == [] + assert path.read_text() == source + + # 6. a block whose anchor (first line) is gone is an error, not a silent pass + path.write_text(path.read_text().replace("First shared paragraph, edited.", "?")) + errors = hook.process_file(path, False, {}) + assert len(errors) == 1 and "could not find" in errors[0] + + +_PARA_TWO = "\nPara one line A.\nPara one line B.\n\nPara two.\n" +_PARA_ONE = "\nPara one collapsed.\n" +_PARA_THREE = _PARA_TWO + "\nPara three added.\n" +_PARA_FIRST = "\nPara one line A.\nPara one line B.\n" + + +def _para_module(note): + body = note.strip("\n").replace("\n", "\n ") + return f'''from mne.utils import fill_doc_static +@fill_doc_static("note") +def f(): + """Do. + + Notes + ----- + {body} + + This paragraph is unrelated local text. + """ +''' + + +@pytest.mark.parametrize( + "docdict_now, module_now, n_errors, reverse_keys", + [ + (_PARA_ONE, _PARA_TWO, 0, []), # collapsed in docdict, first line new + (_PARA_FIRST, _PARA_TWO, 0, []), # collapsed in docdict, first para kept + (_PARA_THREE, _PARA_TWO, 0, []), # expanded in docdict + (_PARA_TWO, _PARA_ONE, 1, []), # collapsed locally, first line new: error + (_PARA_TWO, _PARA_FIRST, 0, ["note"]), # collapsed locally, first para kept + (_PARA_TWO, _PARA_TWO, 0, []), # untouched + ], +) +def test_check_static_docs_paragraph_count( + tmp_path, monkeypatch, docdict_now, module_now, n_errors, reverse_keys +): + """Test shared blocks growing or shrinking by whole paragraphs.""" + hook = _load_hook() + head_module = _para_module(_PARA_TWO) + path = tmp_path / "mod.py" + path.write_text(_para_module(module_now)) + monkeypatch.setattr(hook, "docdict", {"note": docdict_now}) + monkeypatch.setattr(hook, "DOCS_PY", tmp_path / "docs.py") + monkeypatch.setattr(hook, "_old_docdict", lambda: {"note": _PARA_TWO}) + monkeypatch.setattr(hook, "REPO", tmp_path) + monkeypatch.setattr( + hook.subprocess, "run", lambda *a, **k: SimpleNamespace(stdout=head_module) + ) + hook._old_bodies.cache_clear() + reverse = {} + errors = hook.process_file(path, True, reverse) + assert len(errors) == n_errors, errors + assert list(reverse) == reverse_keys + body = path.read_text() + # the docstring's own local paragraph is never swallowed or dropped + assert "This paragraph is unrelated local text." in body + assert "unrelated local text" not in str(reverse) + if not n_errors and not reverse_keys: # forward: docstring matches docdict + assert docdict_now.strip("\n") in inspect.cleandoc(body.split('"""')[1]), body diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index 324d7f79baa..905db9f2447 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -31,6 +31,7 @@ _to_rgb, _validate_type, fill_doc, + fill_doc_static, logger, verbose, warn, @@ -1353,7 +1354,7 @@ def plot_evoked_topo( ) -@fill_doc +@fill_doc_static("picks_all", "sphere_topomap_auto") def plot_evoked_image( evoked, picks=None, @@ -1384,7 +1385,15 @@ def plot_evoked_image( ---------- evoked : instance of Evoked The evoked data. - %(picks_all)s + picks : str | array-like | slice | None + Channels to include. Slices and lists of integers will be interpreted as + channel indices. In lists, channel *type* strings (e.g., ``['meg', + 'eeg']``) will pick channels of those types, channel *name* strings (e.g., + ``['MEG0111', 'MEG2623']`` will pick the given channels. Can also be the + string values ``'all'`` to pick all channels, or ``'data'`` to pick + :term:`data channels`. None (default) will pick all channels. Bad channels + are included by default. Note that channels in ``info['bads']`` *will be + included* if their names or indices are explicitly provided. This parameter can also be used to set the order the channels are shown in, as the channel image is sorted by the order of picks. exclude : list of str | 'bads' @@ -1485,13 +1494,45 @@ def plot_evoked_image( group_by=dict(Left_ROI=[1, 2, 3, 4], Right_ROI=[5, 6, 7, 8]) If None, all picked channels are plotted to the same axis. - %(sphere_topomap_auto)s + sphere : float | array-like of float | instance of ConductorModel | str | list of str | None + The sphere parameters to use for the head outline. + Can be array-like of shape (4,) to give the X/Y/Z origin and radius in meters, or a + single float to give just the radius (origin assumed 0, 0, 0). + Can also be an instance of a spherical :class:`~mne.bem.ConductorModel` to use the + origin and radius from that object. + Can also be a ``str``, in which case: + + - ``'auto'``: the sphere is fit to external digitization points first, and to + external + EEG digitization points if the former fails. + + - ``'eeglab'``: the head circle is defined by EEG electrodes ``'Fpz'``, ``'Oz'``, + ``'T7'``, and ``'T8'`` (if ``'Fpz'`` is not present, it will be approximated from + the coordinates of ``'Oz'``). + + - ``'extra'``: the sphere is fit to external digitization points. + + - ``'eeg'``: the sphere is fit to EEG digitization points. + + - ``'cardinal'``: the sphere is fit to cardinal digitization points. + + - ``'hpi'``: the sphere is fit to HPI coil digitization points. + + Can also be a list of ``str``, in which case the sphere is fit to the specified + digitization points, which can be any combination of ``'extra'``, ``'eeg'``, + ``'cardinal'``, and ``'hpi'``, as specified above. + ``None`` (the default) is equivalent to ``'auto'`` when enough extra digitization + points are available, and (0, 0, 0, 0.095) otherwise. + + .. versionadded:: 0.20 + .. versionchanged:: 1.1 Added ``'eeglab'`` option. + .. versionchanged:: 1.11 Added ``'extra'``, ``'eeg'``, ``'cardinal'``, ``'hpi'`` and + list of ``str`` options. Returns ------- fig : instance of matplotlib.figure.Figure Figure containing the images. - """ + """ # noqa: E501 return _plot_evoked( evoked=evoked, picks=picks, diff --git a/mne/viz/raw.py b/mne/viz/raw.py index d4899b601cd..f49618cdacf 100644 --- a/mne/viz/raw.py +++ b/mne/viz/raw.py @@ -18,6 +18,7 @@ legacy, sizeof_fmt, verbose, + verbose_static, ) from ..utils.spectrum import _split_psd_kwargs from .utils import ( @@ -32,7 +33,24 @@ ) -@verbose +@verbose_static( + "event_color", + "scalings", + "group_by_browse", + "show_scrollbars", + "show_scalebars", + "show_zero_line", + "time_format", + "precompute", + "use_opengl", + "picks_all", + "theme_pg", + "overview_mode", + "splash", + "figure_class", + "browser", + "notes_2d_backend", +) def plot_raw( raw, events=None, @@ -111,8 +129,13 @@ def plot_raw( bad_color : color object Color to make bad channels. - %(event_color)s - Defaults to ``'cyan'``. + event_color : color object | dict | None + Color(s) to use for :term:`events`. To show all :term:`events` in the same + color, pass any matplotlib-compatible color. To color events differently, + pass a `dict` that maps event names or integer event numbers to colors + (must include entries for *all* events, or include a "fallback" entry with + key ``-1``). If ``None``, colors are chosen from the current Matplotlib + color cycle. annotation_colors : dict | None A dictionary mapping annotation description strings to colors. Use this to override the default color assigned to specific annotation types (e.g., @@ -126,7 +149,24 @@ def plot_raw( Matching labels remain visible, non-matching labels are hidden. .. versionadded:: 1.11 - %(scalings)s + scalings : 'auto' | dict | None + Scaling factors for the traces. If a dictionary where any + value is ``'auto'``, the scaling factor is set to match the 99.5th + percentile of the respective data. If ``'auto'``, all scalings (for all + channel types) are set to ``'auto'``. If any values are ``'auto'`` and the + data is not preloaded, a subset up to 100 MB will be loaded. If ``None``, + defaults to:: + + dict(mag=1e-12, grad=4e-11, eeg=20e-6, eog=150e-6, ecg=5e-4, + emg=1e-3, ref_meg=1e-12, misc=1e-3, stim=1, + resp=1, chpi=1e-4, whitened=1e2) + + .. note:: + A particular scaling value ``s`` corresponds to half of the visualized + signal range around zero (i.e. from ``0`` to ``+s`` or from ``0`` to + ``-s``). For example, the default scaling of ``20e-6`` (20µV) for EEG + signals means that the visualized range will be 40 µV (20 µV in the + positive direction and 20 µV in the negative direction). remove_dc : bool If True remove DC component when plotting data. order : array of int | None @@ -184,7 +224,16 @@ def plot_raw( Individual projectors can be enabled/disabled interactively (see Notes). This argument only affects the plot; use ``raw.apply_proj()`` to modify the data stored in the Raw object. - %(group_by_browse)s + group_by : str + How to group channels. ``'type'`` groups by channel type, + ``'original'`` plots in the order of ch_names, ``'selection'`` uses + Elekta's channel groupings (only works for Neuromag data), + ``'position'`` groups the channels by the positions of the sensors. + ``'selection'`` and ``'position'`` modes allow custom selections by + using a lasso selector on the topomap. In butterfly mode, ``'type'`` + and ``'original'`` group the channels by type, whereas ``'selection'`` + and ``'position'`` use regional grouping. ``'type'`` and ``'original'`` + modes are ignored when ``order`` is not ``None``. Defaults to ``'type'``. butterfly : bool Whether to start in butterfly mode. Defaults to False. decim : int | 'auto' @@ -211,32 +260,96 @@ def plot_raw( the event numbers). .. versionadded:: 0.16.0 - %(show_scrollbars)s - %(show_scalebars)s - - .. versionadded:: 0.20.0 - %(show_zero_line)s - %(time_format)s - %(precompute)s - %(use_opengl)s - %(picks_all)s - %(theme_pg)s - - .. versionadded:: 1.0 - %(overview_mode)s - - .. versionadded:: 1.1 - %(splash)s - - .. versionadded:: 1.6 - %(verbose)s - %(figure_class)s + show_scrollbars : bool + Whether to show scrollbars when the plot is initialized. Can be toggled + after initialization by pressing :kbd:`z` ("zen mode") while the plot + window is focused. Default is ``True``. + + .. versionadded:: 0.19.0 + show_scalebars : bool + Whether to show scale bars when the plot is initialized. Can be toggled + after initialization by pressing :kbd:`s` while the plot window is focused. + Default is ``True``. + show_zero_line : bool + Whether to show the zero line for each channel trace when the plot is + initialized. The line always marks the true zero of the channel, even + if the currently-visible window's mean has been subtracted for display + (see ``remove_dc``). Can be toggled after initialization by pressing + :kbd:`0` while the plot window is focused. Default is ``False``. .. versionadded:: 1.13 + time_format : 'float' | 'clock' + Style of time labels on the horizontal axis. If ``'float'``, labels will be + number of seconds from the start of the recording. If ``'clock'``, + labels will show "clock time" (hours/minutes/seconds) inferred from + ``raw.info['meas_date']``. Default is ``'float'``. + + .. versionadded:: 0.24 + precompute : bool | str + Whether to load all data (not just the visible portion) into RAM and + apply preprocessing (e.g., projectors) to the full data array in a separate + processor thread, instead of window-by-window during scrolling. The default + None uses the ``MNE_BROWSER_PRECOMPUTE`` variable, which defaults to + ``'auto'``. ``'auto'`` compares available RAM space to the expected size of + the precomputed data, and precomputes only if enough RAM is available. + This is only used with the Qt backend. + + .. versionadded:: 0.24 + .. versionchanged:: 1.0 + Support for the ``MNE_BROWSER_PRECOMPUTE`` config variable. + use_opengl : bool | None + Whether to use OpenGL when rendering the plot (requires ``pyopengl``). + May increase performance, but effect is dependent on system CPU and + graphics hardware. Only works if using the Qt backend. Default is + None, which will use False unless the user configuration variable + ``MNE_BROWSER_USE_OPENGL`` is set to ``'true'``, + see :func:`mne.set_config`. + + .. versionadded:: 0.24 + picks : str | array-like | slice | None + Channels to include. Slices and lists of integers will be interpreted as + channel indices. In lists, channel *type* strings (e.g., ``['meg', + 'eeg']``) will pick channels of those types, channel *name* strings (e.g., + ``['MEG0111', 'MEG2623']`` will pick the given channels. Can also be the + string values ``'all'`` to pick all channels, or ``'data'`` to pick + :term:`data channels`. None (default) will pick all channels. Bad channels + are included by default. Note that channels in ``info['bads']`` *will be + included* if their names or indices are explicitly provided. + theme : str | path-like + Can be "auto", "light", or "dark" or a path-like to a + custom stylesheet. For Dark-Mode and automatic Dark-Mode-Detection, + `qdarkstyle `__ and + `darkdetect `__, + respectively, are required. + If None (default), the config option MNE_BROWSER_THEME will be used, + defaulting to "auto" if it's not found. + + For the ``"matplotlib"`` backend, only ``"light"``, ``"dark"``, and + ``"auto"`` are supported. For the ``"qt"`` backend, a path-like to a + custom stylesheet is also accepted. + overview_mode : str | None + Can be "channels", "empty", or "hidden" to set the overview bar mode + for the ``'qt'`` backend. If None (default), the config option + ``MNE_BROWSER_OVERVIEW_MODE`` will be used, defaulting to "channels" + if it's not found. + splash : bool + If True (default), a splash screen is shown during the application + startup. Only applicable to the ``qt`` backend. + verbose : bool | str | int | None + Control verbosity of the logging output. If ``None``, use the default + verbosity level. See the :ref:`logging documentation ` and + :func:`mne.verbose` for details. Should only be passed as a keyword + argument. + figure_class : class + The backend specific ``MNEBrowseFigure`` class to use. This is typically + used to pass a subclass in order to customize the plot. This parameter + requires cooperation from the backend, and is currently only supported by + the ``matplotlib`` backend. Returns ------- - %(browser)s + fig : matplotlib.figure.Figure | mne_qt_browser.figure.MNEQtBrowser + Browser instance. Notes ----- @@ -264,7 +377,22 @@ def plot_raw( By default, the channel means are removed when ``remove_dc`` is set to ``True``. This flag can be toggled by pressing 'd'. - %(notes_2d_backend)s + MNE-Python provides two different backends for browsing plots (i.e., + :meth:`raw.plot()`, :meth:`epochs.plot()`, + and :meth:`ica.plot_sources()`). One is + based on :mod:`matplotlib`, and the other is based on + :doc:`PyQtGraph`. You can set the backend temporarily with the + context manager :func:`mne.viz.use_browser_backend`, you can set it for the + duration of a Python session using :func:`mne.viz.set_browser_backend`, and you + can set the default for your computer via + :func:`mne.set_config('MNE_BROWSER_BACKEND', 'matplotlib')` + (or ``'qt'``). + + .. note:: For the PyQtGraph backend to run in IPython with ``block=False`` + you must run the magic command ``%gui qt5`` first. + .. note:: To report issues with the PyQtGraph backend, please use the + `issues `_ + of ``mne-qt-browser``. """ from ..annotations import _annotations_starts_stops from ..io import BaseRaw diff --git a/tools/dev/ensure_headers.py b/tools/dev/ensure_headers.py index a4095d82b42..01b8ea4802a 100644 --- a/tools/dev/ensure_headers.py +++ b/tools/dev/ensure_headers.py @@ -49,20 +49,18 @@ def get_paths_from_tree(root, level=0): def first_commentable_line(lines): """Find the first line where we can add a comment.""" max_len = 100 - if lines[0].startswith(('"""', 'r"""')): - if lines[0].count('"""') == 2: - return 1 - for insert in range(1, min(max_len, len(lines))): + start = 1 if lines[0].startswith("#!") else 0 # keep a shebang first + if lines[start].startswith(('"""', 'r"""')): + if lines[start].count('"""') == 2: + return start + 1 + for insert in range(start + 1, min(start + max_len, len(lines))): if '"""' in lines[insert]: return insert + 1 else: raise RuntimeError( f"Failed to find end of file docstring within {max_len} lines" ) - if lines[0].startswith("#!"): - return 1 - else: - return 0 + return start def path_multi_author(path): diff --git a/tools/hooks/check_static_docs.py b/tools/hooks/check_static_docs.py new file mode 100755 index 00000000000..8af7e5e6641 --- /dev/null +++ b/tools/hooks/check_static_docs.py @@ -0,0 +1,770 @@ +#!/usr/bin/env python +"""Check (or fix) statically filled docstrings against ``mne.utils.docs.docdict``. + +Functions/methods decorated with ``@fill_doc_static(*keys)`` or +``@verbose_static(*keys)`` must contain, verbatim, the expanded text of +``docdict[key]`` for each key (``verbose_static`` implies the ``"verbose"`` key). +Methods decorated with ``@copy_doc_static(source)`` or +``@copy_function_doc_to_method_doc_static(source)`` must start with the +(transformed) docstring of ``source``. Unlike the dynamic decorators, nothing is +substituted at import time, so IDEs and other static tools see the complete +docstring. + +Usage:: + + python tools/hooks/check_static_docs.py [--fix] FILE [FILE ...] + +Without ``--fix`` a non-zero exit status and a diff are emitted for every +docstring that is out of sync; with ``--fix`` the files are rewritten. Leftover +``%(key)s`` placeholders are expanded (and added to the decorator), which makes +migrating a function from ``@fill_doc`` a matter of renaming the decorator and +running ``--fix``. + +How blocks are located (no fences are needed): + +- A *parameter* entry (``name : type`` ...) is found by its parameter name and + extends to the next line at the same or lower indentation. +- Any other entry is found by its first line and spans as many paragraphs as the + ``docdict`` entry has. If the first line itself changed, the previous version of + ``docdict`` (from ``git HEAD``) is used to find the old block. + +Text specific to one docstring may follow a shared block (a ``.. versionadded::`` +note, an extra sentence); the block's previous version (from ``git HEAD``) is used +to tell the two apart when the shared part changes. New lines added directly +after the shared text count as shared; start them with a blank line to mark them +as specific to the docstring. + +Shared text may be edited either in ``mne/utils/docs.py`` or in one docstring: the +side that changed since ``git HEAD`` wins and is propagated to the other (with +``--fix``); edits that cannot be attributed to one side are rejected. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import argparse +import ast +import difflib +import functools +import importlib +import inspect +import re +import subprocess +import sys +import types +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +# Make the repo's ``mne`` importable from the isolated pre-commit environment. +sys.path.insert(0, str(REPO)) +from mne.utils.docs import _copy_doc, _copy_function_doc, docdict # noqa: E402 + +_FILL_DECORATORS = {"fill_doc_static", "verbose_static"} +_COPY_DECORATORS = {"copy_doc_static", "copy_function_doc_to_method_doc_static"} +_PLACEHOLDER_RE = re.compile(r"%\((\w+)\)s") +_PARAM_RE = re.compile(r"^(\*{0,2}\w+(?:, \*{0,2}\w+)*)\s*:") +_LINE_LENGTH = 88 # ruff's default, which pyproject.toml does not override + + +class DocError(Exception): + """Error in a statically documented function.""" + + +# -------------------------------------------------------------------------- +# docdict access + + +def _entry_lines(text): + """Split a docdict entry into lines without surrounding blank lines.""" + return [line.rstrip() for line in text.strip("\n").splitlines()] + + +@functools.cache +def _old_docdict(): + """Return the docdict from ``git HEAD``, to locate blocks whose text changed.""" + try: + source = subprocess.run( + ["git", "show", "HEAD:mne/utils/docs.py"], + capture_output=True, + text=True, + check=True, + cwd=REPO, + ).stdout + except (OSError, subprocess.CalledProcessError): + return {} + module = types.ModuleType("mne.utils._docs_head") + module.__package__ = "mne.utils" + try: + exec(compile(source, "", "exec"), module.__dict__) + except Exception: + return {} + return dict(module.docdict) + + +def _iter_functions(tree): + """Yield ``(qualname, node)`` for every function definition in ``tree``.""" + stack = [(tree, "")] + while stack: + node, prefix = stack.pop() + for child in ast.iter_child_nodes(node): + if isinstance(child, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef): + qualname = f"{prefix}{child.name}" + if not isinstance(child, ast.ClassDef): + yield qualname, child + stack.append((child, qualname + ".")) + else: + stack.append((child, prefix)) + + +def _docstring_node(node): + body0 = node.body[0] if node.body else None + if ( + isinstance(body0, ast.Expr) + and isinstance(body0.value, ast.Constant) + and isinstance(body0.value.value, str) + ): + return body0.value + return None + + +@functools.cache +def _old_bodies(path): + """Return {qualname: docstring body} for ``path`` as of ``git HEAD``.""" + try: + source = subprocess.run( + ["git", "show", f"HEAD:{path.resolve().relative_to(REPO).as_posix()}"], + capture_output=True, + text=True, + check=True, + cwd=REPO, + ).stdout + tree = ast.parse(source) + except (OSError, ValueError, subprocess.CalledProcessError, SyntaxError): + return {} + offset = _offsets(source) + out = {} + for qualname, node in _iter_functions(tree): + const = _docstring_node(node) + if const is None: + continue + start = offset(const.lineno, const.col_offset) + stop = offset(const.end_lineno, const.end_col_offset) + try: + out[qualname] = _literal_parts(source[start:stop])[1] + except DocError: + pass + return out + + +def _indent_of(line): + return len(line) - len(line.lstrip()) + + +def _paragraphs(lines, start): + """Yield (start, stop) spans of consecutive non-blank lines from ``start``.""" + ii = start + while ii < len(lines): + if not lines[ii].strip(): + ii += 1 + continue + stop = ii + while stop < len(lines) and lines[stop].strip(): + stop += 1 + yield ii, stop + ii = stop + + +def _n_paragraphs(lines): + return sum(1 for _ in _paragraphs(lines, 0)) + + +# -------------------------------------------------------------------------- +# locating and replacing blocks + + +def _reindent(entry, indent): + return [indent + line.rstrip() if line.strip() else "" for line in entry] + + +def _deindent(lines, indent): + return [line[indent:].rstrip() if line.strip() else "" for line in lines] + + +def _block_end(lines, start, entry): + """Return the end of the block starting at ``start`` for ``entry``.""" + if _PARAM_RE.match(entry[0]): # structural: until the next line at <= indent + indent = _indent_of(lines[start]) + stop = start + 1 + while stop < len(lines): + line = lines[stop] + if line.strip() and _indent_of(line) <= indent: + break + stop += 1 + else: # prose: as many paragraphs as the entry has + spans = list(_paragraphs(lines, start))[: _n_paragraphs(entry)] + stop = spans[-1][1] if spans else start + 1 + while stop > start + 1 and not lines[stop - 1].strip(): # trailing blanks + stop -= 1 + return stop + + +def _anchor_candidates(lines, entry, min_indent): + """Return line indices where ``entry`` could start.""" + first = entry[0].strip() + match = _PARAM_RE.match(first) + anchor = match.group(1) if match else first + out = [] + for ii, line in enumerate(lines): + if _indent_of(line) < min_indent or not line.strip(): + continue + stripped = line.strip() + if match: + this = _PARAM_RE.match(stripped) + if this and this.group(1) == anchor: + out.append(ii) + elif stripped == anchor: + out.append(ii) + return out + + +def _score(lines, start, entry): + """Count how many leading lines of ``entry`` match the docstring at ``start``.""" + indent = " " * _indent_of(lines[start]) + want = _reindent(entry, indent) + got = lines[start : start + len(want)] + return sum(1 for a, b in zip(want, got) if a.rstrip() == b.rstrip()) + + +def _locate(lines, key, entry, min_indent): + """Return ``(start, stop, version)`` of the block for ``entry`` in ``lines``. + + ``version`` is the entry text the block was found with: the current one, or + the ``git HEAD`` one if the entry's anchor line changed. + """ + versions = [entry] + old = _old_docdict().get(key) + if old is not None and _entry_lines(old) != entry: + versions.append(_entry_lines(old)) + for version in versions: + candidates = _anchor_candidates(lines, version, min_indent) + if not candidates: + continue + scored = sorted((_score(lines, c, version), c) for c in candidates) + best, start = scored[-1] + if len(scored) > 1 and scored[-2][0] == best: + raise DocError( + f"ambiguous location for docdict[{key!r}]: lines " + f"{[c + 1 for sc, c in scored if sc == best]} all start with " + f"{version[0].strip()!r}" + ) + return start, _block_end(lines, start, version), version + raise DocError( + f"could not find the block for docdict[{key!r}] (first line " + f"{entry[0].strip()!r}); add it (``%({key})s`` on its own line) or paste it " + "by hand" + ) + + +def _expand_placeholders(lines, keys, entries=None): + """Expand ``%(key)s`` lines in place; return the keys found.""" + entries = docdict if entries is None else entries + found = [] + out = [] + for line in lines: + found_here = _PLACEHOLDER_RE.findall(line) + if not found_here: + out.append(line) + continue + if len(found_here) > 1 or not line.strip().startswith("%("): + raise DocError( + f"inline placeholder use {line.strip()!r} is not supported; " + "write the text out by hand" + ) + key = found_here[0] + if key not in entries: + raise DocError(f"unknown docdict key {key!r}") + indent = " " * _indent_of(line) + entry = _entry_lines(entries[key]) + out.extend(_reindent(entry, indent)) + suffix = line.split(")s", 1)[1].strip() + if suffix: # trailing text moves to its own line, at the entry's indent + out.append(" " * _indent_of(out[-1]) + suffix) + found.append(key) + lines[:] = out + return found + + +def _old_block_info(old_body, key, entry, old_entry): + """Return ``(old own text, first line after the old block)`` from ``git HEAD``. + + The own text is what followed the shared text *inside* the old block; the + line after the block bounds it from below when the block's extent cannot be + determined from its content. Returns ``None`` if the old docstring (or the + block in it) cannot be found. + """ + if old_body is None: + return None + old_lines = old_body.splitlines() + widths = [_indent_of(x) for x in old_lines[1:] if x.strip()] + try: + # the old docstring may predate its migration and still hold placeholders + _expand_placeholders(old_lines, [], _old_docdict() or docdict) + # the old body holds the *old* text, so locate with the old entry's extent + look_for = old_entry if old_entry is not None else entry + start, stop, _ = _locate(old_lines, key, look_for, min(widths) if widths else 0) + except DocError: + return None + block = _deindent(old_lines[start:stop], _indent_of(old_lines[start])) + old_next = next((x.strip() for x in old_lines[stop:] if x.strip()), None) + if old_next is not None and old_next.startswith(('"', "'")): + old_next = None # the closing quotes: the block ended the docstring + for known in (old_entry, entry): + if known is not None and block[: len(known)] == known: + return block[len(known) :], old_next + return None + + +def _bounded_stop(lines, start, old_next): + """End of the block at ``start``, bounded by the line that used to follow it.""" + if old_next is None: # the old block ran to the end of the docstring + stop = len(lines) + else: + stop = next( + ( + ii + for ii in range(start + 1, len(lines)) + if lines[ii].strip() == old_next + ), + None, + ) + if stop is None: + return None + while stop > start + 1 and not lines[stop - 1].strip(): + stop -= 1 + return stop + + +def _split_block(current, entry, old_entry, old_own): + """Split a block into (shared text, site-specific text that follows it).""" + shared, own = _split_block_raw(current, entry, old_entry, old_own) + while shared and not shared[-1].strip(): # a separator belongs to the own text + own.insert(0, shared.pop()) + return shared, own + + +def _split_block_raw(current, entry, old_entry, old_own): + """Split a block without normalizing blank lines at the boundary. + + ``old_own`` is the site-specific text as of ``git HEAD`` (``None`` if + unknown). Lines added directly after the shared text count as shared unless + they start with a blank line, which marks a site-specific addition (as in + the ``.. versionadded::`` notes that follow many parameters). + """ + # prefer the longest matching version: a verbatim match of a longer (old) + # entry outweighs a shorter match followed by what looks like own text + knowns = [k for k in (entry, old_entry) if k is not None] + knowns = sorted({tuple(k): list(k) for k in knowns}.values(), key=len, reverse=True) + if old_own is None: # no previous version of this docstring (not in git) + for known in knowns: + if current[: len(known)] == known: + return list(current[: len(known)]), list(current[len(known) :]) + if len(current) == len(entry): # edited in place, nothing follows it + return list(current), [] + raise DocError( + "the shared text changed, but its previous version is not available " + "(not in git HEAD) to tell it apart from the text following it; make " + "the change in mne/utils/docs.py instead" + ) + if old_own and current[-len(old_own) :] != old_own: + # the site-specific text was edited, so the shared text must be intact + for known in knowns: + if current[: len(known)] == known: + return list(current[: len(known)]), list(current[len(known) :]) + raise DocError( + "both the shared text and the text following it changed in this " + "docstring; make the shared change in mne/utils/docs.py instead" + ) + head = current[: len(current) - len(old_own)] if old_own else list(current) + for known in knowns: + if head[: len(known)] != known: + continue + rest = head[len(known) :] + if not rest: # the whole head is this version of the shared text + return list(known), list(old_own) + if rest[:1] == [""]: # a blank line marks a site-specific addition + return list(known), list(rest) + list(old_own) + return list(head), list(old_own) + + +def expected_fill(body, keys, reverse, old_body=None): + """Return (new body, all keys) with every entry matching ``docdict``. + + If an entry is unchanged since ``git HEAD`` but the docstring's copy of it was + edited, the edit is recorded in ``reverse`` (key -> new text) to be pushed + back into ``docdict`` rather than overwritten. ``old_body`` is this + docstring as of ``git HEAD``, used to tell shared text apart from the + site-specific text that may follow it. + """ + lines = body.splitlines() + widths = [_indent_of(x) for x in lines[1:] if x.strip()] + min_indent = min(widths) if widths else 0 + keys = list(keys) + for key in _expand_placeholders(lines, keys): + if key not in keys: + keys.append(key) + for key in keys: + if key not in docdict: + raise DocError(f"unknown docdict key {key!r}") + entry = _entry_lines(docdict[key]) + start, stop, _ = _locate(lines, key, entry, min_indent) + indent = _indent_of(lines[start]) + current = _deindent(lines[start:stop], indent) + old_entry = _old_docdict().get(key) + old_entry = _entry_lines(old_entry) if old_entry is not None else None + old_info = _old_block_info(old_body, key, entry, old_entry) + old_own = old_info[0] if old_info is not None else None + knowns = [k for k in (entry, old_entry) if k is not None] + if old_info is not None and old_entry is not None and old_entry != entry: + # a stale copy may extend past the new entry's extent (e.g. the + # entry lost a paragraph); recognize it by the old entry's full text + bounded = _bounded_stop(lines, start, old_info[1]) + if bounded is not None and bounded > stop: + extended = _deindent(lines[start:bounded], indent) + if extended[: len(old_entry)] == old_entry: + stop, current = bounded, extended + if old_info is not None and not any(current[: len(k)] == k for k in knowns): + # The block content matches no known version of the entry, so its + # extent cannot be trusted either; bound it by the line that + # followed the old block instead. + bounded = _bounded_stop(lines, start, old_info[1]) + if bounded is None: + raise DocError( + f"docdict[{key!r}]: cannot tell where the edited shared text " + "ends (the text that used to follow it is gone); make the " + "change in mne/utils/docs.py instead" + ) + stop = bounded + current = _deindent(lines[start:stop], indent) + try: + shared, own = _split_block(current, entry, old_entry, old_own) + except DocError as exc: + raise DocError(f"docdict[{key!r}]: {exc}") from None + if shared == entry: + continue # in sync; anything after the entry is the site's own text + if old_entry == entry: + # docdict is unchanged, so the docstring's copy is what was edited + reverse[key] = shared + continue + lines[start:stop] = _reindent(entry + own, " " * indent) + new = "\n".join(lines) + if body.endswith("\n"): + new += "\n" + return new, keys + + +def _resolve(source): + """Resolve a ``"func:mne.viz.plot_raw"`` / ``"meth:mne.io.Raw.plot"`` source.""" + kind, _, path = source.partition(":") + if kind not in ("func", "meth") or not path: + raise DocError(f"copy source must look like 'func:some.path', got {source!r}") + module_name, _, attr = path.rpartition(".") + if kind == "meth": + module_name, _, cls_name = module_name.rpartition(".") + # reload so that a source docstring fixed earlier in this run is what we copy + module = importlib.import_module(module_name) + module = importlib.reload(module) + if kind == "meth": + obj = getattr(getattr(module, cls_name), attr) + else: + obj = getattr(module, attr) + return kind, obj + + +def expected_copy(body, source, default_indent): + """Return the body for a method whose docstring is copied from ``source``.""" + kind, obj = _resolve(source) + dummy = types.SimpleNamespace(__doc__=None) + (_copy_function_doc if kind == "func" else _copy_doc)(obj, dummy) + expected = inspect.cleandoc(dummy.__doc__).splitlines() + lines = body.splitlines() + widths = [_indent_of(x) for x in lines[1:] if x.strip()] + indent = " " * min(widths) if widths else default_indent + current = [] + if body.strip() not in ("", "."): + current = inspect.cleandoc(body).splitlines() + # the method's own text (if any) follows the copied part + n_match = sum(1 for a, b in zip(expected, current) if a.rstrip() == b.rstrip()) + if n_match == len(expected): + own = current[len(expected) :] + else: # copied part changed: assume it still spans the same paragraphs + spans = list(_paragraphs(current, 0))[: _n_paragraphs(expected)] + own = current[spans[-1][1] :] if spans else [] + new = expected + own + while new and not new[-1].strip(): + new.pop() + new = [new[0]] + _reindent(new[1:], indent) + [indent] + return "\n".join(new) + + +# -------------------------------------------------------------------------- +# source editing + + +def _literal_parts(segment): + """Split a string-literal source segment into (prefix, body, quote).""" + m = re.match(r"^([rRuUbBfF]*)(\"\"\"|'''|\"|')", segment) + if m is None or not segment.endswith(m.group(2)): + raise DocError("could not parse docstring literal") + quote = m.group(2) + return m.group(1), segment[m.end() : -len(quote)], quote + + +def _decorator(node): + """Return (kind, call node, arguments) for a static-doc decorator, if any.""" + for dec in node.decorator_list: + if not isinstance(dec, ast.Call): + continue + func = dec.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "") + if name not in _FILL_DECORATORS | _COPY_DECORATORS: + continue + args = [] + for arg in dec.args: + if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)): + raise DocError(f"@{name} arguments must be string literals") + args.append(arg.value) + if name in _COPY_DECORATORS and len(args) != 1: + raise DocError(f"@{name} takes exactly one source string") + return name, dec, args + return None + + +def _offsets(source): + line_starts = [0] + for line in source.splitlines(keepends=True): + line_starts.append(line_starts[-1] + len(line)) + + def offset(lineno, col): # ast columns are in UTF-8 bytes + start = line_starts[lineno - 1] + return start + len(source[start:].encode()[:col].decode()) + + return offset + + +def process_file(path, fix, reverse, *, kinds=_FILL_DECORATORS | _COPY_DECORATORS): + """Check one file; return a list of error messages.""" + source = path.read_text() + offset = _offsets(source) + errors = [] + edits = [] # (start, stop, replacement) + rerun = False # a docstring was inserted and still needs filling + for qualname, node in _iter_functions(ast.parse(source)): + where = f"{path}:{node.lineno} {node.name}" + try: + found = _decorator(node) + if found is None: + continue + name, dec, args = found + if name not in kinds: + continue + body0 = node.body[0] if node.body else None + const = _docstring_node(node) + if const is None: + if fix and name in _COPY_DECORATORS and body0 is not None: + at = offset(body0.lineno, 0) + edits.append((at, at, " " * body0.col_offset + '"""."""\n')) + rerun = True + continue + raise DocError('needs a docstring (use """.""" for a pure copy)') + start, stop = ( + offset(const.lineno, const.col_offset), + offset(const.end_lineno, const.end_col_offset), + ) + prefix, body, quote = _literal_parts(source[start:stop]) + if name in _COPY_DECORATORS: + want = expected_copy(body, args[0], " " * const.col_offset) + new_args = args + else: + implied = ["verbose"] if name == "verbose_static" else [] + old_body = _old_bodies(path).get(qualname) + want, keys = expected_fill(body, implied + args, reverse, old_body) + new_args = [k for k in keys if k not in implied] + except DocError as exc: + errors.append(f"{where}: {exc}") + continue + except Exception as exc: # e.g. import error resolving a copy source + errors.append(f"{where}: {type(exc).__name__}: {exc}") + continue + # overlong shared text gets the usual E501 suppression comment on the + # closing quotes (reflowing the docdict entry is the nicer fix when practical) + rest_of_line = source[stop:].split("\n", 1)[0] + noqa_edit = None + overlong = any(len(line) > _LINE_LENGTH for line in want.splitlines()) + if overlong and not rest_of_line.strip(): + noqa_edit = (stop, stop, " # noqa: E501") + elif not overlong and rest_of_line == " # noqa: E501": + noqa_edit = (stop, stop + len(rest_of_line), "") # no longer needed + if want == body and new_args == args and noqa_edit is None: + continue + if "\\" in want and "r" not in prefix.lower(): + if "\\" in body and body != want: + errors.append( + f"{where}: expanded text contains a backslash; make this a raw " + "(r) docstring first" + ) + continue + prefix_edit = (start, start + len(prefix), "r" + prefix) + else: + prefix_edit = None + if fix: + body_start = start + len(prefix) + len(quote) + edits.append((body_start, stop - len(quote), want)) + if noqa_edit is not None: + edits.append(noqa_edit) + if prefix_edit is not None: + edits.append(prefix_edit) + if new_args != args: + dec_start, dec_stop = ( + offset(dec.lineno, dec.col_offset), + offset(dec.end_lineno, dec.end_col_offset), + ) + call = f"{name}({', '.join(repr(a) for a in new_args)})".replace( + "'", '"' + ) + edits.append((dec_start, dec_stop, call)) + elif want == body: # only the E501 suppression comment is stale + verb = "needs" if noqa_edit[2] else "no longer needs" + errors.append( + f"{where}: docstring {verb} a trailing ``# noqa: E501``; run\n" + f" python tools/hooks/check_static_docs.py --fix {path}" + ) + else: + diff = difflib.unified_diff( + body.splitlines(keepends=True), + want.splitlines(keepends=True), + "current docstring", + "expected", + ) + what = ( + f"copied docstring of {args[0]!r}" + if name in _COPY_DECORATORS + else "mne/utils/docs.py::docdict" + ) + errors.append( + f"{where}: docstring out of sync with {what}.\n" + " Shared text must be edited at its source (not in this docstring); " + "then run\n" + f" python tools/hooks/check_static_docs.py --fix {path}\n" + + "".join(" " + line for line in diff) + ) + if edits: + for start, stop, rep in sorted(edits, reverse=True): + source = source[:start] + rep + source[stop:] + path.write_text(source) + print(f"fixed {len(edits)} docstring edit(s) in {path}") + if rerun: + errors.extend(process_file(path, fix, reverse)) + return errors + + +DOCS_PY = REPO / "mne" / "utils" / "docs.py" + + +def _write_docdict_entries(reverse): + """Write edited entries back into docs.py; return (written keys, errors).""" + source = DOCS_PY.read_text() + offset = _offsets(source) + edits, written, errors = [], [], [] + literals = {} + for node in ast.walk(ast.parse(source)): + if not (isinstance(node, ast.Assign) and len(node.targets) == 1): + continue + target = node.targets[0] + if ( + isinstance(target, ast.Subscript) + and getattr(target.value, "id", "") == "docdict" + and isinstance(target.slice, ast.Constant) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ): + literals[target.slice.value] = node.value + for key, new_lines in reverse.items(): + const = literals.get(key) + if const is None or const.value != docdict[key]: + errors.append( + f"docdict[{key!r}] was edited in a docstring, but it is not a plain " + "string literal in mne/utils/docs.py; edit it there by hand" + ) + continue + old = docdict[key] + lead = "\n" if old.startswith("\n") else "" + trail = "\n" if old.endswith("\n") else "" + new = lead + "\n".join(new_lines) + trail + start = offset(const.lineno, const.col_offset) + stop = offset(const.end_lineno, const.end_col_offset) + prefix, _, quote = _literal_parts(source[start:stop]) + if "\\" in new and "r" not in prefix.lower(): + prefix = "r" + prefix + edits.append((start, stop, f"{prefix}{quote}{new}{quote}")) + dict.__setitem__(docdict, key, new) # BunchConst forbids reassignment + written.append(key) + for start, stop, rep_ in sorted(edits, reverse=True): + source = source[:start] + rep_ + source[stop:] + if edits: + DOCS_PY.write_text(source) + print(f"updated docdict[{', '.join(map(repr, written))}] in {DOCS_PY}") + return written, errors + + +def _files_using(keys): + """Return the files whose static decorators use ``keys`` (or copies, if empty).""" + out = [] + for path in sorted((REPO / "mne").rglob("*.py")): + if "tests" in path.parts: + continue # decorators in tests are examples, not documentation + text = path.read_text() + if "_static(" not in text: + continue + if ( + any(f'"{key}"' in text for key in keys) + or ("verbose" in keys and "verbose_static(" in text) + or (not keys and any(f"{d}(" in text for d in _COPY_DECORATORS)) + ): + out.append(path) + return out + + +def main(argv=None): + """Run the check.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--fix", action="store_true", help="rewrite files in place") + parser.add_argument("files", nargs="+", type=Path) + args = parser.parse_args(argv) + errors = [] + reverse = {} + files = [p for p in args.files if p.suffix == ".py" and "_static(" in p.read_text()] + # fill sites first, so that copies of them (processed second) see fixed text + for kinds in (_FILL_DECORATORS, _COPY_DECORATORS): + for path in files: + errors.extend(process_file(path, args.fix, reverse, kinds=kinds)) + if reverse and not args.fix: + for key in reverse: + errors.append( + f"docdict[{key!r}] was edited in a docstring; run with --fix to push " + "the edit into mne/utils/docs.py and every other docstring using it" + ) + elif reverse: + written, rev_errors = _write_docdict_entries(reverse) + errors.extend(rev_errors) + for path in _files_using(written): + errors.extend(process_file(path, True, {}, kinds=_FILL_DECORATORS)) + for path in _files_using([]): # copies of anything just updated + errors.extend(process_file(path, True, {}, kinds=_COPY_DECORATORS)) + for err in errors: + print(err, file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main())