Skip to content

Commit 75a3cb1

Browse files
Merge pull request #42 from geo-stack/support_atomic_write_savefile_manager
PR: Add option to save files atomically in the SaveFileManager
2 parents 84c4237 + 6d2d2cc commit 75a3cb1

File tree

2 files changed

+300
-88
lines changed

2 files changed

+300
-88
lines changed

qtapputils/managers/fileio.py

Lines changed: 122 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from typing import Callable
1212

1313
# ---- Standard imports
14+
import os
1415
import os.path as osp
16+
import uuid
1517

1618
# ---- Third party imports
1719
from qtpy.QtCore import QObject
@@ -20,16 +22,15 @@
2022

2123
class SaveFileManager(QObject):
2224
def __init__(self, namefilters: dict, onsave: Callable,
23-
parent: QWidget = None):
25+
parent: QWidget = None, atomic: bool = False):
2426
"""
2527
A manager to save files.
2628
2729
Parameters
2830
----------
2931
namefilters : dict
3032
A dictionary containing the file filters to use in the
31-
'Save As' file dialog. Here is an example of a correctly
32-
formated namefilters dictionary:
33+
'Save As' file dialog. For example:
3334
3435
namefilters = {
3536
'.pdf': 'Portable Document Format (*.pdf)',
@@ -39,43 +40,143 @@ def __init__(self, namefilters: dict, onsave: Callable,
3940
}
4041
4142
Note that the first entry in the dictionary will be used as the
42-
default name filter to use in the 'Save As' dialog.
43+
default name filter in the 'Save As' dialog.
4344
onsave : Callable
44-
The callable that is used to save the file.
45+
The callable that is used to save the file. This should be a
46+
function that takes the output filename as its first argument,
47+
and writes the file contents to disk.
4548
parent: QWidget, optional
4649
The parent widget to use for the 'Save As' file dialog.
50+
atomic: bool, optional
51+
Whether to save files atomically (write to a temp file then move).
52+
Defaults to False for backward compatibility. For better data
53+
integrity, consider setting atomic=True.
4754
"""
4855
super().__init__()
4956
self.parent = parent
5057
self.namefilters = namefilters
5158
self.onsave = onsave
59+
self.atomic = atomic
60+
61+
def _get_valid_tempname(self, filename):
62+
destdir = osp.dirname(filename)
63+
while True:
64+
tempname = osp.join(
65+
destdir,
66+
f'.temp_{str(uuid.uuid4())[:8]}_'
67+
f'{osp.basename(filename)}'
68+
)
69+
if not osp.exists(tempname):
70+
return tempname
71+
72+
def _get_new_save_filename(self, filename):
73+
root, ext = osp.splitext(filename)
74+
if ext not in self.namefilters:
75+
ext = next(iter(self.namefilters))
76+
filename += ext
77+
78+
filename, filefilter = QFileDialog.getSaveFileName(
79+
self.parent,
80+
"Save As",
81+
filename,
82+
';;'.join(self.namefilters.values()),
83+
self.namefilters[ext])
84+
85+
if filename:
86+
# Make sure the filename has the right extension.
87+
ext = dict(map(reversed, self.namefilters.items()))[filefilter]
88+
if not filename.endswith(ext):
89+
filename += ext
90+
91+
return filename
5292

93+
# ---- Public methods
5394
def save_file(self, filename: str, *args, **kwargs) -> str:
5495
"""
55-
Save in provided filename.
96+
ave file to the provided filename, with atomic write option.
5697
5798
Parameters
5899
----------
59100
filename : str
60-
The abosulte path where to save the file.
101+
The absolute path where to save the file.
61102
62103
Returns
63104
-------
64105
filename : str
65-
The absolute path where the file was successfully saved. Returns
66-
'None' if the saving operation was cancelled or was unsuccessfull.
106+
The absolute path where the file was successfully saved.
107+
Returns None if save was cancelled or unsuccessful.
67108
"""
68-
try:
69-
self.onsave(filename, *args, **kwargs)
70-
except PermissionError:
109+
def _show_warning(message: str):
71110
QMessageBox.warning(
72-
self.parent,
73-
'File in Use',
74-
("The save file operation cannot be completed because the "
75-
"file is in use by another application or user."),
76-
QMessageBox.Ok)
77-
filename = self.save_file_as(filename, *args, **kwargs)
78-
return filename
111+
self.parent, 'Save Error', message, QMessageBox.Ok
112+
)
113+
114+
def _show_critical(error: Exception):
115+
msg = (f'An unexpected error occurred while saving the file:'
116+
f'<br><br>'
117+
f'<font color="#CC0000">{type(error).__name__}:</font> '
118+
f'{error}')
119+
QMessageBox.critical(
120+
self.parent, 'Save Error', msg, QMessageBox.Ok
121+
)
122+
123+
write_permission_msg = (
124+
"You do not have write permission for this location.\n\n"
125+
"Please choose a different location and try again."
126+
)
127+
overwrite_error_msg = (
128+
"The save operation could not be completed because:\n\n"
129+
"- You do not have write permission for the selected location"
130+
", or\n"
131+
"- The file is currently in use by another application.\n\n"
132+
"Please choose a different location or ensure the file is not "
133+
"open in another program and try again."
134+
)
135+
136+
while True:
137+
file_exists = osp.exists(filename)
138+
tempname = None
139+
140+
try:
141+
if self.atomic:
142+
tempname = self._get_valid_tempname(filename)
143+
self.onsave(tempname, *args, **kwargs)
144+
try:
145+
os.replace(tempname, filename)
146+
return filename
147+
except PermissionError:
148+
if file_exists:
149+
_show_warning(overwrite_error_msg)
150+
else:
151+
_show_warning(write_permission_msg)
152+
153+
filename = self._get_new_save_filename(filename)
154+
if not filename:
155+
return None
156+
else:
157+
self.onsave(filename, *args, **kwargs)
158+
return filename
159+
160+
except PermissionError:
161+
if self.atomic or not file_exists:
162+
_show_warning(write_permission_msg)
163+
else:
164+
_show_warning(overwrite_error_msg)
165+
166+
filename = self._get_new_save_filename(filename)
167+
if not filename:
168+
return None
169+
170+
except Exception as error:
171+
_show_critical(error)
172+
return None
173+
174+
finally:
175+
if self.atomic and osp.exists(tempname):
176+
try:
177+
os.remove(tempname)
178+
except Exception:
179+
pass
79180

80181
def save_file_as(self, filename: str, *args, **kwargs) -> str:
81182
"""
@@ -90,23 +191,8 @@ def save_file_as(self, filename: str, *args, **kwargs) -> str:
90191
-------
91192
filename : str
92193
The absolute path where the file was successfully saved. Returns
93-
'None' if the saving operation was cancelled or was unsuccessfull.
194+
'None' if the saving operation was cancelled or was unsuccessful.
94195
"""
95-
root, ext = osp.splitext(filename)
96-
if ext not in self.namefilters:
97-
ext = next(iter(self.namefilters))
98-
filename += ext
99-
100-
filename, filefilter = QFileDialog.getSaveFileName(
101-
self.parent,
102-
"Save As",
103-
filename,
104-
';;'.join(self.namefilters.values()),
105-
self.namefilters[ext])
196+
filename = self._get_new_save_filename(filename)
106197
if filename:
107-
# Make sur the filename has the right extension.
108-
ext = dict(map(reversed, self.namefilters.items()))[filefilter]
109-
if not filename.endswith(ext):
110-
filename += ext
111-
112198
return self.save_file(filename, *args, **kwargs)

0 commit comments

Comments
 (0)