Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/14261.newfeature.rst
Original file line number Diff line number Diff line change
@@ -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`_.
51 changes: 50 additions & 1 deletion mne/report/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1259,6 +1280,7 @@ def add_custom_css(self, css):
"""
style = f'\n<style type="text/css">\n{css}\n</style>'
self.include += style
self._unsaved_changes = True

def add_custom_js(self, js):
"""Add custom JavaScript to the report.
Expand All @@ -1275,6 +1297,7 @@ def add_custom_js(self, js):
"""
script = f'\n<script type="text/javascript">\n{js}\n</script>'
self.include += script
self._unsaved_changes = True

@fill_doc
def add_epochs(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
42 changes: 41 additions & 1 deletion mne/report/tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down
Loading