diff --git a/doc/changes/dev/14261.newfeature.rst b/doc/changes/dev/14261.newfeature.rst
new file mode 100644
index 00000000000..b4d78ece2e4
--- /dev/null
+++ b/doc/changes/dev/14261.newfeature.rst
@@ -0,0 +1 @@
+Added a ``only_if_changed=True`` parameter to :meth:`mne.Report.save` to skip writing an already up-to-date file, by `Eric Larson`_.
diff --git a/mne/report/report.py b/mne/report/report.py
index 17e1a499682..0323c4da28f 100644
--- a/mne/report/report.py
+++ b/mne/report/report.py
@@ -756,6 +756,7 @@ def open_report(fname, **params):
)
report = Report()
report.__setstate__(state)
+ report._unsaved_changes = False # what we have matches what's on disk
else:
report = Report(**params)
# Keep track of the filename in case the Report object is used as a context
@@ -948,6 +949,23 @@ def __init__(
self.fname = None # The name of the saved report
self.data_path = None
+ self._unsaved_changes = False # must come last, mutators set it to True
+
+ @property
+ def unsaved_changes(self):
+ """Whether the report content changed since it was created, loaded, or saved.
+
+ Returns
+ -------
+ unsaved_changes : bool
+ ``True`` if content was added, replaced, removed, or reordered since the
+ report was created, read from disk, or last saved.
+
+ Notes
+ -----
+ .. versionadded:: 1.13
+ """
+ return self._unsaved_changes
@property
def img_max_width(self):
@@ -1112,7 +1130,9 @@ def copy(self):
report : instance of Report
The copied report.
"""
- return copy.deepcopy(self)
+ report = copy.deepcopy(self)
+ report._unsaved_changes = self._unsaved_changes # deepcopy uses __setstate__
+ return report
def get_contents(self):
"""Get the content of the report.
@@ -1161,6 +1181,7 @@ def reorder(self, order):
f"order must be a permutation of range({n_elements}), got:\n{order}"
)
self._content = [self._content[ii] for ii in order]
+ self._unsaved_changes = True
def _content_as_html(self):
"""Generate HTML representations based on the added content & sections.
@@ -1259,6 +1280,7 @@ def add_custom_css(self, css):
"""
style = f'\n'
self.include += style
+ self._unsaved_changes = True
def add_custom_js(self, js):
"""Add custom JavaScript to the report.
@@ -1275,6 +1297,7 @@ def add_custom_js(self, js):
"""
script = f'\n'
self.include += script
+ self._unsaved_changes = True
@fill_doc
def add_epochs(
@@ -2369,11 +2392,13 @@ def remove(self, *, title=None, tags=None, remove_all=False):
elif not remove_all: # only remove last occurrence
remove_idx = remove_idx[-1]
del self._content[remove_idx]
+ self._unsaved_changes = True
else: # remove all occurrences
remove_idx = tuple(remove_idx)
self._content = [
e for idx, e in enumerate(self._content) if idx not in remove_idx
]
+ self._unsaved_changes = True
return remove_idx
@@ -2420,6 +2445,7 @@ def _add_or_replace(self, *, title, section, tags, html_partial, replace=False):
new_content.dom_id = dom_id
new_content.html = html_partial(id_=dom_id)
assert isinstance(new_content.html, str), type(new_content.html)
+ self._unsaved_changes = True
def _add_code(self, *, code, title, language, section, tags, replace):
if isinstance(code, Path):
@@ -3268,6 +3294,9 @@ def __setstate__(self, state):
if param_name == "_content":
param_val = [_ContentElement(**val) for val in param_val]
setattr(self, param_name, param_val)
+ # conservative, as we don't know where the state came from (open_report and
+ # copy, which both go through here, set the correct value themselves)
+ self._unsaved_changes = True
return state
@verbose
@@ -3278,6 +3307,7 @@ def save(
overwrite=False,
sort_content=False,
*,
+ only_if_changed=False,
verbose=None,
):
"""Save the report and optionally open it in browser.
@@ -3305,6 +3335,13 @@ def save(
-> bem -> forward-solution -> inverse-operator -> source-estimate.
.. versionadded:: 0.24.0
+ only_if_changed : bool
+ If ``True`` and :attr:`~mne.Report.unsaved_changes` is ``False`` and the
+ output file already exists, skip writing it (the existing file is left
+ untouched) and just return its name. If the output file does not exist, it
+ is always written.
+
+ .. versionadded:: 1.13
%(verbose)s
Returns
@@ -3318,6 +3355,16 @@ def save(
warn(f"`data_path` not provided. Using {self.data_path} instead")
fname = op.join(self.data_path, "report.html")
+ if only_if_changed and not self._unsaved_changes:
+ # overwrite="read" so that an existing file is not an error here
+ check_fname = _check_fname(fname, overwrite="read", name=fname)
+ if check_fname.is_file():
+ fname = op.realpath(str(check_fname))
+ logger.info(f"Report has no unsaved changes, not saving to : {fname}")
+ if self.fname is None:
+ self.fname = fname
+ return fname
+
fname = str(_check_fname(fname, overwrite=overwrite, name=fname))
fname = op.realpath(fname) # resolve symlinks
@@ -3367,6 +3414,8 @@ def save(
html = [header_html, toc_html, *self.html, footer_html]
Path(fname).write_text(data="".join(html), encoding="utf-8")
+ self._unsaved_changes = False
+
building_doc = os.getenv("_MNE_BUILDING_DOC", "").lower() == "true"
if open_browser and not is_hdf5 and not building_doc:
webbrowser.open_new_tab("file://" + fname)
diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py
index 96af2bc37e5..399f19b1978 100644
--- a/mne/report/tests/test_report.py
+++ b/mne/report/tests/test_report.py
@@ -628,9 +628,12 @@ def test_open_report(tmp_path):
with open_report(hdf5, subjects_dir=tmp_path) as report:
assert report.subjects_dir == str(tmp_path)
assert report.fname == str(hdf5)
+ assert not report.unsaved_changes
report.add_figure(fig=fig1, title="evoked response")
+ assert report.unsaved_changes
# Exiting the context block should have triggered saving to HDF5
assert Path(hdf5).exists()
+ assert not report.unsaved_changes
# Let's add some companion data to the HDF5 file
with h5py.File(hdf5, "r+") as f:
@@ -639,6 +642,7 @@ def test_open_report(tmp_path):
# Load the HDF5 version of the report and check equivalence
report2 = open_report(hdf5)
+ assert not report2.unsaved_changes
assert report2.fname == str(hdf5)
assert report2.subjects_dir == report.subjects_dir
assert report2.html == report.html
@@ -648,7 +652,9 @@ def test_open_report(tmp_path):
# Check parameters when loading a report
pytest.raises(ValueError, open_report, hdf5, foo="bar") # non-existing
pytest.raises(ValueError, open_report, hdf5, subjects_dir="foo")
- open_report(hdf5, subjects_dir=str(tmp_path)) # This should work
+ with open_report(hdf5, subjects_dir=str(tmp_path)) as report3: # This should work
+ assert not report3.unsaved_changes # a session that changes nothing ...
+ assert not report3.unsaved_changes # ... stays clean, though __exit__ still saves
# Check that the context manager doesn't swallow exceptions
with pytest.raises(ZeroDivisionError):
@@ -688,6 +694,40 @@ def test_remove():
assert r2.html[2] == r.html[3]
+def test_unsaved_changes(tmp_path):
+ """Test Report.unsaved_changes and Report.save(only_if_changed=True)."""
+ fname = tmp_path / "report.html"
+ kwargs = dict(open_browser=False, only_if_changed=True)
+ r = Report()
+ assert not r.unsaved_changes
+ fig1, fig2 = _get_example_figures()
+ r.add_figure(fig=fig1, title="figure1")
+ assert r.unsaved_changes
+ assert r.copy().unsaved_changes # copies inherit the flag
+ # the target does not exist yet, so it's written even though it's up to date
+ assert r.save(fname, **kwargs) == os.path.realpath(fname)
+ assert not r.unsaved_changes
+ content, mtime = fname.read_text("utf-8"), fname.stat().st_mtime_ns
+ # nothing changed, so nothing is written (and no overwrite=True needed)
+ assert r.save(fname, **kwargs) == os.path.realpath(fname)
+ assert (fname.read_text("utf-8"), fname.stat().st_mtime_ns) == (content, mtime)
+ # ... but every kind of content mutation makes it stale again (and get written)
+ for mutate in (
+ lambda: r.add_figure(fig=fig2, title="figure2"),
+ lambda: r.remove(title="figure2"),
+ lambda: r.reorder([0]),
+ lambda: r.add_custom_css("p {color: red;}"),
+ lambda: r.add_custom_js("console.log('hello');"),
+ ):
+ assert not r.unsaved_changes
+ mutate()
+ assert r.unsaved_changes
+ r.save(fname, overwrite=True, **kwargs)
+ assert fname.read_text("utf-8") != content
+ assert r.remove(title="does-not-exist") is None # no-op, so still up to date
+ assert not r.unsaved_changes
+
+
@pytest.mark.parametrize("tags", (True, False)) # shouldn't matter
def test_add_or_replace(tags):
"""Test replacing existing figures in a report."""