Skip to content

Type public Epochs, Evoked, and io members fully#14056

Merged
larsoner merged 5 commits into
mne-tools:mainfrom
larsoner:typed-fully
Jul 23, 2026
Merged

Type public Epochs, Evoked, and io members fully#14056
larsoner merged 5 commits into
mne-tools:mainfrom
larsoner:typed-fully

Conversation

@larsoner

Copy link
Copy Markdown
Member

Okay this PR diff is big, but it's simple enough at its core:

  1. Add a test that checks that our NumpyDoc-documented docstrings match the types we define in our code. This turned up a few minor issues (fixed).
  2. Fill in type annotations for public interfaces in mne/io, mne/evoked.py, and mne/epochs.py, ensuring the test passes

I had Claude Opus 4.8 work on the test, but I simplified it and checked it. I also had Claude do the drudge-work of putting all those type annotations inline. The test ensures they're correct, though, so this should make review a bit easier (hopefully)!

This adds a little bit of work to future contributions having to type-define two places (docstring + code), but I don't think that's too onerous. Someday if numpy/numpydoc#196 (or numpy/numpydoc#601) lands we could maybe DRY things again.

I think with this scaffolding in place, we're safe to move to typing other modules in a similar fashion (or by using typestubs as a first pass, which @drammock is going to look into IIRC). In the meantime, I suggest we add this since it should make things slightly nicer for our end-users.

I considered pydoclint as a checker, but it is a static tool, so we need to get rid of %/docdict before we could pursue that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wmvanvliet

Copy link
Copy Markdown
Contributor

Wait, MNE-Python is going typed now?

@larsoner

Copy link
Copy Markdown
Member Author

We've discussed it and proposed it in grants but never gotten it funded IIRC

@drammock drammock left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for tackling this @larsoner. I looked pretty closely at all the readers in io/, the tests, and the utils. io/base.py I skimmed, and epochs.py and evoked.py I skipped.

There are merge conflicts now but once those are gone and you've rebutted/addressed all my comments, +1 for merge.

Comment thread mne/io/edf/edf.py
input_fname: Path | str | FileLike,
eog: list | tuple | None = None,
misc: list | tuple | None = None,
stim_channel: Literal["auto"] | str | list | int = "auto",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a point to saying Literal["auto"] | str instead of just str here (and elsewhere in the diff)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think it's a tiny bit clearer -- "auto" is a special thing, whereas any other string is a channel name and behaves differently. This is consistent with how our docstring documents it as well

    stim_channel : ``'auto'`` | str | list of str | int | list of int

... which is enforced to be consistent the checker!

Comment on lines +461 to +462
s = re.sub(r"\s*\(\s*default[^)]*\)", "", s, flags=re.I) # (default X)
s = re.sub(r"\s*,\s*default[:=]?\s*[^,|]+", "", s, flags=re.I) # , default X

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be removed from the docstring sources, IMO. AFAIR our convention is to state the default in the param description prose, not in the type description. Can be a TODO for a follow-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a good follow-up, and removing this would be a way to find the violators.

s = s.replace("~", "") # drop the sphinx "abbreviate" marker
s = re.sub(r"\s*\(\s*default[^)]*\)", "", s, flags=re.I) # (default X)
s = re.sub(r"\s*,\s*default[:=]?\s*[^,|]+", "", s, flags=re.I) # , default X
s = re.sub(r"\s*,?\s*optional\b", "", s, flags=re.I) # , optional

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"optional" is also not something we're supposed to do according to the contrib guide (item 2)

Comment thread mne/tests/test_docstring_parameters.py Outdated
Comment on lines +467 to +469
s = re.sub(
r"\b(list|tuple|dict|set) of [\w.]+", r"\1", s, flags=re.I
) # list of X -> list

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the . in [\w.]+ should be escaped? Assume it's meant to capture the dot in e.g. "list of numpy.ndarray"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I always have to look this stuff up ! Looks like it should be okay

In a character class (square brackets) any character except ^, -, ] or \ is a literal.

https://stackoverflow.com/questions/19976018/does-a-dot-have-to-be-escaped-in-a-character-class-square-brackets-of-a-regula

Comment thread mne/tests/test_docstring_parameters.py Outdated
Comment on lines +472 to +477
s = re.sub( # textual Literal["a", "b"] (from string annotations) -> a | b
r"\bLiteral\[([^\]]*)\]", lambda m: m.group(1).replace(",", "|"), s
)
s = re.sub(
r"\{([^}]*)\}", lambda m: m.group(1).replace(",", "|"), s
) # {a, b} -> a|b

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, these two will map Literal["a", "b"] or {"a", "b"} to "a"| "b" (note the asymmetric space). Does that matter?

Comment on lines +495 to +496
elif "array" in low or low == "ndarray":
atoms.add("array")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could miss marray if we happen to take in or return masked arrays somewhere

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I expect we might have to adjust this if we add more types

Comment thread mne/tests/test_docstring_parameters.py Outdated
Comment on lines +552 to +555
# free-form docstring types (e.g. "matplotlib colormap | (colormap, bool)")
# cannot be matched mechanically, so only validate comparable ones
if not doc_atoms or not all(_is_type_like(d) for d in doc_atoms):
continue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels like a case of "we shouldn't be doing that in the first place". Are there other cases like this besides the interactive colormap one? would it make sense to have an explicit allowlist (so we have something we can work on whittling down)?

Comment thread mne/tests/test_docstring_parameters.py Outdated
if not fname.startswith("_") and _defined_under(func, name):
_check_type_hints(func, cls=None, where=name, incorrect=incorrect)
for func, cls in _documented_callables():
_check_type_hints(func, cls=cls, incorrect=incorrect)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems weird that the helper mutates the input incorrect list, and doesn't return anything. Unexpected (but I guess ok as it's not user-facing and only used here?)

)
super().generic_visit(node)

def visit_If(self, node):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth a comment that the capital I is intentional / the func name is dictated by the way ast.NodeVisitor works

Comment thread mne/io/fieldtrip/fieldtrip.py Outdated

def read_raw_fieldtrip(fname, info, data_name="data") -> RawArray:
def read_raw_fieldtrip(
fname: Path | str, info: dict | None, data_name: str = "data"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why info: dict | None here instead of info: Info | None?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preexisting cruft with the docstring ... I'll fix it here though!

@larsoner
larsoner enabled auto-merge (squash) July 23, 2026 23:23
@larsoner

Copy link
Copy Markdown
Member Author

Pushed a commit with the allowlist approach (most look pretty easily fixable, but probably better reviewed separately) plus regex and minor comment cleanups/adds. Marking for squash+merge-when-green 🤞

@larsoner
larsoner merged commit 41ae901 into mne-tools:main Jul 23, 2026
29 checks passed
@larsoner
larsoner deleted the typed-fully branch July 23, 2026 23:48
@wmvanvliet

wmvanvliet commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Any recommendations on how to proceed with adding type annotations to other parts of the codebase? When we write new stuff and re-write existing stuff, should we type annotate it?

@drammock

Copy link
Copy Markdown
Member

Any recommendations on how to proceed with adding type annotations to other parts of the codebase? When we write new stuff and re-write existing stuff, should we type annotate it?

@larsoner and I are planning to crank through the rest of the codebase now-ish, one submodule at a time, with the help of the docstub package and an LLM. LMK if you want to assist, so we don't duplicate effort

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants