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