Windows: Make moving files in the calibre library folder more robust

Now even folders are opened and locked at the start of the operation not
just files. Also folders have their attributes reset to normal in case
they are marked readonly, not just files.
This commit is contained in:
Kovid Goyal 2023-06-06 11:33:06 +05:30
parent b725d7feb6
commit c2e9a78ddf
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
2 changed files with 35 additions and 5 deletions

View File

@ -8,7 +8,7 @@ import stat
import time import time
from collections import defaultdict from collections import defaultdict
from contextlib import suppress from contextlib import suppress
from typing import Callable, Dict, Set, Tuple, Union from typing import Callable, Dict, Set, Tuple, Union, List
from calibre.constants import filesystem_encoding, iswindows from calibre.constants import filesystem_encoding, iswindows
from calibre.utils.filenames import make_long_path_useable, samefile, windows_hardlink from calibre.utils.filenames import make_long_path_useable, samefile, windows_hardlink
@ -28,6 +28,9 @@ class UnixFileCopier:
def register(self, path: str, dest: str) -> None: def register(self, path: str, dest: str) -> None:
self.copy_map[path] = dest self.copy_map[path] = dest
def register_folder(self, path: str) -> None:
pass
def __enter__(self) -> None: def __enter__(self) -> None:
pass pass
@ -64,6 +67,8 @@ class WindowsFileCopier:
self.path_to_fileid_map : Dict[str, WindowsFileId] = {} self.path_to_fileid_map : Dict[str, WindowsFileId] = {}
self.fileid_to_paths_map: Dict[WindowsFileId, Set[str]] = defaultdict(set) self.fileid_to_paths_map: Dict[WindowsFileId, Set[str]] = defaultdict(set)
self.path_to_handle_map: Dict[str, 'winutil.Handle'] = {} self.path_to_handle_map: Dict[str, 'winutil.Handle'] = {}
self.folder_to_handle_map: Dict[str, 'winutil.Handle'] = {}
self.folders: List[str] = []
self.copy_map: Dict[str, str] = {} self.copy_map: Dict[str, str] = {}
def register(self, path: str, dest: str) -> None: def register(self, path: str, dest: str) -> None:
@ -73,11 +78,18 @@ class WindowsFileCopier:
self.path_to_fileid_map[path] = winutil.get_file_id(make_long_path_useable(path)) self.path_to_fileid_map[path] = winutil.get_file_id(make_long_path_useable(path))
self.copy_map[path] = dest self.copy_map[path] = dest
def _open_file(self, path: str, retry_on_sharing_violation: bool = True) -> 'winutil.Handle': def register_folder(self, path: str) -> None:
with suppress(OSError):
# Ensure the folder is not read-only
winutil.set_file_attributes(make_long_path_useable(path), winutil.FILE_ATTRIBUTE_NORMAL)
self.path_to_fileid_map[path] = winutil.get_file_id(make_long_path_useable(path))
self.folders.append(path)
def _open_file(self, path: str, retry_on_sharing_violation: bool = True, is_folder: bool = False) -> 'winutil.Handle':
flags = winutil.FILE_FLAG_BACKUP_SEMANTICS if is_folder else winutil.FILE_FLAG_SEQUENTIAL_SCAN
try: try:
return winutil.create_file(make_long_path_useable(path), winutil.GENERIC_READ, return winutil.create_file(make_long_path_useable(path), winutil.GENERIC_READ,
winutil.FILE_SHARE_DELETE, winutil.FILE_SHARE_DELETE, winutil.OPEN_EXISTING, flags)
winutil.OPEN_EXISTING, winutil.FILE_FLAG_SEQUENTIAL_SCAN)
except OSError as e: except OSError as e:
if e.winerror == winutil.ERROR_SHARING_VIOLATION: if e.winerror == winutil.ERROR_SHARING_VIOLATION:
# The file could be a hardlink to an already opened file, # The file could be a hardlink to an already opened file,
@ -88,7 +100,7 @@ class WindowsFileCopier:
return self.path_to_handle_map[other] return self.path_to_handle_map[other]
if retry_on_sharing_violation: if retry_on_sharing_violation:
time.sleep(WINDOWS_SLEEP_FOR_RETRY_TIME) time.sleep(WINDOWS_SLEEP_FOR_RETRY_TIME)
return self._open_file(path, False) return self._open_file(path, False, is_folder)
err = IOError(errno.EACCES, err = IOError(errno.EACCES,
_('File is open in another program')) _('File is open in another program'))
err.filename = path err.filename = path
@ -100,6 +112,8 @@ class WindowsFileCopier:
self.fileid_to_paths_map[file_id].add(path) self.fileid_to_paths_map[file_id].add(path)
for src in self.copy_map: for src in self.copy_map:
self.path_to_handle_map[src] = self._open_file(src) self.path_to_handle_map[src] = self._open_file(src)
for path in self.folders:
self.folder_to_handle_map[path] = self._open_file(path, is_folder=True)
def __exit__(self, exc_type, exc_val, exc_tb) -> None: def __exit__(self, exc_type, exc_val, exc_tb) -> None:
try: try:
@ -108,6 +122,8 @@ class WindowsFileCopier:
finally: finally:
for h in self.path_to_handle_map.values(): for h in self.path_to_handle_map.values():
h.close() h.close()
for h in self.folder_to_handle_map.values():
h.close()
def copy_all(self) -> None: def copy_all(self) -> None:
for src_path, dest_path in self.copy_map.items(): for src_path, dest_path in self.copy_map.items():
@ -133,6 +149,8 @@ class WindowsFileCopier:
def delete_all_source_files(self) -> None: def delete_all_source_files(self) -> None:
for src_path in self.copy_map: for src_path in self.copy_map:
winutil.delete_file(make_long_path_useable(src_path)) winutil.delete_file(make_long_path_useable(src_path))
for path in reversed(self.folders):
os.rmdir(make_long_path_useable(path))
def get_copier(delete_all=False) -> Union[UnixFileCopier, WindowsFileCopier]: def get_copier(delete_all=False) -> Union[UnixFileCopier, WindowsFileCopier]:
@ -189,12 +207,14 @@ def copy_tree(
copier = get_copier(delete_source) copier = get_copier(delete_source)
copier.register_folder(src)
for (dirpath, dirnames, filenames) in os.walk(src, onerror=raise_error): for (dirpath, dirnames, filenames) in os.walk(src, onerror=raise_error):
for d in dirnames: for d in dirnames:
path = os.path.join(dirpath, d) path = os.path.join(dirpath, d)
dest = dest_from_entry(dirpath, d) dest = dest_from_entry(dirpath, d)
os.makedirs(make_long_path_useable(dest), exist_ok=True) os.makedirs(make_long_path_useable(dest), exist_ok=True)
shutil.copystat(make_long_path_useable(path), make_long_path_useable(dest), follow_symlinks=False) shutil.copystat(make_long_path_useable(path), make_long_path_useable(dest), follow_symlinks=False)
copier.register_folder(path)
for f in filenames: for f in filenames:
path = os.path.join(dirpath, f) path = os.path.join(dirpath, f)
dest = dest_from_entry(dirpath, f) dest = dest_from_entry(dirpath, f)
@ -214,6 +234,8 @@ def copy_tree(
if delete_source: if delete_source:
try: try:
shutil.rmtree(make_long_path_useable(src)) shutil.rmtree(make_long_path_useable(src))
except FileNotFoundError:
pass
except OSError: except OSError:
if iswindows: if iswindows:
time.sleep(WINDOWS_SLEEP_FOR_RETRY_TIME) time.sleep(WINDOWS_SLEEP_FOR_RETRY_TIME)

View File

@ -6,12 +6,15 @@ import shutil
import tempfile import tempfile
import time import time
import unittest import unittest
from contextlib import closing
from calibre import walk from calibre import walk
from calibre.constants import iswindows from calibre.constants import iswindows
from .copy_files import copy_tree, rename_files from .copy_files import copy_tree, rename_files
from .filenames import nlinks_file from .filenames import nlinks_file
if iswindows:
from calibre_extensions import winutil
class TestCopyFiles(unittest.TestCase): class TestCopyFiles(unittest.TestCase):
@ -97,6 +100,11 @@ class TestCopyFiles(unittest.TestCase):
self.assertRaises(IOError, copy_tree, src, dest) self.assertRaises(IOError, copy_tree, src, dest)
self.ae(os.listdir(self.d()), ['sub']) self.ae(os.listdir(self.d()), ['sub'])
self.assertFalse(tuple(walk(self.d()))) self.assertFalse(tuple(walk(self.d())))
h = winutil.create_file(self.s('sub'), winutil.GENERIC_READ, 0, winutil.OPEN_EXISTING, winutil.FILE_FLAG_BACKUP_SEMANTICS)
with closing(h):
self.assertRaises(IOError, copy_tree, src, dest)
self.ae(os.listdir(self.d()), ['sub'])
self.assertFalse(tuple(walk(self.d())))
def find_tests(): def find_tests():
return unittest.defaultTestLoader.loadTestsFromTestCase(TestCopyFiles) return unittest.defaultTestLoader.loadTestsFromTestCase(TestCopyFiles)