Repository: mne-tools/mne-python

Issue:
BUG: Tags with spaces are broken
This:
```
import mne
import matplotlib.pyplot as plt
report = mne.Report()
figs, captions = list(), list()
for n in range(2):
    fig, ax = plt.subplots()
    ax.text(0, 0, str(n))
    figs.append(fig)
    captions.append(str(n))
report.add_figure(figs, 'numbers', caption=captions, tags=['My numbers'])
report.save('rep.html', overwrite=True)
```
is broken -- the top bar covers content, and the sliders do not work:

![Screenshot from 2021-11-05 10-43-33](https://user-images.githubusercontent.com/2365790/140529033-4b36e284-edf8-4311-a532-63319e39bac8.png)

If I change it to `tags['My_numbers']` it works fine:

![Screenshot from 2021-11-05 10-43-49](https://user-images.githubusercontent.com/2365790/140529045-95106962-eb90-4cbb-b0e8-08c15d517aad.png)

@hoechenberger can you look?



Discussion:
Looks like insufficient tag sanitization, they must not contain any spaces. Will have a look. 
A nice solution would be to replace spaces with underscores just in the CSS and such and still display visually with spaces, but if that's not possible or too hard, then just raising an error when people have spaces in their tags is okay, too

Resolving patch:
diff --git a/mne/report/report.py b/mne/report/report.py
--- a/mne/report/report.py
+++ b/mne/report/report.py
@@ -10,6 +10,7 @@
 import dataclasses
 from dataclasses import dataclass
 from typing import Tuple
+from collections.abc import Sequence
 import base64
 from io import BytesIO, StringIO
 import contextlib
@@ -278,6 +279,41 @@ class _ContentElement:
     html: str
 
 
+def _check_tags(tags) -> Tuple[str]:
+    # Must be iterable, but not a string
+    if (isinstance(tags, str) or not isinstance(tags, (Sequence, np.ndarray))):
+        raise TypeError(
+            f'tags must be a collection of str, but got {type(tags)} '
+            f'instead: {tags}'
+        )
+    tags = tuple(tags)
+
+    # Check for invalid dtypes
+    bad_tags = [tag for tag in tags
+                if not isinstance(tag, str)]
+    if bad_tags:
+        raise TypeError(
+            f'tags must be strings, but got the following instead: '
+            f'{", ".join([str(tag) for tag in bad_tags])}'
+        )
+
+    # Check for invalid characters
+    invalid_chars = (' ', '"', '\n')  # we'll probably find more :-)
+    bad_tags = []
+    for tag in tags:
+        for invalid_char in invalid_chars:
+            if invalid_char in tag:
+                bad_tags.append(tag)
+                break
+    if bad_tags:
+        raise ValueError(
+            f'The following tags contained invalid characters: '
+            f'{", ".join(repr(tag) for tag in bad_tags)}'
+        )
+
+    return tags
+
+
 ###############################################################################
 # PLOTTING FUNCTIONS
 
@@ -807,7 +843,7 @@ def add_epochs(self, epochs, title, *, psd=True, projs=None,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         add_projs = self.projs if projs is None else projs
 
@@ -903,7 +939,7 @@ def add_evokeds(self, evokeds, *, titles=None, noise_cov=None, projs=None,
             noise_cov = self.cov_fname
         if noise_cov is not None and not isinstance(noise_cov, Covariance):
             noise_cov = read_cov(fname=noise_cov)
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         add_projs = self.projs if projs is None else projs
 
@@ -968,7 +1004,7 @@ def add_raw(self, raw, title, *, psd=None, projs=None, butterfly=True,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         if psd is None:
             add_psd = dict() if self.raw_psd is True else self.raw_psd
@@ -1038,7 +1074,7 @@ def add_stc(self, stc, title, *, subject=None, subjects_dir=None,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         html, dom_id = self._render_stc(
             stc=stc,
@@ -1083,7 +1119,7 @@ def add_forward(self, forward, title, *, subject=None, subjects_dir=None,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         html, dom_id = self._render_forward(
             forward=forward, subject=subject, subjects_dir=subjects_dir,
@@ -1126,7 +1162,7 @@ def add_inverse_operator(self, inverse_operator, title, *, subject=None,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         if ((subject is not None and trans is None) or
                 (trans is not None and subject is None)):
@@ -1172,7 +1208,7 @@ def add_trans(self, trans, *, info, title, subject=None, subjects_dir=None,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         html, dom_id = self._render_trans(
             trans=trans,
@@ -1210,7 +1246,7 @@ def add_covariance(self, cov, *, info, title, tags=('covariance',),
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         htmls = self._render_cov(
             cov=cov,
             info=info,
@@ -1260,7 +1296,7 @@ def add_events(self, events, title, *, event_id=None, sfreq, first_samp=0,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         html, dom_id = self._render_events(
             events=events,
             event_id=event_id,
@@ -1302,7 +1338,7 @@ def add_projs(self, *, info, projs=None, title, topomap_kwargs=None,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         output = self._render_ssp_projs(
             info=info, projs=projs, title=title,
             image_format=self.image_format, tags=tags,
@@ -1615,7 +1651,7 @@ def add_ica(
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         dom_id, html = self._render_ica(
             ica=ica, inst=inst, picks=picks,
@@ -1767,7 +1803,7 @@ def add_code(self, code, title, *, language='python', tags=('code',),
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         language = language.lower()
         html, dom_id = self._render_code(
             code=code, title=title, language=language, tags=tags
@@ -1797,7 +1833,7 @@ def add_sys_info(self, title, *, tags=('mne-sysinfo',)):
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
 
         with contextlib.redirect_stdout(StringIO()) as f:
             sys_info()
@@ -1830,7 +1866,7 @@ def add_figure(self, fig, title, *, caption=None, image_format=None,
         -----
         .. versionadded:: 0.24.0
         """  # noqa E501
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         if image_format is None:
             image_format = self.image_format
 
@@ -1903,7 +1939,7 @@ def add_image(self, image, title, *, caption=None, tags=('custom-image',),
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         img_bytes = Path(image).expanduser().read_bytes()
         img_base64 = base64.b64encode(img_bytes).decode('ascii')
         del img_bytes  # Free memory
@@ -1944,7 +1980,7 @@ def add_html(self, html, title, *, tags=('custom-html',), replace=False):
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         dom_id = self._get_dom_id()
         html_element = _html_element(
             id=dom_id, html=html, title=title, tags=tags,
@@ -1986,7 +2022,7 @@ def add_bem(self, subject, title, *, subjects_dir=None, decim=2, width=512,
         -----
         .. versionadded:: 0.24.0
         """
-        tags = tuple(tags)
+        tags = _check_tags(tags)
         width = _ensure_int(width, 'width')
         html = self._render_bem(subject=subject, subjects_dir=subjects_dir,
                                 decim=decim, n_jobs=n_jobs, width=width,
diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py
--- a/tutorials/intro/70_report.py
+++ b/tutorials/intro/70_report.py
@@ -92,7 +92,8 @@
 # ^^^^^^^^^^^^^
 #
 # Events can be added via :meth:`mne.Report.add_events`. You also need to
-# supply the sampling frequency used during the recording.
+# supply the sampling frequency used during the recording; this information
+# is used to generate a meaningful time axis.
 
 events_path = sample_dir / 'sample_audvis_filt-0-40_raw-eve.fif'
 events = mne.read_events(events_path)


Test patch:
diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py
--- a/mne/report/tests/test_report.py
+++ b/mne/report/tests/test_report.py
@@ -823,3 +823,39 @@ def test_sorting(tmp_path):
 
     r.save(fname=op.join(tmp_path, 'report.html'), sort_content=True,
            open_browser=False)
+
+
+@pytest.mark.parametrize(
+    ('tags', 'not_a_collection', 'wrong_dtype', 'invalid_chars'),
+    [
+        # not a collection
+        ('foo', True, False, False),
+        (123, True, False, False),
+        # wrong dtype
+        ([1, 2, 3], False, True, False),
+        (['foo', 1], False, True, False),
+        # invalid characters
+        (['foo bar'], False, False, True),
+        (['foo"'], False, False, True),
+        (['foo\n'], False, False, True),
+        # all good
+        (['foo'], False, False, False),
+        (['foo', 'bar'], False, False, False),
+        (np.array(['foo', 'bar']), False, False, False)
+    ]
+)
+def test_tags(tags, not_a_collection, wrong_dtype, invalid_chars):
+    """Test handling of invalid tags."""
+    r = Report()
+
+    if not_a_collection:
+        with pytest.raises(TypeError, match='must be a collection of str'):
+            r.add_code(code='foo', title='bar', tags=tags)
+    elif wrong_dtype:
+        with pytest.raises(TypeError, match='must be strings'):
+            r.add_code(code='foo', title='bar', tags=tags)
+    elif invalid_chars:
+        with pytest.raises(ValueError, match='contained invalid characters'):
+            r.add_code(code='foo', title='bar', tags=tags)
+    else:
+        r.add_code(code='foo', title='bar', tags=tags)