From a83bee472d3a4f57633debb10991971a4fe222b6 Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 28 May 2026 12:02:13 -0500 Subject: [PATCH 1/2] Skip self-copy when reading uncopied lazy-loaded field When a file-backed NXfield is deep-copied (via NXdata.__init__, NXdata.weighted_data, etc.), `__deepcopy__` records `_uncopied_data = (file, source_path)` so the data can be lazily transferred to its new destination at next read. If the deep-copied field ends up at the same on-disk path as the source -- e.g. the nexpy PlotDialog wraps a field selected from inside an NXdata in a new in-memory NXdata named after the original group and reparents to the same grandparent -- then `_get_uncopied_data` asks HDF5 to copy the field onto itself and h5py raises `RuntimeError: Unable to synchronously copy object (destination object already exists)`. Guard `_get_uncopied_data` against this case: when the source and destination paths agree, the data is already where it needs to be, so skip the copy and fall through to the normal readvalue. Tests: new tests/test_files.py::test_read_lazy_field_after_same_path_rewrap reproduces the original crash (the field has to stay lazy-loaded, hence the 2k x 2k mask) and asserts a clean read after the fix. Full suite still passes (104 tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/nexusformat/nexus/tree.py | 11 ++++++++- tests/test_files.py | 46 +++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/nexusformat/nexus/tree.py b/src/nexusformat/nexus/tree.py index 0dca1252..6d86c8e1 100644 --- a/src/nexusformat/nexus/tree.py +++ b/src/nexusformat/nexus/tree.py @@ -3286,7 +3286,16 @@ def _get_uncopied_data(self, idx=None): return f.readvalue(_path, idx=idx) else: if self.nxfilemode == 'rw': - f.copy(_path, self.nxpath) + # Skip the copy if the field already lives at the + # destination path. This happens when a file-backed + # field is deep-copied into a new container that + # ends up at the same on-disk location (e.g. the + # PlotDialog wraps a field in an in-memory NXdata + # whose nxpath collides with the source). HDF5 + # otherwise raises 'destination object already + # exists' here. + if _path != self.nxpath: + f.copy(_path, self.nxpath) else: self._create_memfile() f.copy(_path, self._memfile, name='data') diff --git a/tests/test_files.py b/tests/test_files.py index d4dc8dcd..3e72cb37 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -1,8 +1,9 @@ import os +import numpy as np import pytest -from nexusformat.nexus.tree import (NXdata, NXentry, NXFile, NXroot, nxload, - nxopen) +from nexusformat.nexus.tree import (NXdata, NXentry, NXfield, NXFile, NXroot, + nxload, nxopen) def test_file_creation(tmpdir): @@ -80,3 +81,44 @@ def test_file_context_manager(tmpdir, field1, field2): assert "entry/data/f2" in w2 assert "signal" in w2["entry/data"].attrs assert "axes" in w2["entry/data"].attrs + + +def test_read_lazy_field_after_same_path_rewrap(tmpdir): + """A file-backed NXfield that has been deep-copied into a new + in-memory container at the same on-disk path used to raise + ``RuntimeError: destination object already exists`` when read, + because ``_get_uncopied_data`` asked HDF5 to copy the field onto + itself. This is the scenario hit by the nexpy PlotDialog when a + field inside an NXdata is selected as the signal: the dialog + wraps it in a fresh NXdata with the same name and reparents to + the original grandparent, so the wrapped field's nxpath collides + with the source. The field must be large enough to stay + lazy-loaded (``_value is None``) so the deepcopy preserves the + ``_uncopied_data`` reference. + """ + filename = os.path.join(tmpdir, "file.nxs") + shape = (2000, 2000) # big enough to stay lazy-loaded + root = NXroot(NXentry(NXdata( + NXfield(np.zeros(shape, dtype=np.int64), name="signal"), + name="data"))) + root["entry/data/signal_mask"] = NXfield(np.zeros(shape, dtype=bool)) + root["entry/data/signal"].attrs["mask"] = "signal_mask" + root.save(filename, mode="w") + del root + + root = nxload(filename, "rw") + src = root["entry/data"] + # Mimic PlotDialog: wrap the mask in a new NXdata named after the + # original group, reparented to the entry. The wrapped field + # ends up at /entry/data/signal_mask -- the same path as the + # source. + wrapper = NXdata(src["signal_mask"], name=src.nxname) + wrapper.nxgroup = src.nxgroup + field = wrapper.nxsignal + assert field._uncopied_data is not None + assert field._uncopied_data[1] == field.nxpath + + arr = field[()] + assert arr.shape == shape + assert arr.dtype == bool + assert field._uncopied_data is None From 768bed9eea657ce94217acde225a4ecd28fe43cc Mon Sep 17 00:00:00 2001 From: Ray Osborn Date: Thu, 28 May 2026 13:34:37 -0500 Subject: [PATCH 2/2] Remove NeXpy-specific comments --- tests/test_files.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/tests/test_files.py b/tests/test_files.py index 3e72cb37..fe0f529f 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -83,21 +83,10 @@ def test_file_context_manager(tmpdir, field1, field2): assert "axes" in w2["entry/data"].attrs -def test_read_lazy_field_after_same_path_rewrap(tmpdir): - """A file-backed NXfield that has been deep-copied into a new - in-memory container at the same on-disk path used to raise - ``RuntimeError: destination object already exists`` when read, - because ``_get_uncopied_data`` asked HDF5 to copy the field onto - itself. This is the scenario hit by the nexpy PlotDialog when a - field inside an NXdata is selected as the signal: the dialog - wraps it in a fresh NXdata with the same name and reparents to - the original grandparent, so the wrapped field's nxpath collides - with the source. The field must be large enough to stay - lazy-loaded (``_value is None``) so the deepcopy preserves the - ``_uncopied_data`` reference. - """ +def test_read_lazy_field_on_copy(tmpdir): + filename = os.path.join(tmpdir, "file.nxs") - shape = (2000, 2000) # big enough to stay lazy-loaded + shape = (2000, 2000) root = NXroot(NXentry(NXdata( NXfield(np.zeros(shape, dtype=np.int64), name="signal"), name="data"))) @@ -108,10 +97,6 @@ def test_read_lazy_field_after_same_path_rewrap(tmpdir): root = nxload(filename, "rw") src = root["entry/data"] - # Mimic PlotDialog: wrap the mask in a new NXdata named after the - # original group, reparented to the entry. The wrapped field - # ends up at /entry/data/signal_mask -- the same path as the - # source. wrapper = NXdata(src["signal_mask"], name=src.nxname) wrapper.nxgroup = src.nxgroup field = wrapper.nxsignal