Repository: mne-tools/mne-python

Issue:
ENH: Allow mne.Report section reordering
Currently with `mne.Report` you can:

1. Add new sections, which get placed at the end
2. Remove sections
3. Replace existing sections

In MNE-BIDS-Pipeline we have an issue where we parallelize over runs and it messes up report *order*. In serial mode, run 1 gets processed, then 2, etc. But in parallel mode, sometimes run 4 finishes before run 1, so its summary gets added to the report first, and thus gets placed first in the HTML. There is no way to fix this other than removing and re-adding the sections in the right order, which is cumbersome.

To fix this, I suggest that we provide some way to reorder sections. One option would be:

1. Expose a `report.get_contents` that is probably just a public version of this:

   https://github.com/mne-tools/mne-python/blob/acab264e3d844cce5dfa9fadb49cc55352a261bc/mne/report/report.py#L968

   This gives the HTML, DOM IDs, titles, and tags, but I think a more natural order for the public function would be `(titles, tags, html)` (no need for DOM IDs I think, this is mostly for our linking and HTML structure convenience).
2. Expose a `report.reorder(order)` where `order` must satisfy `np.array_equal(np.unique(order), np.arange(len(self._contents))`, and the contents are reordered appropriately.

That way users can 1) get access to the stuff they've added already, 2) figure out what reordering they want to apply, and 3) apply it.

Discussion:
larsoner: All green @drammock !
hoechenberger: @larsoner Thanks!

I don't find the API self-explanatory. How would a user find out what to feed into the new method?
larsoner: Basically the same way you would reorder a NumPy array `x` to put the last element first, like `x[[3, 0, 1, 2]]`
hoechenberger: The improved docstring really helps, this is good to go for me!

Patch:
diff --git a/doc/changes/devel/12513.newfeature.rst b/doc/changes/devel/12513.newfeature.rst
new file mode 100644
index 000000000..7189adaf3
--- /dev/null
+++ b/doc/changes/devel/12513.newfeature.rst
@@ -0,0 +1,2 @@
+Added the ability to reorder report contents via :meth:`mne.Report.reorder` (with
+helper to get contents with :meth:`mne.Report.get_contents`), by `Eric Larson`_.
diff --git a/mne/report/report.py b/mne/report/report.py
index 30fd2e691..b2fafe5b4 100644
--- a/mne/report/report.py
+++ b/mne/report/report.py
@@ -8,6 +8,7 @@
 # Copyright the MNE-Python contributors.
 
 import base64
+import copy
 import dataclasses
 import fnmatch
 import io
@@ -965,6 +966,64 @@ class Report:
             )
         return items, captions, comments
 
+    def copy(self):
+        """Return a deepcopy of the report.
+
+        Returns
+        -------
+        report : instance of Report
+            The copied report.
+        """
+        return copy.deepcopy(self)
+
+    def get_contents(self):
+        """Get the content of the report.
+
+        Returns
+        -------
+        titles : list of str
+            The title of each content element.
+        tags : list of list of str
+            The tags for each content element, one list per element.
+        htmls : list of str
+            The HTML contents for each element.
+
+        Notes
+        -----
+        .. versionadded:: 1.7
+        """
+        htmls, _, titles, tags = self._content_as_html()
+        return titles, tags, htmls
+
+    def reorder(self, order):
+        """Reorder the report content.
+
+        Parameters
+        ----------
+        order : array-like of int
+            The indices of the new order (as if you were reordering an array).
+            For example if there are 4 elements in the report,
+            ``order=[3, 0, 1, 2]`` would take the last element and move it to
+            the front. In other words, ``elements = [elements[ii] for ii in order]]``.
+
+        Notes
+        -----
+        .. versionadded:: 1.7
+        """
+        _validate_type(order, "array-like", "order")
+        order = np.array(order)
+        if order.dtype.kind != "i" or order.ndim != 1:
+            raise ValueError(
+                "order must be an array of integers, got "
+                f"{order.ndim}D array of dtype {order.dtype}"
+            )
+        n_elements = len(self._content)
+        if not np.array_equal(np.sort(order), np.arange(n_elements)):
+            raise ValueError(
+                f"order must be a permutation of range({n_elements}), got:\n{order}"
+            )
+        self._content = [self._content[ii] for ii in order]
+
     def _content_as_html(self):
         """Generate HTML representations based on the added content & sections.
 
@@ -1039,18 +1098,12 @@ class Report:
     @property
     def html(self):
         """A list of HTML representations for all content elements."""
-        htmls, _, _, _ = self._content_as_html()
-        return htmls
+        return self._content_as_html()[0]
 
     @property
     def tags(self):
-        """All tags currently used in the report."""
-        tags = []
-        for c in self._content:
-            tags.extend(c.tags)
-
-        tags = tuple(sorted(set(tags)))
-        return tags
+        """A sorted tuple of all tags currently used in the report."""
+        return tuple(sorted(set(sum(self._content_as_html()[3], ()))))
 
     def add_custom_css(self, css):
         """Add custom CSS to the report.
@@ -2875,7 +2928,7 @@ class Report:
                 )
 
         if sort_content:
-            self._content = self._sort(content=self._content, order=CONTENT_ORDER)
+            self._sort(order=CONTENT_ORDER)
 
     def __getstate__(self):
         """Get the state of the report as a dictionary."""
@@ -2954,7 +3007,7 @@ class Report:
         fname = op.realpath(fname)  # resolve symlinks
 
         if sort_content:
-            self._content = self._sort(content=self._content, order=CONTENT_ORDER)
+            self._sort(order=CONTENT_ORDER)
 
         if not overwrite and op.isfile(fname):
             msg = (
@@ -3017,30 +3070,23 @@ class Report:
         if self.fname is not None:
             self.save(self.fname, open_browser=False, overwrite=True)
 
-    @staticmethod
-    def _sort(content, order):
+    def _sort(self, *, order):
         """Reorder content to reflect "natural" ordering."""
-        content_unsorted = content.copy()
-        content_sorted = []
         content_sorted_idx = []
-        del content
 
         # First arrange content with known tags in the predefined order
         for tag in order:
-            for idx, content in enumerate(content_unsorted):
+            for idx, content in enumerate(self._content):
                 if tag in content.tags:
                     content_sorted_idx.append(idx)
-                    content_sorted.append(content)
 
         # Now simply append the rest (custom tags)
-        content_remaining = [
-            content
-            for idx, content in enumerate(content_unsorted)
-            if idx not in content_sorted_idx
-        ]
-
-        content_sorted = [*content_sorted, *content_remaining]
-        return content_sorted
+        self.reorder(
+            np.r_[
+                content_sorted_idx,
+                np.setdiff1d(np.arange(len(self._content)), content_sorted_idx),
+            ]
+        )
 
     def _render_one_bem_axis(
         self,


Test patch:
diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py
index 437cfec3c..3860e2273 100644
--- a/mne/report/tests/test_report.py
+++ b/mne/report/tests/test_report.py
@@ -5,7 +5,6 @@
 # Copyright the MNE-Python contributors.
 
 import base64
-import copy
 import glob
 import os
 import pickle
@@ -638,7 +637,7 @@ def test_remove():
     r.add_figure(fig=fig2, title="figure2", tags=("slider",))
 
     # Test removal by title
-    r2 = copy.deepcopy(r)
+    r2 = r.copy()
     removed_index = r2.remove(title="figure1")
     assert removed_index == 2
     assert len(r2.html) == 3
@@ -647,7 +646,7 @@ def test_remove():
     assert r2.html[2] == r.html[3]
 
     # Test restricting to section
-    r2 = copy.deepcopy(r)
+    r2 = r.copy()
     removed_index = r2.remove(title="figure1", tags=("othertag",))
     assert removed_index == 1
     assert len(r2.html) == 3
@@ -692,7 +691,7 @@ def test_add_or_replace(tags):
     assert len(r.html) == 4
     assert len(r._content) == 4
 
-    old_r = copy.deepcopy(r)
+    old_r = r.copy()
 
     # Replace our last occurrence of title='duplicate'
     r.add_figure(
@@ -765,7 +764,7 @@ def test_add_or_replace_section():
     assert len(r.html) == 3
     assert len(r._content) == 3
 
-    old_r = copy.deepcopy(r)
+    old_r = r.copy()
     assert r.html[0] == old_r.html[0]
     assert r.html[1] == old_r.html[1]
     assert r.html[2] == old_r.html[2]
@@ -1108,24 +1107,53 @@ def test_sorting(tmp_path):
     """Test that automated ordering based on tags works."""
     r = Report()
 
-    r.add_code(code="E = m * c**2", title="intelligence >9000", tags=("bem",))
-    r.add_code(code="a**2 + b**2 = c**2", title="Pythagoras", tags=("evoked",))
-    r.add_code(code="🧠", title="source of truth", tags=("source-estimate",))
-    r.add_code(code="🥦", title="veggies", tags=("raw",))
+    titles = ["intelligence >9000", "Pythagoras", "source of truth", "veggies"]
+    r.add_code(code="E = m * c**2", title=titles[0], tags=("bem",))
+    r.add_code(code="a**2 + b**2 = c**2", title=titles[1], tags=("evoked",))
+    r.add_code(code="🧠", title=titles[2], tags=("source-estimate",))
+    r.add_code(code="🥦", title=titles[3], tags=("raw",))
 
     # Check that repeated calls of add_* actually continuously appended to
     # the report
     orig_order = ["bem", "evoked", "source-estimate", "raw"]
     assert [c.tags[0] for c in r._content] == orig_order
 
+    # tags property behavior and get_contents
+    assert list(r.tags) == sorted(orig_order)
+    titles, tags, htmls = r.get_contents()
+    assert set(sum(tags, ())) == set(r.tags)
+    assert len(titles) == len(tags) == len(htmls) == len(r._content)
+    for title, tag, html in zip(titles, tags, htmls):
+        title = title.replace(">", "&gt;")
+        assert title in html
+        for t in tag:
+            assert t in html
+
     # Now check the actual sorting
-    content_sorted = r._sort(content=r._content, order=CONTENT_ORDER)
+    r_sorted = r.copy()
+    r_sorted._sort(order=CONTENT_ORDER)
     expected_order = ["raw", "evoked", "bem", "source-estimate"]
 
-    assert content_sorted != r._content
-    assert [c.tags[0] for c in content_sorted] == expected_order
+    assert r_sorted._content != r._content
+    assert [c.tags[0] for c in r_sorted._content] == expected_order
+    assert [c.tags[0] for c in r._content] == orig_order
+
+    r.copy().save(fname=tmp_path / "report.html", sort_content=True, open_browser=False)
+
+    # Manual sorting should be the same
+    r_sorted = r.copy()
+    order = np.argsort([CONTENT_ORDER.index(t) for t in orig_order])
+    r_sorted.reorder(order)
+
+    assert r_sorted._content != r._content
+    got_order = [c.tags[0] for c in r_sorted._content]
+    assert [c.tags[0] for c in r._content] == orig_order  # original unmodified
+    assert got_order == expected_order
 
-    r.save(fname=tmp_path / "report.html", sort_content=True, open_browser=False)
+    with pytest.raises(ValueError, match="order must be a permutation"):
+        r.reorder(np.arange(len(r._content) + 1))
+    with pytest.raises(ValueError, match="array of integers"):
+        r.reorder([1.0])
 
 
 @pytest.mark.parametrize(