Skip to content

Commit cceff06

Browse files
committed
Support SAV publication without hard links
1 parent b3a225e commit cceff06

2 files changed

Lines changed: 46 additions & 4 deletions

File tree

‎src/openstatspec/spss/sav.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import hashlib
1010
import os
1111
import json
12+
import errno
1213
import math
1314
import ctypes
1415
from contextlib import ExitStack, contextmanager
@@ -352,15 +353,28 @@ def _publish_staged_destination(
352353
state.update({"had_previous": True, "backup_installed": True})
353354
else:
354355
backup.unlink(missing_ok=True)
355-
356356
# The hard-link claim is atomic and fails if another process publishes the
357-
# destination after the check above. Staging is on the same filesystem.
358-
os.link(staged, destination)
357+
# destination after the check above. Some portable filesystems do not
358+
# implement hard links; the surrounding export lock serializes those
359+
# fallback replacements with every other OpenStatSpec publisher.
360+
try:
361+
os.link(staged, destination)
362+
except OSError as link_error:
363+
if link_error.errno not in {
364+
errno.EOPNOTSUPP, errno.ENOTSUP, errno.EPERM, errno.EXDEV,
365+
}:
366+
raise
367+
if _path_entry_exists(destination):
368+
raise FileExistsError(
369+
"The export destination was published by another operation."
370+
) from link_error
371+
os.replace(staged, destination)
372+
else:
373+
staged.unlink()
359374
published_identity = _destination_identity(destination)
360375
if published_identity is None:
361376
raise FileNotFoundError("The published export destination disappeared.")
362377
state["published_identity"] = published_identity
363-
staged.unlink()
364378

365379

366380
@contextmanager

‎tests/test_sav_sqlite.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import hashlib
2+
import errno
23
import json
34
import sqlite3
45
import threading
@@ -384,3 +385,30 @@ def publish_concurrently(source, target):
384385
assert sav_module._path_entry_exists(backup) is False
385386
assert state["published_identity"] is None
386387

388+
389+
def test_export_publication_falls_back_when_hard_links_are_unsupported(
390+
tmp_path, monkeypatch,
391+
) -> None:
392+
staged = tmp_path / "staged.sav"
393+
destination = tmp_path / "destination.sav"
394+
backup = tmp_path / "destination.previous"
395+
staged.write_bytes(b"portable staged export")
396+
397+
def unsupported_hard_link(*_args, **_kwargs):
398+
raise OSError(errno.EOPNOTSUPP, "hard links are unsupported")
399+
400+
monkeypatch.setattr(sav_module.os, "link", unsupported_hard_link)
401+
state = {}
402+
sav_module._publish_staged_destination(
403+
staged=staged,
404+
destination=destination,
405+
backup=backup,
406+
state=state,
407+
)
408+
409+
assert destination.read_bytes() == b"portable staged export"
410+
assert not staged.exists()
411+
assert state["published_identity"] == sav_module._destination_identity(
412+
destination,
413+
)
414+

0 commit comments

Comments
 (0)