mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-08 10:44:09 -04:00
Start refactoring atomic move code
This commit is contained in:
parent
d0b27b38b6
commit
c3d7b84386
198
src/calibre/utils/copy_files.py
Normal file
198
src/calibre/utils/copy_files.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# License: GPLv3 Copyright: 2023, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Callable, Dict, Set, Tuple, Union
|
||||||
|
|
||||||
|
from calibre.constants import filesystem_encoding, iswindows
|
||||||
|
from calibre.utils.filenames import make_long_path_useable, samefile, windows_hardlink
|
||||||
|
|
||||||
|
if iswindows:
|
||||||
|
from calibre_extensions import winutil
|
||||||
|
|
||||||
|
WINDOWS_SLEEP_FOR_RETRY_TIME = 2 # seconds
|
||||||
|
WindowsFileId = Tuple[int, int, int]
|
||||||
|
|
||||||
|
class UnixFileCopier:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.copy_map: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def register(self, path: str, dest: str) -> None:
|
||||||
|
self.copy_map[path] = dest
|
||||||
|
|
||||||
|
def __enter__(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __exit__(self, *a) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def copy_all(self) -> None:
|
||||||
|
for src_path, dest_path in self.copy_map.items():
|
||||||
|
with suppress(OSError):
|
||||||
|
os.link(src_path, dest_path, follow_symlinks=False)
|
||||||
|
shutil.copystat(src_path, dest_path, follow_symlinks=False)
|
||||||
|
return
|
||||||
|
shutil.copy2(src_path, dest_path, follow_symlinks=False)
|
||||||
|
|
||||||
|
def delete_all_source_files(self) -> None:
|
||||||
|
for src_path in self.copy_map:
|
||||||
|
os.unlink(src_path)
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsFileCopier:
|
||||||
|
|
||||||
|
'''
|
||||||
|
Locks all files before starting the copy, ensuring other processes cannot interfere
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.path_to_fileid_map : Dict[str, WindowsFileId] = {}
|
||||||
|
self.fileid_to_paths_map: Dict[WindowsFileId, Set[str]] = defaultdict(set)
|
||||||
|
self.path_to_handle_map: Dict[str, 'winutil.Handle'] = {}
|
||||||
|
self.copy_map: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def register(self, path: str, dest: str) -> None:
|
||||||
|
with suppress(OSError):
|
||||||
|
# Ensure the file 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.copy_map[path] = dest
|
||||||
|
|
||||||
|
def _open_file(self, path: str, retry_on_sharing_violation: bool = True) -> 'winutil.Handle':
|
||||||
|
try:
|
||||||
|
return winutil.create_file(path, winutil.GENERIC_READ,
|
||||||
|
winutil.FILE_SHARE_DELETE,
|
||||||
|
winutil.OPEN_EXISTING, winutil.FILE_FLAG_SEQUENTIAL_SCAN)
|
||||||
|
except OSError as e:
|
||||||
|
if e.winerror == winutil.ERROR_SHARING_VIOLATION:
|
||||||
|
# The file could be a hardlink to an already opened file,
|
||||||
|
# in which case we use the same handle for both files
|
||||||
|
fileid = self.path_to_fileid_map[path]
|
||||||
|
for other in self.fileid_to_paths_map[fileid]:
|
||||||
|
if other in self.path_to_handle_map:
|
||||||
|
return self.path_to_handle_map[other]
|
||||||
|
if retry_on_sharing_violation:
|
||||||
|
time.sleep(WINDOWS_SLEEP_FOR_RETRY_TIME)
|
||||||
|
return self._open_file(path, False)
|
||||||
|
err = IOError(errno.EACCES,
|
||||||
|
_('File is open in another program'))
|
||||||
|
err.filename = path
|
||||||
|
raise err from e
|
||||||
|
raise
|
||||||
|
|
||||||
|
def __enter__(self) -> None:
|
||||||
|
for path, file_id in self.path_to_fileid_map.items():
|
||||||
|
self.fileid_to_paths_map[file_id].add(path)
|
||||||
|
for src in self.copy_map:
|
||||||
|
self.path_to_handle_map = self._open_file(src)
|
||||||
|
|
||||||
|
def __exit__(self, *a) -> None:
|
||||||
|
for h in self.path_to_handle_map.values():
|
||||||
|
h.close()
|
||||||
|
|
||||||
|
def copy_all(self) -> None:
|
||||||
|
for src_path, dest_path in self.copy_map.items():
|
||||||
|
with suppress(Exception):
|
||||||
|
windows_hardlink(src_path, dest_path)
|
||||||
|
shutil.copystat(src_path, dest_path, follow_symlinks=False)
|
||||||
|
handle = self.path_to_handle_map[src_path]
|
||||||
|
winutil.set_file_pointer(handle, 0, winutil.FILE_BEGIN)
|
||||||
|
with open(dest_path, 'wb') as f:
|
||||||
|
sz = 1024 * 1024
|
||||||
|
while True:
|
||||||
|
raw = winutil.read_file(handle, sz)
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
f.write(raw)
|
||||||
|
shutil.copystat(src_path, dest_path, follow_symlinks=False)
|
||||||
|
|
||||||
|
def delete_all_source_files(self) -> None:
|
||||||
|
for src_path in self.copy_map:
|
||||||
|
winutil.delete_file(make_long_path_useable(src_path))
|
||||||
|
|
||||||
|
|
||||||
|
def get_copier() -> Union[UnixFileCopier | WindowsFileCopier]:
|
||||||
|
return WindowsFileCopier() if iswindows else UnixFileCopier()
|
||||||
|
|
||||||
|
|
||||||
|
def copy_files(src_to_dest_map: Dict[str, str], delete_source: bool = False) -> None:
|
||||||
|
copier = get_copier()
|
||||||
|
for s, d in src_to_dest_map.items():
|
||||||
|
if not samefile(s, d):
|
||||||
|
copier.register(s, d)
|
||||||
|
with copier:
|
||||||
|
copier.copy_all()
|
||||||
|
if delete_source:
|
||||||
|
copier.delete_all_source_files()
|
||||||
|
|
||||||
|
|
||||||
|
def copy_tree(
|
||||||
|
src: str, dest: str,
|
||||||
|
transform_destination_filename: Callable[[str, str], str] = lambda src_path, dest_path : dest_path,
|
||||||
|
delete_source: bool = False
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Copy all files in the tree over. On Windows locks all files before starting the copy to ensure that
|
||||||
|
other processes cannot interfere once the copy starts.
|
||||||
|
'''
|
||||||
|
if iswindows:
|
||||||
|
if isinstance(src, bytes):
|
||||||
|
src = src.decode(filesystem_encoding)
|
||||||
|
if isinstance(dest, bytes):
|
||||||
|
dest = dest.decode(filesystem_encoding)
|
||||||
|
|
||||||
|
dest = os.path.abspath(dest)
|
||||||
|
os.makedirs(dest, exist_ok=True)
|
||||||
|
if samefile(src, dest):
|
||||||
|
raise ValueError(f'Cannot copy tree if the source and destination are the same: {src!r} == {dest!r}')
|
||||||
|
|
||||||
|
def raise_error(e: OSError) -> None:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def dest_from_entry(dirpath: str, x: str) -> str:
|
||||||
|
path = os.path.join(dirpath, d)
|
||||||
|
rel = os.path.relpath(path, src)
|
||||||
|
return os.path.join(dest, rel)
|
||||||
|
|
||||||
|
|
||||||
|
copier = get_copier()
|
||||||
|
for (dirpath, dirnames, filenames) in os.walk(src, onerror=raise_error):
|
||||||
|
for d in dirnames:
|
||||||
|
path = os.path.join(dirpath, d)
|
||||||
|
dest = dest_from_entry(dirpath, d)
|
||||||
|
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)
|
||||||
|
for f in filenames:
|
||||||
|
path = os.path.join(dirpath, f)
|
||||||
|
dest = dest_from_entry(dirpath, d)
|
||||||
|
dest = transform_destination_filename(path, dest)
|
||||||
|
if not iswindows:
|
||||||
|
s = os.stat(path, follow_symlinks=False)
|
||||||
|
if stat.S_ISLNK(s.st_mode):
|
||||||
|
link_dest = os.readlink(path)
|
||||||
|
os.symlink(link_dest, dest)
|
||||||
|
continue
|
||||||
|
copier.register(path, dest)
|
||||||
|
|
||||||
|
|
||||||
|
with copier:
|
||||||
|
copier.copy_all()
|
||||||
|
if delete_source:
|
||||||
|
copier.delete_all_source_files()
|
||||||
|
|
||||||
|
if delete_source:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(make_long_path_useable(src))
|
||||||
|
except OSError:
|
||||||
|
if iswindows:
|
||||||
|
time.sleep(WINDOWS_SLEEP_FOR_RETRY_TIME)
|
||||||
|
shutil.rmtree(make_long_path_useable(src))
|
||||||
|
else:
|
||||||
|
raise
|
24
src/calibre/utils/copy_files_test.py
Normal file
24
src/calibre/utils/copy_files_test.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# License: GPLv3 Copyright: 2023, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopyFiles(unittest.TestCase):
|
||||||
|
|
||||||
|
ae = unittest.TestCase.assertEqual
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tdir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tdir)
|
||||||
|
|
||||||
|
def test_copy_files(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def find_tests():
|
||||||
|
return unittest.defaultTestLoader.loadTestsFromTestCase(TestCopyFiles)
|
@ -291,6 +291,8 @@ def find_tests(which_tests=None, exclude_tests=None):
|
|||||||
a(find_tests())
|
a(find_tests())
|
||||||
from calibre.live import find_tests
|
from calibre.live import find_tests
|
||||||
a(find_tests())
|
a(find_tests())
|
||||||
|
from calibre.utils.copy_files_test import find_tests
|
||||||
|
a(find_tests())
|
||||||
if iswindows:
|
if iswindows:
|
||||||
from calibre.utils.windows.wintest import find_tests
|
from calibre.utils.windows.wintest import find_tests
|
||||||
a(find_tests())
|
a(find_tests())
|
||||||
|
Loading…
x
Reference in New Issue
Block a user