11import os .path
2+ import sys
3+ import re
24
3- from AnyQt .QtWidgets import QFileDialog , QGridLayout , QWidget
5+ from AnyQt .QtWidgets import QFileDialog , QGridLayout , QMessageBox
46
57from Orange .data .table import Table
68from Orange .data .io import TabReader , CSVReader , PickleReader , ExcelReader
911from Orange .widgets .utils .widgetpreview import WidgetPreview
1012from Orange .widgets .widget import Input
1113
12- class FileDialog (QFileDialog ):
13- def changeEvent (self , e ):
14- print (e )
15- super ().selectFile (e )
1614
1715class OWSave (widget .OWWidget ):
1816 name = "Save Data"
@@ -24,170 +22,122 @@ class OWSave(widget.OWWidget):
2422 settings_version = 2
2523
2624 writers = [TabReader , CSVReader , PickleReader , ExcelReader ]
27- filters = [f"{ w .DESCRIPTION } (*.*)" for w in writers ]
28- filt_ext = {filter : w .EXTENSIONS [0 ] for filter , w in zip (filters , writers )}
25+ filters = {
26+ ** {f"{ w .DESCRIPTION } (*{ w .EXTENSIONS [0 ]} )" : w
27+ for w in writers },
28+ ** {f"Compressed { w .DESCRIPTION } (*{ w .EXTENSIONS [0 ]} .gz)" : w
29+ for w in writers }
30+ }
2931 userhome = os .path .expanduser (f"~{ os .sep } " )
3032
3133 class Inputs :
3234 data = Input ("Data" , Table )
3335
3436 class Error (widget .OWWidget .Error ):
35- unsupported_sparse = widget .Msg ("Use .pkl format for sparse data." )
37+ unsupported_sparse = widget .Msg ("Use Pickle format for sparse data." )
3638 no_file_name = widget .Msg ("File name is not set." )
3739 general_error = widget .Msg ("{}" )
3840
3941 class Warning (widget .OWWidget .Warning ):
40- ignored_flag = widget .Msg ("{} ignored for this format." )
42+ type_annotation_ignored = widget .Msg (
43+ "Type annotation setting is ignored for this format." )
4144
4245 want_main_area = False
4346 resizing_enabled = False
4447
45- compress : bool
46- add_type_annotations : bool
47-
4848 last_dir = Setting ("" )
49- filter = Setting (filters [ 0 ] )
50- compress = Setting (False )
49+ filter = Setting (next ( iter ( filters )) )
50+ filename = Setting ("" , schema_only = True )
5151 add_type_annotations = Setting (True )
5252 auto_save = Setting (False )
5353
5454 def __init__ (self ):
5555 super ().__init__ ()
5656 self .data = None
57- self .filename = ""
58- self .writer = self .writers [0 ]
5957
6058 grid = QGridLayout ()
61- gui .widgetBox (self .controlArea , box = True , orientation = grid )
62- grid .setSpacing (8 )
63- self .bt_save = gui .button (None , self , "Save" , callback = self .save_file )
64- grid .addWidget (self .bt_save , 0 , 0 )
65- grid .addWidget (
66- gui .button (None , self , "Save as ..." , callback = self .save_file_as ),
67- 0 , 1 )
68- grid .addWidget (
69- gui .checkBox (None , self , "auto_save" ,
70- "Autosave when receiving new data" ,
71- callback = self ._update_controls ),
72- 1 , 0 , 1 , 2 )
73- grid .addWidget (QWidget (), 2 , 0 , 1 , 2 )
74-
59+ gui .widgetBox (self .controlArea , orientation = grid )
7560 grid .addWidget (
7661 gui .checkBox (
7762 None , self , "add_type_annotations" ,
78- "Save with type annotations" , callback = self ._update_controls ),
79- 3 , 0 , 1 , 2 )
63+ "Save with type annotations" , callback = self ._update_messages ),
64+ 0 , 0 , 1 , 2 )
65+ grid .setRowMinimumHeight (1 , 8 )
8066 grid .addWidget (
8167 gui .checkBox (
82- None , self , "compress" , "Compress file (gzip)" ,
83- callback = self ._update_controls ),
84- 4 , 0 , 1 , 2 )
68+ None , self , "auto_save" , "Autosave when receiving new data" ,
69+ callback = self ._update_messages ),
70+ 2 , 0 , 1 , 2 )
71+ grid .setRowMinimumHeight (3 , 8 )
72+ self .bt_save = gui .button (None , self , "Save" , callback = self .save_file )
73+ grid .addWidget (self .bt_save , 4 , 0 )
74+ grid .addWidget (
75+ gui .button (None , self , "Save as ..." , callback = self .save_file_as ),
76+ 4 , 1 )
8577
8678 self .adjustSize ()
87- self ._update_controls ()
79+ self ._update_messages ()
80+
81+ @property
82+ def writer (self ):
83+ return self .filters [self .filter ]
8884
8985 @Inputs .data
9086 def dataset (self , data ):
9187 self .Error .clear ()
9288 self .data = data
93-
94- self ._update_controls ()
95- if self .data is None :
96- self .info .set_input_summary (self .info .NoInput )
97- else :
98- self .info .set_input_summary (
99- str (len (self .data )),
100- f"Data set { self .data .name or '(no name)' } "
101- f"with { len (self .data )} instances" )
102-
89+ self ._update_status ()
90+ self ._update_messages ()
10391 if self .auto_save and self .filename :
10492 self .save_file ()
10593
106- def save_file_as (self ):
107- if self .filename :
108- start_dir = self .filename
109- else :
110- data_name = getattr (self .data , 'name' , '' )
111- if data_name :
112- data_name += self .filt_ext [self .filter ]
113- start_dir = os .path .join (self .last_dir or self .userhome , data_name )
114-
115- dlg = FileDialog (None , "Set File" , start_dir , ";;" .join (self .filters ))
116- dlg .setLabelText (dlg .Accept , "Select" )
117- dlg .setAcceptMode (dlg .AcceptSave )
118- dlg .setSupportedSchemes (["file" ])
119- dlg .selectNameFilter (self .filter )
120- dlg .setOption (QFileDialog .HideNameFilterDetails )
121- dlg .currentChanged .connect (print )
122- if dlg .exec () == dlg .Rejected :
123- return
124-
125- filename = dlg .selectedFiles ()[0 ]
126- selected_filter = dlg .selectedNameFilter ()
127-
128- # filename, selected_filter = QFileDialog.getSaveFileName(
129- # self, "Save data", start_dir, ";;".join(self.filters), self.filter,
130- # QFileDialog.HideNameFilterDetails)
131- if not filename :
132- return
133-
134- self .filename = filename
135- self .last_dir = os .path .split (filename )[0 ]
136- self .filter = selected_filter
137- self .writer = self .writers [self .filters .index (self .filter )]
138- self ._update_controls ()
139- self .save_file ()
140-
14194 def save_file (self ):
14295 if not self .filename :
14396 self .save_file_as ()
14497 return
98+
14599 self .Error .general_error .clear ()
146- if not self ._can_save ():
100+ if self .data is None \
101+ or not self .filename \
102+ or (self .data .is_sparse ()
103+ and not self .writer .SUPPORT_SPARSE_DATA ):
147104 return
148105 try :
149106 self .writer .write (
150- self ._fullname () , self .data , self .add_type_annotations )
107+ self .filename , self .data , self .add_type_annotations )
151108 except IOError as err_value :
152109 self .Error .general_error (str (err_value ))
153110
154- def _fullname (self ):
155- return self .filename \
156- + ".gz" * self .writer .SUPPORT_COMPRESSED * self .compress
157-
158- def _update_controls (self ):
159- if self .filename :
160- self .bt_save .setText (
161- f"Save as { os .path .split (self ._fullname ())[1 ]} " )
162- else :
163- self .bt_save .setText ("Save" )
164- self .Error .no_file_name (shown = not self .filename and self .auto_save )
111+ def save_file_as (self ):
112+ filename , selected_filter = self .get_save_filename ()
113+ if not filename :
114+ return
115+ self .filename = filename
116+ self .filter = selected_filter
117+ self .last_dir = os .path .split (self .filename )[0 ]
118+ self .bt_save .setText (f"Save as { os .path .split (filename )[1 ]} " )
119+ self ._update_messages ()
120+ self .save_file ()
165121
122+ def _update_messages (self ):
123+ self .Error .no_file_name (
124+ shown = not self .filename and self .auto_save )
166125 self .Error .unsupported_sparse (
167126 shown = self .data is not None and self .data .is_sparse ()
168127 and self .filename and not self .writer .SUPPORT_SPARSE_DATA )
128+ self .Warning .type_annotation_ignored (
129+ shown = self .data is not None and self .filename
130+ and self .add_type_annotations
131+ and not self .writer .OPTIONAL_TYPE_ANNOTATIONS )
169132
170- if self .data is None or not self .filename :
171- self .Warning .ignored_flag .clear ()
133+ def _update_status (self ):
134+ if self .data is None :
135+ self .info .set_input_summary (self .info .NoInput )
172136 else :
173- no_compress = self .compress \
174- and not self .writer .SUPPORT_COMPRESSED
175- no_anotation = self .add_type_annotations \
176- and not self .writer .OPTIONAL_TYPE_ANNOTATIONS
177- ignored = [
178- "" ,
179- "Compression flag is" ,
180- "Type annotation flag is" ,
181- "Compression and type annotation flags are"
182- ][no_compress + 2 * no_anotation ]
183- self .Warning .ignored_flag (ignored , shown = bool (ignored ))
184-
185- def _can_save (self ):
186- return not (
187- self .data is None
188- or not self .filename
189- or self .data .is_sparse () and not self .writer .SUPPORT_SPARSE_DATA
190- )
137+ self .info .set_input_summary (
138+ str (len (self .data )),
139+ f"Data set { self .data .name or '(no name)' } "
140+ f"with { len (self .data )} instances" )
191141
192142 def send_report (self ):
193143 self .report_data_brief (self .data )
@@ -196,17 +146,126 @@ def send_report(self):
196146 self .report_items ((
197147 ("File name" , self .filename or "not set" ),
198148 ("Format" , writer .DESCRIPTION ),
199- ("Compression" , writer .SUPPORT_COMPRESSED and noyes [self .compress ]),
200149 ("Type annotations" ,
201150 writer .OPTIONAL_TYPE_ANNOTATIONS
202151 and noyes [self .add_type_annotations ])
203152 ))
204153
205154 @classmethod
206155 def migrate_settings (cls , settings , version = 0 ):
207- settings .filter = next (iter (cls .filt_ext ))
208- # if version < 2:
209- # settings["filter"] = settings.pop("filetype")
156+ if version < 2 :
157+ prev_filter = settings .pop ("filetype" , None )
158+ if prev_filter is None :
159+ settings ["filter" ] = next (iter (cls .filters ))
160+ else :
161+ prev_ext = cls ._extension_from_filter (prev_filter )
162+ prev_filter = prev_filter .split ("(" )[0 ]
163+ if settings .pop ("compress" , False ):
164+ prev_filter = f"Compressed { prev_filter } (*{ prev_ext } .gz)"
165+ else :
166+ prev_filter = f"{ prev_filter } (*{ prev_ext } )"
167+ settings ["filter" ] = prev_filter
168+
169+ def _initial_start_dir (self ):
170+ if self .filename and os .path .exists (os .path .split (self .filename )[0 ]):
171+ return self .filename
172+ else :
173+ data_name = getattr (self .data , 'name' , '' )
174+ if data_name :
175+ data_name += self .writer .EXTENSIONS [0 ]
176+ return os .path .join (self .last_dir or self .userhome , data_name )
177+
178+ @staticmethod
179+ def _replace_extension (filename , extension ):
180+ if filename .endswith (extension ): # it may contain dots before extension
181+ return filename
182+ last_fn = None
183+ while last_fn != filename :
184+ last_fn , filename = filename , os .path .splitext (filename )[0 ]
185+ return filename + extension
186+
187+ @staticmethod
188+ def _extension_from_filter (selected_filter ):
189+ return re .search (r".*\(\*?(\..*)\)$" , selected_filter ).group (1 )
190+
191+ # As of Qt 5.9, QFileDialog.setDefaultSuffix does not support double
192+ # suffixes, not even in non-native dialogs. We handle each OS separately.
193+ if sys .platform == "darwin" :
194+ # On macOS, is double suffixes are passed to the dialog, they are
195+ # appended multiple times even if already present (QTBUG-44227).
196+ # The only known workaround with native dialog is to use suffix *.*.
197+ # We add the correct suffix after closing the dialog and only then check
198+ # if the file exists and ask whether to override.
199+ # It is a bit confusing that the user does not see the final name in the
200+ # dialog, but I see no better solution.
201+ def get_save_filename (self ):
202+ def no_suffix (filt ):
203+ return filt .split ("(" )[0 ] + "(*.*)"
204+
205+ mac_filters = {no_suffix (f ): f for f in self .filters }
206+ filename = self ._initial_start_dir ()
207+ while True :
208+ dlg = QFileDialog (
209+ None , "Save File" , filename , ";;" .join (mac_filters ))
210+ dlg .setAcceptMode (dlg .AcceptSave )
211+ dlg .selectNameFilter (no_suffix (self .filter ))
212+ dlg .setOption (QFileDialog .HideNameFilterDetails )
213+ dlg .setOption (QFileDialog .DontConfirmOverwrite )
214+ if dlg .exec () == dlg .Rejected :
215+ return "" , ""
216+ filename = dlg .selectedFiles ()[0 ]
217+ selected_filter = mac_filters [dlg .selectedNameFilter ()]
218+ filename = self ._replace_extension (
219+ filename , self ._extension_from_filter (selected_filter ))
220+ if not os .path .exists (filename ) or QMessageBox .question (
221+ self , "Overwrite file?" ,
222+ f"File { os .path .split (filename )[1 ]} already exists.\n "
223+ "Overwrite?" ) == QMessageBox .Yes :
224+ return filename , selected_filter
225+
226+ elif sys .platform == "win32" :
227+ # TODO: This is not tested!!!
228+ # Windows native dialog may work correctly; if not, we may do the same
229+ # as for macOS?
230+ def get_save_filename (self ):
231+ return QFileDialog .getSaveFileName (
232+ self , "Save File" , self ._initial_start_dir (),
233+ ";;" .join (self .filters ), self .filter )
234+
235+ else : # Linux and any unknown platforms
236+ # Qt does not use a native dialog on Linux, so we can connect to
237+ # filterSelected and to overload selectFile to change the extension
238+ # while the dialog is open.
239+ # For unknown platforms (which?), we also use the non-native dialog to
240+ # be sure we know what happens.
241+ class SaveFileDialog (QFileDialog ):
242+ # pylint: disable=protected-access
243+ def __init__ (self , * args , ** kwargs ):
244+ super ().__init__ (* args , ** kwargs )
245+ self .setAcceptMode (QFileDialog .AcceptSave )
246+ self .setOption (QFileDialog .DontUseNativeDialog )
247+ self .filterSelected .connect (self .updateDefaultExtension )
248+
249+ def updateDefaultExtension (self , selected_filter ):
250+ self .suffix = OWSave ._extension_from_filter (selected_filter )
251+ files = self .selectedFiles ()
252+ if files and not os .path .isdir (files [0 ]):
253+ self .selectFile (files [0 ].split ("." )[0 ])
254+
255+ def selectFile (self , filename ):
256+ filename = OWSave ._replace_extension (filename , self .suffix )
257+ super ().selectFile (filename )
258+
259+ def get_save_filename (self ):
260+ dlg = self .SaveFileDialog (
261+ None , "Save File" , self ._initial_start_dir (),
262+ ";;" .join (self .filters ))
263+ dlg .selectNameFilter (self .filter )
264+ dlg .updateDefaultExtension (self .filter )
265+ if dlg .exec () == QFileDialog .Rejected :
266+ return "" , ""
267+ else :
268+ return dlg .selectedFiles ()[0 ], dlg .selectedNameFilter ()
210269
211270
212271if __name__ == "__main__" : # pragma: no cover
0 commit comments