feat: Endname per renameat2(RENAME_NOREPLACE) – echtes rename ohne Überschreiben

Watcher, die nur auf IN_MOVED_TO/IN_CLOSE_WRITE lauschen, sehen damit neue
Dateien wie bei os.replace. Dateisysteme ohne renameat2 (NFS, CIFS) nutzen
weiter link() + unlink() bzw. prüfen + rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 19:49:03 +02:00
co-authored by Claude Opus 5.5
parent 2770c2e204
commit 00a0537085
3 changed files with 120 additions and 7 deletions
+9 -1
View File
@@ -7,7 +7,8 @@ verlieren. Ablauf für jede Datei:
Größe prüfen,
2. Metadaten setzen (mtime, Modus, Eigentümer – Fehler sind nur Hinweise),
3. prüfen, dass sich die Quelle seit der Stabilitätsmessung nicht verändert hat,
4. atomar auf den Endnamen umbenennen – ohne zu überschreiben (``link`` bzw.
4. atomar auf den Endnamen umbenennen – ohne zu überschreiben
(``renameat2(RENAME_NOREPLACE)``, sonst ``link`` + ``unlink`` bzw.
SFTP-``rename``), außer „überschreiben“ ist ausdrücklich eingestellt,
5. erst danach löscht der Aufrufer die Quelle.
@@ -33,6 +34,7 @@ from typing import Literal
from netmover.models import CollisionPolicy
from netmover.mounting.sftp import SftpSession
from netmover.mover.fsops import rename_noreplace
from netmover.mover.naming import collision_candidates
from netmover.timeouts import CancelToken
@@ -283,6 +285,9 @@ class LocalDestination(Destination):
def _link_noreplace(self, tmp: str, final: str) -> None:
src, dst = self._p(tmp), self._p(final)
# Bevorzugt ein echtes rename ohne Überschreiben (Watcher sehen IN_MOVED_TO).
if rename_noreplace(src, dst):
return
try:
os.link(src, dst)
except FileExistsError:
@@ -340,6 +345,9 @@ class LocalDestination(Destination):
raise CollisionSkip
continue
try:
if rename_noreplace(src, self._p(cand)):
final = cand
break
os.link(src, self._p(cand))
except FileExistsError:
if policy is CollisionPolicy.SKIP:
+53
View File
@@ -0,0 +1,53 @@
"""Atomares Umbenennen ohne Überschreiben.
``renameat2(..., RENAME_NOREPLACE)`` ist ein echtes ``rename`` (Watcher sehen
``IN_MOVED_TO`` wie bei ``os.replace``), scheitert aber mit ``EEXIST``, statt
eine vorhandene Datei zu ersetzen. ext4, xfs, btrfs und tmpfs können das; NFS
und CIFS meist nicht – dann liefert :func:`rename_noreplace` ``False`` und der
Aufrufer weicht auf ``link()`` + ``unlink()`` aus.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import errno
import os
from collections.abc import Callable
from functools import cache
AT_FDCWD = -100
RENAME_NOREPLACE = 1
_UNSUPPORTED = frozenset({errno.EINVAL, errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EPERM})
@cache
def _renameat2() -> Callable[..., int] | None:
name = ctypes.util.find_library("c")
if name is None:
return None
libc = ctypes.CDLL(name, use_errno=True)
fn = getattr(libc, "renameat2", None)
if fn is None:
return None
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
fn.restype = ctypes.c_int
renameat2: Callable[..., int] = fn
return renameat2
def rename_noreplace(src: str | os.PathLike[str], dst: str | os.PathLike[str]) -> bool:
"""True = umbenannt. False = vom Dateisystem nicht unterstützt (nichts passiert).
``FileExistsError``, wenn ``dst`` existiert; andere Fehler als ``OSError``."""
fn = _renameat2()
if fn is None:
return False
res = fn(AT_FDCWD, os.fsencode(src), AT_FDCWD, os.fsencode(dst), RENAME_NOREPLACE)
if res == 0:
return True
err = ctypes.get_errno()
if err == errno.EEXIST:
raise FileExistsError(err, os.strerror(err), os.fsdecode(dst))
if err in _UNSUPPORTED:
return False
raise OSError(err, os.strerror(err), os.fsdecode(src))