-
-
Notifications
You must be signed in to change notification settings - Fork 186
/
Copy pathSaveFile.py
124 lines (107 loc) · 5.43 KB
/
SaveFile.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# Copyright (c) 2021 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
import tempfile
import os
import os.path
import sys
from typing import Union, IO
fsync = os.fsync
if sys.platform != "win32":
import fcntl
def lockFile(file):
try:
fcntl.flock(file, fcntl.LOCK_EX)
except OSError: # Some file systems don't support file locks.
pass
except ValueError: # Can fail on some operating systems due to other instances of the application closing the file to prepare for deletion.
pass # It's okay to ignore the lock then because they were about to delete the file anyway.
if hasattr(fcntl, 'F_FULLFSYNC'):
def fsync(fd):
# https://lists.apple.com/archives/darwin-dev/2005/Feb/msg00072.html
# https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/fsync.2.html
fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
else: # On Windows, flock doesn't exist so we disable it at risk of corruption when using multiple application instances.
def lockFile(file): #pylint: disable=unused-argument
pass
class SaveFile:
"""A class to handle atomic writes to a file.
This class can be used to perform atomic writes to a file. Atomic writes ensure
that the file contents are always correct and that concurrent writes do not
end up writing to the same file at the same time.
"""
# How many time so re-try saving this file when getting unknown exceptions.
__max_retries = 10
# Create a new SaveFile.
#
# \param path The path to write to.
# \param mode The file mode to use. See open() for details.
# \param encoding The encoding to use while writing the file. Defaults to UTF-8.
# \param kwargs Keyword arguments passed on to open().
def __init__(self, path: Union[str, IO[str]], mode: str, encoding: str = "utf-8", **kwargs) -> None:
self._path = path
self._mode = mode
self._encoding = encoding
self._open_kwargs = kwargs
self._file = None
self._temp_file = None
def __enter__(self):
# Create a temporary file that we can write to.
self._temp_file = tempfile.NamedTemporaryFile(self._mode, dir = os.path.dirname(self._path), encoding = self._encoding, delete = False, **self._open_kwargs) #pylint: disable=bad-whitespace
return self._temp_file
def __exit__(self, exc_type, exc_value, traceback):
self._temp_file.flush()
fsync(self._temp_file.fileno())
self._temp_file.close()
self.__max_retries = 10
while not self._file:
# First, try to open the file we want to write to.
try:
self._file = open(self._path, self._mode, encoding = self._encoding, **self._open_kwargs)
except PermissionError:
self._file = None #Always retry.
except Exception as e:
if self.__max_retries <= 0:
raise e
self.__max_retries -= 1
while True:
# Try to acquire a lock. This will block if the file was already locked by a different process.
lockFile(self._file)
# Once the lock is released it is possible the other instance already replaced the file we opened.
# So try to open it again and check if we have the same file.
# If we do, that means the file did not get replaced in the mean time and we properly acquired a lock on the right file.
try:
file_new = open(self._path, self._mode, encoding = self._encoding, **self._open_kwargs)
except PermissionError:
# This primarily happens on Windows where trying to open an opened file will raise a PermissionError.
# We want to block on those, to simulate blocking writes.
continue
except Exception as e:
#In other cases with unknown exceptions, don't try again indefinitely.
if self.__max_retries <= 0:
raise e
self.__max_retries -= 1
continue
if not self._file.closed and os.path.sameopenfile(self._file.fileno(), file_new.fileno()):
file_new.close()
# Close the actual file to release the file lock.
# Note that this introduces a slight race condition where another process can lock the file
# before this process can replace it.
self._file.close()
# Replace the existing file with the temporary file.
# This operation is guaranteed to be atomic on Unix systems and should be atomic on Windows as well.
# This way we can ensure we either have the old file or the new file.
# Note that due to the above mentioned race condition, on Windows a PermissionError can be raised.
# If that happens, the replace operation failed and we should try again.
try:
os.replace(self._temp_file.name, self._path)
except PermissionError:
continue
except Exception as e:
if self.__max_retries <= 0:
raise e
self.__max_retries -= 1
break
else:
# Otherwise, retry the entire procedure.
self._file.close()
self._file = file_new