Repository: mne-tools/mne-python

Issue:
Demo & RFC: Add return type hints to `read_evokeds()`
x-ref #12243

Here I'm demonstrating a slightly tricky case for type hints: the value of a parameter determines the function's return value. I just wanted to show to you what this looks like, so we can evaluate whether this is acceptable.

MWE:
```python
import mne

evokeds_path = (
    mne.datasets.sample.data_path() / "MEG" / "sample" / "sample_audvis-ave.fif"
)

evokeds = mne.read_evokeds(evokeds_path)
evoked = mne.read_evokeds(evokeds_path, condition=0)
```

`main`:
<img width="802" alt="Screenshot 2023-11-29 at 22 17 30" src="https://github.com/mne-tools/mne-python/assets/2046265/64fdf140-e14c-498f-bbb0-7037c209dff9">
<img width="802" alt="Screenshot 2023-11-29 at 22 17 37" src="https://github.com/mne-tools/mne-python/assets/2046265/91e9fd29-5b7d-43f5-bdd0-cfd03a0d824c">

This PR:
<img width="802" alt="Screenshot 2023-11-29 at 22 33 34" src="https://github.com/mne-tools/mne-python/assets/2046265/c2aefa36-11dd-4f56-bc00-0badd3c153de">
<img width="802" alt="Screenshot 2023-11-29 at 22 33 39" src="https://github.com/mne-tools/mne-python/assets/2046265/d5468ed2-db22-47d7-a391-cd0afaa93f8f">


cc @cbrnr @agramfort @larsoner @drammock 


Discussion:
Please note that I added 2 commits here. The second one uses an overload for `condition: Literal[None]`, which is very explicit but may be considered unnecessarily verbose. The first one only uses `condition: None`.
I intentionally didn't add all overloads, so things don't look too messy for now. But I can push an additional commit to cover all allowed types of `condition` tomorrow.
This is how VS Code now presents the signature:
<img width="536" alt="Screenshot 2023-11-29 at 23 22 20" src="https://github.com/mne-tools/mne-python/assets/2046265/97bd2668-0d17-4352-87e9-60fe0b5ca9c8">

Although such cases will hopefully be rare, this example shows that our API should probably be simplified. Why don't we take this opportunity to e.g. always return a `list[Evoked]`?
I agree that this particular overload may be indicative of an API design we may want to reconsider.
-1, not worth the hassle to end users
I think the UX would be greatly improved and from a user's perspective, is not different from the example with `read_raw()` that was generally agreed upon as being convincing.

Right now when I read evokeds, my IDE doesn't know which data type is actually being returned and I have to manually insert type guards in my code. 

I tend to agree with @cbrnr that the "hassle" we're looking at here (overloads) might just be indicative of a sub-optimal API design in the first place 
Agreed it's suboptimal, but to me the code churn we will force upon people isn't worth it
The simplified solution for typing this function would be to annotate the return type as the union of Evoked and list of Evoked. At least this would make things clearer when viewing the signature in an IDE.

No overloads required.

Thoughts?
I don't really understand / haven't used overloads but if you mean having this be the signature:
```
def read_evokeds(
    ...
) -> Union[Evoked, List[Evoked]]:
```
then +1 from me, seems simple and reflective of current behavior.
> I don't really understand / haven't used overloads 

It's just like multiple dispatch in other languages. You specify what goes into the function and what comes out. You repeat that for all input types / parameter values that yield a different function return type. So that in the example above, when you ask the function to read only a single condition, the type checker knows to expect an Evoked; otherwise, a list of Evoked.

The overloads don't change runtime behavior, in case that's something you assumed.

> but if you mean having this be the signature:
> 
> ```
> 
> def read_evokeds(
> 
>     ...
> 
> ) -> Union[Evoked, List[Evoked]]:
> 
> ```
> 
> then +1 from me, seems simple and reflective of current behavior.

Yes, this is what I meant. It makes the UX slightly better but would still require type guards in user code. Because the return type now is "Evoked or a list of Evoked", hence without inserting an `assert isinstance(evoked, ...)`, you won't get helpful advice from your IDE.

But at least VS Code won't keep telling me that this function returns "Unknown or a list of Unknown" anymore, which is already a slight improvement.

Resolving patch:
diff --git a/mne/evoked.py b/mne/evoked.py
--- a/mne/evoked.py
+++ b/mne/evoked.py
@@ -9,6 +9,7 @@
 # Copyright the MNE-Python contributors.
 
 from copy import deepcopy
+from typing import List, Union
 
 import numpy as np
 
@@ -1538,7 +1539,7 @@ def read_evokeds(
     proj=True,
     allow_maxshield=False,
     verbose=None,
-):
+) -> Union[List[Evoked], Evoked]:
     """Read evoked dataset(s).
 
     Parameters
diff --git a/mne/io/_read_raw.py b/mne/io/_read_raw.py
--- a/mne/io/_read_raw.py
+++ b/mne/io/_read_raw.py
@@ -10,6 +10,7 @@
 from pathlib import Path
 
 from ..utils import fill_doc
+from .base import BaseRaw
 
 
 def _read_unsupported(fname, **kwargs):
@@ -110,7 +111,7 @@ def split_name_ext(fname):
 
 
 @fill_doc
-def read_raw(fname, *, preload=False, verbose=None, **kwargs):
+def read_raw(fname, *, preload=False, verbose=None, **kwargs) -> BaseRaw:
     """Read raw file.
 
     This function is a convenient wrapper for readers defined in `mne.io`. The


Test patch: