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 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425
|
import os.path
import sys
import re
from AnyQt.QtWidgets import QFileDialog, QGridLayout, QMessageBox
from Orange.widgets import gui, widget
from Orange.widgets.settings import Setting
_userhome = os.path.expanduser(f"~{os.sep}")
_IS_DARWIN = sys.platform == "darwin"
_IS_WIN32 = sys.platform == "win32"
class OWSaveBase(widget.OWWidget, openclass=True):
"""
Base class for Save widgets
A derived class must provide, at minimum:
- class `Inputs` and the corresponding handler that:
- saves the input to an attribute `data`, and
- calls `self.on_new_input`.
- a class attribute `filters` with a list of filters or a dictionary whose
keys are filters OR a class method `get_filters` that returns such a
list or dictionary
- method `do_save` that saves `self.data` into `self.filename`
Alternatively, instead of defining `do_save` a derived class can make
`filters` a dictionary whose keys are classes that define a method `write`
(like e.g. `TabReader`). Method `do_save` defined in the base class calls
the writer corresponding to the currently chosen filter.
A minimum example of derived class is
`Orange.widgets.model.owsavemodel.OWSaveModel`.
A more advanced widget that overrides a lot of base class behaviour is
`Orange.widgets.data.owsave.OWSave`.
"""
class Information(widget.OWWidget.Information):
empty_input = widget.Msg("Empty input; nothing was saved.")
class Warning(widget.OWWidget.Warning):
auto_save_disabled = widget.Msg(
"Auto save disabled.\n"
"Due to security reasons auto save is only restored for paths "
"that are in the same directory as the workflow file or in a "
"subtree of that directory."
)
class Error(widget.OWWidget.Error):
no_file_name = widget.Msg("File name is not set.")
unsupported_format = widget.Msg("File format is unsupported.\n{}")
general_error = widget.Msg("{}")
want_main_area = False
resizing_enabled = False
filter = Setting("") # Default is provided in __init__
# If the path is in the same directory as the workflow file or its
# subdirectory, it is stored as a relative path, otherwise as absolute.
# For the sake of security, we do not store relative paths from other
# directories, like home or cwd. After loading the widget from a schema,
# auto_save is set to off, unless the stored_path is relative (to the
# workflow).
stored_path = Setting("")
stored_name = Setting("", schema_only=True) # File name, without path
auto_save = Setting(False, schema_only=True)
filters = []
def __init__(self, start_row=0):
"""
Set up the gui.
The gui consists of a checkbox for auto save and two buttons put on a
grid layout. Derived widgets that want to place controls above the auto
save widget can set the `start_row` argument to the first free row,
and this constructor will start filling the grid there.
Args:
start_row (int): the row at which to start filling the gui
"""
super().__init__()
self.data = None
self.__show_auto_save_disabled = False
self._absolute_path = self._abs_path_from_setting()
# This cannot be done outside because `filters` is defined by subclass
if not self.filter:
self.filter = self.default_filter()
self.grid = grid = QGridLayout()
gui.widgetBox(self.controlArea, orientation=grid, box=True)
grid.addWidget(
gui.checkBox(
None, self, "auto_save", "Autosave when receiving new data",
callback=self._on_auto_save_toggled),
start_row, 0, 1, 2)
self.bt_save = gui.button(
self.buttonsArea, self,
label=f"Save as {self.stored_name}" if self.stored_name else "Save",
callback=self.save_file)
gui.button(self.buttonsArea, self, "Save as ...", callback=self.save_file_as)
self.adjustSize()
self.update_messages()
def default_filter(self):
"""Returns the first filter in the list"""
return next(iter(self.get_filters()))
@property
def last_dir(self):
# Not the best name, but kept for compatibility
return self._absolute_path
@last_dir.setter
def last_dir(self, absolute_path):
"""Store _absolute_path and update relative path (stored_path)"""
self._absolute_path = absolute_path
self.stored_path = absolute_path
workflow_dir = self.workflowEnv().get("basedir", None)
if workflow_dir:
try:
relative_path = os.path.relpath(absolute_path, start=workflow_dir)
except ValueError: # on Windows for paths on different drives
pass
else:
if not relative_path.startswith(".."):
self.stored_path = relative_path
def _abs_path_from_setting(self):
"""
Compute absolute path from `stored_path` from settings.
Absolute stored path is used only if it exists.
Auto save is disabled unless stored_path is relative.
"""
workflow_dir = self.workflowEnv().get("basedir")
if os.path.isabs(self.stored_path):
if os.path.exists(self.stored_path):
self._disable_auto_save_and_warn()
return self.stored_path
elif workflow_dir is not None:
return os.path.normpath(
os.path.join(workflow_dir, self.stored_path))
self.stored_path = workflow_dir or _userhome
self.auto_save = False
return self.stored_path
def _disable_auto_save_and_warn(self):
if self.auto_save:
self.__show_auto_save_disabled = True
self.auto_save = False
def _on_auto_save_toggled(self):
self.__show_auto_save_disabled = False
self.update_messages()
@property
def filename(self):
if self.stored_name:
return os.path.join(self._absolute_path, self.stored_name)
else:
return ""
@filename.setter
def filename(self, value):
self.last_dir, self.stored_name = os.path.split(value)
# pylint: disable=unused-argument
def workflowEnvChanged(self, key, value, oldvalue):
# Trigger refresh of relative path, e.g. when saving the scheme
if key == "basedir":
self.last_dir = self._absolute_path
@classmethod
def get_filters(cls):
return cls.filters
@property
def writer(self):
"""
Return the active writer or None if there is no writer for this filter
The base class uses this property only in `do_save` to find the writer
corresponding to the filter. Derived classes (e.g. OWSave) may also use
it elsewhere.
Filter may not exist if it comes from settings saved in Orange with
some add-ons that are not (or no longer) present, or if support for
some extension was dropped, like the old Excel format.
"""
filters = self.get_filters()
if self.filter not in filters:
return None
return filters[self.filter]
def on_new_input(self):
"""
This method must be called from input signal handler.
- It clears errors, warnings and information and calls
`self.update_messages` to set the as needed.
- It also calls `update_status` the can be overriden in derived
methods to set the status (e.g. the number of input rows)
- Calls `self.save_file` if `self.auto_save` is enabled and
`self.filename` is provided.
"""
self.Error.clear()
self.Warning.clear()
self.Information.clear()
self.update_messages()
self.update_status()
if self.auto_save and self.filename:
self.save_file()
def save_file_as(self):
"""
Ask the user for the filename and try saving the file
"""
filename, selected_filter = self.get_save_filename()
if not filename:
return
self.filename = filename
self.filter = selected_filter
self.Error.unsupported_format.clear()
self.bt_save.setText(f"Save as {self.stored_name}")
self.update_messages()
self._try_save()
def save_file(self):
"""
If file name is provided, try saving, else call save_file_as
"""
if not self.filename:
self.save_file_as()
else:
self._try_save()
def _try_save(self):
"""
Private method that calls do_save within try-except that catches and
shows IOError. Do nothing if not data or no file name.
"""
self.Error.general_error.clear()
if self.data is None or not self.filename:
return
try:
self.do_save()
except IOError as err_value:
self.Error.general_error(str(err_value))
def do_save(self):
"""
Do the saving.
Default implementation calls the write method of the writer
corresponding to the current filter. This requires that get_filters()
returns is a dictionary whose keys are classes.
Derived classes may simplify this by providing a list of filters and
override do_save. This is particularly handy if the widget supports only
a single format.
"""
# This method is separated out because it will usually be overriden
if self.writer is None:
self.Error.unsupported_format(self.filter)
return
self.writer.write(self.filename, self.data)
def update_messages(self):
"""
Update errors, warnings and information.
Default method sets no_file_name if auto_save is enabled but file name
is not provided; and empty_input if file name is given but there is no
data.
Derived classes that define further messages will typically set them in
this method.
"""
self.Error.no_file_name(shown=not self.filename and self.auto_save)
self.Information.empty_input(shown=self.filename and self.data is None)
self.Warning.auto_save_disabled(shown=self.__show_auto_save_disabled)
def update_status(self):
"""
Update the input/output indicator. Default method does nothing.
"""
def initial_start_dir(self):
"""
Provide initial start directory
Return either the current file's path, the last directory or home.
"""
if self.filename and os.path.exists(os.path.split(self.filename)[0]):
return os.path.splitext(self.filename)[0]
else:
return self.last_dir or _userhome
@staticmethod
def suggested_name():
"""
Suggest the name for the output file or return an empty string.
"""
return ""
@classmethod
def _replace_extension(cls, filename, extension):
"""
Remove all extensions that appear in any filter.
Double extensions are broken in different weird ways across all systems,
including omitting some, like turning iris.tab.gz to iris.gz. This
function removes anything that can appear anywhere.
"""
known_extensions = set()
for filt in cls.get_filters():
known_extensions |= set(cls._extension_from_filter(filt).split("."))
if "" in known_extensions:
known_extensions.remove("")
while True:
base, ext = os.path.splitext(filename)
if ext[1:] not in known_extensions:
break
filename = base
return filename + extension
@staticmethod
def _extension_from_filter(selected_filter):
return re.search(r".*\(\*?(\..*)\)$", selected_filter).group(1)
def valid_filters(self):
return self.get_filters()
def default_valid_filter(self):
return self.filter
@classmethod
def migrate_settings(cls, settings, version):
# We cannot use versions because they are overriden in derived classes
if "last_dir" in settings:
settings["stored_path"] = settings.pop("last_dir")
if "filename" in settings:
settings["stored_name"] = os.path.split(
settings.pop("filename") or "")[1]
# As of Qt 5.9, QFileDialog.setDefaultSuffix does not support double
# suffixes, not even in non-native dialogs. We handle each OS separately.
if _IS_DARWIN or _IS_WIN32:
def get_save_filename(self): # pragma: no cover
filename = self.initial_start_dir()
while True:
dlg = QFileDialog(
None, "Save File", filename, ";;".join(self.valid_filters()))
dlg.setAcceptMode(dlg.AcceptSave)
dlg.selectNameFilter(self.default_valid_filter())
# MacOs (currently) ignores DontConfirmOverwrite
# Let us not set it, so we know it's not set in the future
if _IS_WIN32:
dlg.setOption(QFileDialog.DontConfirmOverwrite)
if dlg.exec() == QFileDialog.Rejected:
return "", ""
filename = dlg.selectedFiles()[0]
selected_filter = dlg.selectedNameFilter()
filename = self._replace_extension(
filename, self._extension_from_filter(selected_filter))
if (not os.path.exists(filename)
or _IS_DARWIN # MacOs already asked for confirmation
or QMessageBox.question(
self, "Overwrite file?",
f"File {os.path.split(filename)[1]} already exists.\n"
"Overwrite?") == QMessageBox.Yes):
return filename, selected_filter
else: # Linux and any unknown platforms
# Qt does not use a native dialog on Linux, so we can connect to
# filterSelected and to overload selectFile to change the extension
# while the dialog is open.
# For unknown platforms (which?), we also use the non-native dialog to
# be sure we know what happens.
class SaveFileDialog(QFileDialog):
# pylint: disable=protected-access
def __init__(self, save_cls, *args, **kwargs):
super().__init__(*args, **kwargs)
self.save_cls = save_cls
self.suffix = ""
self.setAcceptMode(QFileDialog.AcceptSave)
self.setOption(QFileDialog.DontUseNativeDialog)
self.filterSelected.connect(self.updateDefaultExtension)
def selectNameFilter(self, selected_filter):
super().selectNameFilter(selected_filter)
self.updateDefaultExtension(selected_filter)
def updateDefaultExtension(self, selected_filter):
self.suffix = \
self.save_cls._extension_from_filter(selected_filter)
files = self.selectedFiles()
if files and not os.path.isdir(files[0]):
self.selectFile(files[0])
def selectFile(self, filename):
filename = \
self.save_cls._replace_extension(filename, self.suffix)
super().selectFile(filename)
def get_save_filename(self):
dlg = self.SaveFileDialog(
type(self),
None, "Save File", self.initial_start_dir(),
";;".join(self.valid_filters()))
dlg.selectNameFilter(self.default_valid_filter())
if dlg.exec() == QFileDialog.Rejected:
return "", ""
else:
return dlg.selectedFiles()[0], dlg.selectedNameFilter()
|