1111from typing import Callable
1212
1313# ---- Standard imports
14+ import os
1415import os .path as osp
16+ import uuid
1517
1618# ---- Third party imports
1719from qtpy .QtCore import QObject
2022
2123class 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