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
10 changes: 8 additions & 2 deletions xrspatial/geotiff/_vrt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2242,7 +2242,13 @@ def _pixel_size_mismatch(a: float, b: float) -> bool:
lines.append('</VRTDataset>')

xml = '\n'.join(lines) + '\n'
with open(vrt_path, 'w') as f:
f.write(xml)
# Write the index atomically: a temp file in the destination
# directory followed by ``os.replace``. The tiled writer promotes the
# tile directory before this call, so a direct in-place write here is
# the last non-atomic step and an interrupted write would leave a
# partial ``.vrt`` pointing at a complete tile set. ``_write_bytes``
# already implements the temp-then-rename pattern for local paths.
from ._writer import _write_bytes
_write_bytes(xml.encode('utf-8'), vrt_path)

return vrt_path
74 changes: 74 additions & 0 deletions xrspatial/geotiff/tests/write/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,80 @@ def test_compatible_sources_succeed(tmp_path):
assert os.path.exists(vrt)


def test_write_vrt_leaves_no_temp_file(tmp_path):
"""A successful write_vrt leaves the final .vrt and no stray temp
files in the destination directory (issue #2965)."""
d = _unique_dir(tmp_path, "atomic_clean")
a = os.path.join(d, "a.tif")
b = os.path.join(d, "b.tif")
_write_tif(a, h=4, w=4, dtype=np.float32)
_write_tif(b, h=4, w=4, dtype=np.float32, origin_x=4.0)
vrt = os.path.join(d, "out.vrt")
_priv_write_vrt(vrt, [a, b])
assert os.path.exists(vrt)
# No leftover temp artifacts from the temp-then-rename write.
leftovers = glob.glob(os.path.join(d, "*.tmp")) + glob.glob(
os.path.join(d, "*.tmp*"))
assert leftovers == [], f"temp files not cleaned up: {leftovers}"


def test_write_vrt_no_partial_file_on_write_failure(tmp_path, monkeypatch):
"""If the index write fails mid-flight, no partial .vrt is left at
the final path (issue #2965). The write goes to a temp file in the
destination directory and is os.replace'd into place, so a failure
before the rename never publishes a truncated index."""
d = _unique_dir(tmp_path, "atomic_fail")
a = os.path.join(d, "a.tif")
b = os.path.join(d, "b.tif")
_write_tif(a, h=4, w=4, dtype=np.float32)
_write_tif(b, h=4, w=4, dtype=np.float32, origin_x=4.0)
vrt = os.path.join(d, "out.vrt")

# Force the atomic helper to blow up while writing the temp file,
# before any rename onto the final path could happen.
def boom(file_bytes, path):
raise OSError("simulated disk-full during VRT index write")

# write_vrt imports _write_bytes locally from _writer at call time,
# so patching the source module is what intercepts the call.
monkeypatch.setattr(writer_mod, "_write_bytes", boom)

with pytest.raises(OSError, match="simulated disk-full"):
_priv_write_vrt(vrt, [a, b])

# The final path must not exist as a partial file.
assert not os.path.exists(vrt)


def test_write_vrt_failure_preserves_existing_file(tmp_path, monkeypatch):
"""A failed re-write leaves any pre-existing .vrt at the final path
untouched rather than truncating it (issue #2965)."""
d = _unique_dir(tmp_path, "atomic_preserve")
a = os.path.join(d, "a.tif")
b = os.path.join(d, "b.tif")
_write_tif(a, h=4, w=4, dtype=np.float32)
_write_tif(b, h=4, w=4, dtype=np.float32, origin_x=4.0)
vrt = os.path.join(d, "out.vrt")

# First write succeeds and produces a valid index.
_priv_write_vrt(vrt, [a, b])
with open(vrt, "rb") as f:
original = f.read()
assert original

def boom(file_bytes, path):
raise OSError("simulated failure during VRT rewrite")

monkeypatch.setattr(writer_mod, "_write_bytes", boom)
with pytest.raises(OSError, match="simulated failure"):
_priv_write_vrt(vrt, [a, b])

# The original index is intact (the temp-then-rename never published
# a partial file over it).
with open(vrt, "rb") as f:
assert f.read() == original


def test_pixel_size_within_tolerance_accepted(tmp_path):
d = _unique_dir(tmp_path, "tol")
a = os.path.join(d, "a.tif")
Expand Down
Loading