From e7d5c6af1ad1b956b052da912da5486e7f5030ab Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 31 Aug 2026 18:35:30 -0400 Subject: [PATCH] Add Report.save(only_if_changed=True) --- doc/changes/dev/14261.newfeature.rst | 1 + mne/report/report.py | 51 +++++++++++++++++++++++++++- mne/report/tests/test_report.py | 42 ++++++++++++++++++++++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 doc/changes/dev/14261.newfeature.rst 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."""