From 84aff3f71d4c6ecfa73b2dbcd3d63ff6083acf95 Mon Sep 17 00:00:00 2001 From: "Kirill A. Korinsky" Date: Fri, 17 Sep 2021 14:12:51 +0200 Subject: [PATCH] Introduce atomic move and write of file The idea of this changes is simple: let move file to some temporary name inside distance folder, and after the file is already copy it renames to expected name. When someone tries to save anything it also moves file to trigger OS level notification for change FS. This commit also enforce that `beets.util.move` shouldn't be used to move directories as it described in comment. Thus, this is fixed #3849 --- beets/library.py | 2 ++ beets/util/__init__.py | 33 +++++++++++++++++++++++---------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/beets/library.py b/beets/library.py index 4e8eed0019..8d51092e9f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -760,6 +760,8 @@ def write(self, path=None, tags=None, id3v23=None): mediafile.update(item_tags) try: mediafile.save() + # Move file to trigger OS event if some processes subscribed to it + util.move(path, path, force=True) except UnreadableFileError as exc: raise WriteError(self.path, exc) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index affdff12f0..ae187932de 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -21,6 +21,7 @@ import errno import locale import re +import tempfile import shutil import fnmatch import functools @@ -474,32 +475,44 @@ def copy(path, dest, replace=False): traceback.format_exc()) -def move(path, dest, replace=False): +def move(path, dest, replace=False, force=False): """Rename a file. `dest` may not be a directory. If `dest` already exists, raises an OSError unless `replace` is True. Has no effect if - `path` is the same as `dest`. If the paths are on different - filesystems (or the rename otherwise fails), a copy is attempted - instead, in which case metadata will *not* be preserved. Paths are - translated to system paths. + `path` is the same as `dest` unless `force` is True. If the paths are + on different filesystems (or the rename otherwise fails), a copy + is attempted instead, in which case metadata will *not* be preserved. + Paths are translated to system paths. """ - if samefile(path, dest): + if os.path.isdir(path): + raise FilesystemError(u'source is directory', 'move', (path, dest)) + if os.path.isdir(dest): + raise FilesystemError(u'distance is directory', 'move', (path, dest)) + if samefile(path, dest) and not force: return path = syspath(path) dest = syspath(dest) - if os.path.exists(dest) and not replace: + if os.path.exists(dest) and not replace and not force: raise FilesystemError(u'file exists', 'rename', (path, dest)) # First, try renaming the file. try: - os.rename(path, dest) + os.replace(path, dest) except OSError: - # Otherwise, copy and delete the original. + tmp = tempfile.mktemp(suffix='.beets', + prefix=py3_path(b'.' + os.path.basename(dest)), + dir=py3_path(os.path.dirname(dest))) + tmp = syspath(tmp) try: - shutil.copyfile(path, dest) + shutil.copyfile(path, tmp) + os.replace(tmp, dest) + tmp = None os.remove(path) except (OSError, IOError) as exc: raise FilesystemError(exc, 'move', (path, dest), traceback.format_exc()) + finally: + if tmp is not None: + os.remove(tmp) def link(path, dest, replace=False):