Added missing docstrings and made pychecker happier.
[rox-lib.git] / python / rox / saving.py
blob02a78810586a28e9794f87e8e516f25aa21bb45c
1 """All ROX applications that can save documents should use drag-and-drop saving.
2 The document itself should use the Saveable mix-in class and override some of the
3 methods to actually do the save.
5 If you want to save a selection then you can create a new object specially for
6 the purpose and pass that to the SaveBox."""
8 import os, sys
9 import rox
10 from rox import alert, info, g, _, filer, escape
11 from rox import choices, get_local_path
13 gdk = g.gdk
15 TARGET_XDS = 0
16 TARGET_RAW = 1
18 def _write_xds_property(context, value):
19 win = context.source_window
20 if value:
21 win.property_change('XdndDirectSave0', 'text/plain', 8,
22 gdk.PROP_MODE_REPLACE,
23 value)
24 else:
25 win.property_delete('XdndDirectSave0')
27 def _read_xds_property(context, delete):
28 win = context.source_window
29 retval = win.property_get('XdndDirectSave0', 'text/plain', delete)
30 if retval:
31 return retval[2]
32 return None
34 def image_for_type(type):
35 'Search <Choices> for a suitable icon. Returns a pixbuf, or None.'
36 from icon_theme import rox_theme
37 media, subtype = type.split('/', 1)
38 path = choices.load('MIME-icons', media + '_' + subtype + '.png')
39 if not path:
40 icon = 'mime-%s:%s' % (media, subtype)
41 try:
42 path = rox_theme.lookup_icon(icon, 48)
43 if not path:
44 icon = 'mime-%s' % media
45 path = rox_theme.lookup_icon(icon, 48)
46 except:
47 print "Error loading MIME icon"
48 if not path:
49 path = choices.load('MIME-icons', media + '.png')
50 if path:
51 return gdk.pixbuf_new_from_file(path)
52 else:
53 return None
55 def _report_save_error():
56 "Report a SaveAbort nicely, otherwise use report_exception()"
57 value = sys.exc_info()[1]
58 if isinstance(value, AbortSave):
59 value.show()
60 else:
61 rox.report_exception()
63 class AbortSave(Exception):
64 """Raise this to cancel a save. If a message is given, it is displayed
65 in a normal alert box (not in the report_exception style). If the
66 message is None, no message is shown (you should have already shown
67 it!)"""
68 def __init__(self, message):
69 self.message = message
71 def show(self):
72 if self.message:
73 rox.alert(self.message)
75 class Saveable:
76 """This class describes the interface that an object must provide
77 to work with the SaveBox/SaveArea widgets. Inherit from it if you
78 want to save. All methods can be overridden, but normally only
79 save_to_stream() needs to be. You can also set save_last_stat to
80 the result of os.stat(filename) when loading a file to make ROX-Lib
81 restore permissions and warn about other programs editing the file."""
83 save_last_stat = None
85 def set_uri(self, uri):
86 """When the data is safely saved somewhere this is called
87 with its new name. Mark your data as unmodified and update
88 the filename for next time. Saving to another application
89 won't call this method. Default method does nothing."""
90 pass
92 def save_to_stream(self, stream):
93 """Write the data to save to the stream. When saving to a
94 local file, stream will be the actual file, otherwise it is a
95 cStringIO object."""
96 raise Exception('You forgot to write the save_to_stream() method...'
97 'silly programmer!')
99 def save_to_file(self, path):
100 """Write data to file. Raise an exception on error.
101 The default creates a temporary file, uses save_to_stream() to
102 write to it, then renames it over the original. If the temporary file
103 can't be created, it writes directly over the original."""
105 # Ensure the directory exists...
106 parent_dir = os.path.dirname(path)
107 if not os.path.isdir(parent_dir):
108 from rox import fileutils
109 try:
110 fileutils.makedirs(parent_dir)
111 except OSError:
112 raise AbortSave(None) # (message already shown)
114 import random
115 tmp = 'tmp-' + `random.randrange(1000000)`
116 tmp = os.path.join(parent_dir, tmp)
118 def open_private(path):
119 return os.fdopen(os.open(path, os.O_CREAT | os.O_WRONLY, 0600), 'wb')
121 try:
122 stream = open_private(tmp)
123 except:
124 # Can't create backup... try a direct write
125 tmp = None
126 stream = open_private(path)
127 try:
128 try:
129 self.save_to_stream(stream)
130 finally:
131 stream.close()
132 if tmp:
133 os.rename(tmp, path)
134 except:
135 _report_save_error()
136 if tmp and os.path.exists(tmp):
137 if os.path.getsize(tmp) == 0 or \
138 rox.confirm(_("Delete temporary file '%s'?") % tmp,
139 g.STOCK_DELETE):
140 os.unlink(tmp)
141 raise AbortSave(None)
142 self.save_set_permissions(path)
143 filer.examine(path)
145 def save_to_selection(self, selection_data):
146 """Write data to the selection. The default method uses save_to_stream()."""
147 from cStringIO import StringIO
148 stream = StringIO()
149 self.save_to_stream(stream)
150 selection_data.set(selection_data.target, 8, stream.getvalue())
152 save_mode = None # For backwards compat
153 def save_set_permissions(self, path):
154 """The default save_to_file() creates files with the mode 0600
155 (user read/write only). After saving has finished, it calls this
156 method to set the final permissions. The save_set_permissions():
157 - sets it to 0666 masked with the umask (if save_mode is None), or
158 - sets it to save_last_stat.st_mode (not masked) otherwise."""
159 if self.save_last_stat is not None:
160 save_mode = self.save_last_stat.st_mode
161 else:
162 save_mode = self.save_mode
164 if save_mode is not None:
165 os.chmod(path, save_mode)
166 else:
167 mask = os.umask(0077) # Get the current umask
168 os.umask(mask) # Set it back how it was
169 os.chmod(path, 0666 & ~mask)
171 def save_done(self):
172 """Time to close the savebox. Default method does nothing."""
173 pass
175 def discard(self):
176 """Discard button clicked, or document safely saved. Only called if a SaveBox
177 was created with discard=1.
178 The user doesn't want the document any more, even if it's modified and unsaved.
179 Delete it."""
180 raise Exception("Sorry... my programmer forgot to tell me how to handle Discard!")
182 save_to_stream._rox_default = 1
183 save_to_file._rox_default = 1
184 save_to_selection._rox_default = 1
185 def can_save_to_file(self):
186 """Indicates whether we have a working save_to_stream or save_to_file
187 method (ie, whether we can save to files). Default method checks that
188 one of these two methods has been overridden."""
189 if not hasattr(self.save_to_stream, '_rox_default'):
190 return 1 # Have user-provided save_to_stream
191 if not hasattr(self.save_to_file, '_rox_default'):
192 return 1 # Have user-provided save_to_file
193 return 0
194 def can_save_to_selection(self):
195 """Indicates whether we have a working save_to_stream or save_to_selection
196 method (ie, whether we can save to selections). Default methods checks that
197 one of these two methods has been overridden."""
198 if not hasattr(self.save_to_stream, '_rox_default'):
199 return 1 # Have user-provided save_to_stream
200 if not hasattr(self.save_to_selection, '_rox_default'):
201 return 1 # Have user-provided save_to_file
202 return 0
204 def save_cancelled(self):
205 """If you multitask during a save (using a recursive mainloop) then the
206 user may click on the Cancel button. This function gets called if so, and
207 should cause the recursive mainloop to return."""
208 raise Exception("Lazy programmer error: can't abort save!")
210 class SaveArea(g.VBox):
211 """A SaveArea contains the widgets used in a save box. You can use
212 this to put a savebox area in a larger window."""
214 document = None # The Saveable with the data
215 entry = None
216 initial_uri = None # The pathname supplied to the constructor
218 def __init__(self, document, uri, type):
219 """'document' must be a subclass of Saveable.
220 'uri' is the file's current location, or a simple name (eg 'TextFile')
221 if it has never been saved.
222 'type' is the MIME-type to use (eg 'text/plain').
224 g.VBox.__init__(self, False, 0)
226 self.document = document
227 self.initial_uri = uri
229 drag_area = self._create_drag_area(type)
230 self.pack_start(drag_area, True, True, 0)
231 drag_area.show_all()
233 entry = g.Entry()
234 entry.connect('activate', lambda w: self.save_to_file_in_entry())
235 self.entry = entry
236 self.pack_start(entry, False, True, 4)
237 entry.show()
239 entry.set_text(uri)
241 def _set_icon(self, type):
242 pixbuf = image_for_type(type)
243 if pixbuf:
244 self.icon.set_from_pixbuf(pixbuf)
245 else:
246 self.icon.set_from_stock(g.STOCK_MISSING_IMAGE, g.ICON_SIZE_DND)
248 def _create_drag_area(self, type):
249 align = g.Alignment()
250 align.set(.5, .5, 0, 0)
252 self.drag_box = g.EventBox()
253 self.drag_box.set_border_width(4)
254 self.drag_box.add_events(gdk.BUTTON_PRESS_MASK)
255 align.add(self.drag_box)
257 self.icon = g.Image()
258 self._set_icon(type)
260 self._set_drag_source(type)
261 self.drag_box.connect('drag_begin', self.drag_begin)
262 self.drag_box.connect('drag_end', self.drag_end)
263 self.drag_box.connect('drag_data_get', self.drag_data_get)
264 self.drag_in_progress = 0
266 self.drag_box.add(self.icon)
268 return align
270 def set_type(self, type, icon = None):
271 """Change the icon and drag target to 'type'.
272 If 'icon' is given (as a GtkImage) then that icon is used,
273 otherwise an appropriate icon for the type is used."""
274 if icon:
275 self.icon.set_from_pixbuf(icon.get_pixbuf())
276 else:
277 self._set_icon(type)
278 self._set_drag_source(type)
280 def _set_drag_source(self, type):
281 if self.document.can_save_to_file():
282 targets = [('XdndDirectSave0', 0, TARGET_XDS)]
283 else:
284 targets = []
285 if self.document.can_save_to_selection():
286 targets = targets + [(type, 0, TARGET_RAW),
287 ('application/octet-stream', 0, TARGET_RAW)]
289 if not targets:
290 raise Exception("Document %s can't save!" % self.document)
291 self.drag_box.drag_source_set(gdk.BUTTON1_MASK | gdk.BUTTON3_MASK,
292 targets,
293 gdk.ACTION_COPY | gdk.ACTION_MOVE)
295 def save_to_file_in_entry(self):
296 """Call this when the user clicks on an OK button you provide."""
297 uri = self.entry.get_text()
298 path = get_local_path(escape(uri))
300 if path:
301 if not self.confirm_new_path(path):
302 return
303 try:
304 self.set_sensitive(False)
305 try:
306 self.document.save_to_file(path)
307 finally:
308 self.set_sensitive(True)
309 self.set_uri(path)
310 self.save_done()
311 except:
312 _report_save_error()
313 else:
314 rox.info(_("Drag the icon to a directory viewer\n"
315 "(or enter a full pathname)"))
317 def drag_begin(self, drag_box, context):
318 self.drag_in_progress = 1
319 self.destroy_on_drag_end = 0
320 self.using_xds = 0
321 self.data_sent = 0
323 try:
324 pixbuf = self.icon.get_pixbuf()
325 if pixbuf:
326 drag_box.drag_source_set_icon_pixbuf(pixbuf)
327 except:
328 # This can happen if we set the broken image...
329 import traceback
330 traceback.print_exc()
332 uri = self.entry.get_text()
333 if uri:
334 i = uri.rfind('/')
335 if (i == -1):
336 leaf = uri
337 else:
338 leaf = uri[i + 1:]
339 else:
340 leaf = _('Unnamed')
341 _write_xds_property(context, leaf)
343 def drag_data_get(self, widget, context, selection_data, info, time):
344 if info == TARGET_RAW:
345 try:
346 self.set_sensitive(False)
347 try:
348 self.document.save_to_selection(selection_data)
349 finally:
350 self.set_sensitive(True)
351 except:
352 _report_save_error()
353 _write_xds_property(context, None)
354 return
356 self.data_sent = 1
357 _write_xds_property(context, None)
359 if self.drag_in_progress:
360 self.destroy_on_drag_end = 1
361 else:
362 self.save_done()
363 return
364 elif info != TARGET_XDS:
365 _write_xds_property(context, None)
366 alert("Bad target requested!")
367 return
369 # Using XDS:
371 # Get the path that the destination app wants us to save to.
372 # If it's local, save and return Success
373 # (or Error if save fails)
374 # If it's remote, return Failure (remote may try another method)
375 # If no URI is given, return Error
376 to_send = 'E'
377 uri = _read_xds_property(context, False)
378 if uri:
379 path = get_local_path(uri)
380 if path:
381 if not self.confirm_new_path(path):
382 to_send = 'E'
383 else:
384 try:
385 self.set_sensitive(False)
386 try:
387 self.document.save_to_file(path)
388 finally:
389 self.set_sensitive(True)
390 self.data_sent = True
391 except:
392 _report_save_error()
393 self.data_sent = False
394 if self.data_sent:
395 to_send = 'S'
396 # (else Error)
397 else:
398 to_send = 'F' # Non-local transfer
399 else:
400 alert("Remote application wants to use " +
401 "Direct Save, but I can't read the " +
402 "XdndDirectSave0 (type text/plain) " +
403 "property.")
405 selection_data.set(selection_data.target, 8, to_send)
407 if to_send != 'E':
408 _write_xds_property(context, None)
409 path = get_local_path(uri)
410 if path:
411 self.set_uri(path)
412 else:
413 self.set_uri(uri)
414 if self.data_sent:
415 self.save_done()
417 def confirm_new_path(self, path):
418 """User wants to save to this path. If it's different to the original path,
419 check that it doesn't exist and ask for confirmation if it does.
420 If document.save_last_stat is set, compare with os.stat for an existing file
421 and warn about changes.
422 Returns true to go ahead with the save."""
423 if not os.path.exists(path):
424 return True
425 if path == self.initial_uri:
426 if self.document.save_last_stat is None:
427 return True # OK. Nothing to compare with.
428 last = self.document.save_last_stat
429 stat = os.stat(path)
430 msg = []
431 if stat.st_mode != last.st_mode:
432 msg.append(_("Permissions changed from %o to %o.") % \
433 (last.st_mode, stat.st_mode))
434 if stat.st_size != last.st_size:
435 msg.append(_("Size was %d bytes; now %d bytes.") % \
436 (last.st_size, stat.st_size))
437 if stat.st_mtime != last.st_mtime:
438 msg.append(_("Modification time changed."))
439 if not msg:
440 return True # No change detected
441 return rox.confirm("File '%s' edited by another program since last load/save. "
442 "Really save (discarding other changes)?\n\n%s" %
443 (path, '\n'.join(msg)), g.STOCK_DELETE)
444 return rox.confirm(_("File '%s' already exists -- overwrite it?") % path,
445 g.STOCK_DELETE, _('_Overwrite'))
447 def set_uri(self, uri):
448 """Data is safely saved somewhere. Update the document's URI and save_last_stat (for local saves).
449 Internal."""
450 path = get_local_path(uri)
451 if path is not None:
452 self.document.save_last_stat = os.stat(path) # Record for next time
453 self.document.set_uri(uri)
455 def drag_end(self, widget, context):
456 self.drag_in_progress = 0
457 if self.destroy_on_drag_end:
458 self.save_done()
460 def save_done(self):
461 self.document.save_done()
463 class SaveBox(g.Dialog):
464 """A SaveBox is a GtkDialog that contains a SaveArea and, optionally, a Discard button.
465 Calls rox.toplevel_(un)ref automatically.
467 save_area = None
469 def __init__(self, document, uri, type = 'text/plain', discard = False):
470 """See SaveArea.__init__.
471 If discard is True then an extra discard button is added to the dialog."""
472 g.Dialog.__init__(self)
473 self.set_has_separator(False)
475 self.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
476 self.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
477 self.set_default_response(g.RESPONSE_OK)
479 if discard:
480 discard_area = g.HButtonBox()
482 def discard_clicked(event):
483 document.discard()
484 self.destroy()
485 button = rox.ButtonMixed(g.STOCK_DELETE, _('_Discard'))
486 discard_area.pack_start(button, False, True, 2)
487 button.connect('clicked', discard_clicked)
488 button.unset_flags(g.CAN_FOCUS)
489 button.set_flags(g.CAN_DEFAULT)
490 self.vbox.pack_end(discard_area, False, True, 0)
491 self.vbox.reorder_child(discard_area, 0)
493 discard_area.show_all()
495 self.set_title(_('Save As:'))
496 self.set_position(g.WIN_POS_MOUSE)
497 self.set_wmclass('savebox', 'Savebox')
498 self.set_border_width(1)
500 # Might as well make use of the new nested scopes ;-)
501 self.set_save_in_progress(0)
502 class BoxedArea(SaveArea):
503 def set_uri(area, uri):
504 SaveArea.set_uri(area, uri)
505 if discard:
506 document.discard()
507 def save_done(area):
508 document.save_done()
509 self.destroy()
511 def set_sensitive(area, sensitive):
512 if self.window:
513 # Might have been destroyed by now...
514 self.set_save_in_progress(not sensitive)
515 SaveArea.set_sensitive(area, sensitive)
516 save_area = BoxedArea(document, uri, type)
517 self.save_area = save_area
519 save_area.show_all()
520 self.build_main_area()
522 i = uri.rfind('/')
523 i = i + 1
524 # Have to do this here, or the selection gets messed up
525 save_area.entry.grab_focus()
526 g.Editable.select_region(save_area.entry, i, -1) # PyGtk bug
527 #save_area.entry.select_region(i, -1)
529 def got_response(widget, response):
530 if self.save_in_progress:
531 try:
532 document.save_cancelled()
533 except:
534 rox.report_exception()
535 return
536 if response == g.RESPONSE_CANCEL:
537 self.destroy()
538 elif response == g.RESPONSE_OK:
539 self.save_area.save_to_file_in_entry()
540 elif response == g.RESPONSE_DELETE_EVENT:
541 pass
542 else:
543 raise Exception('Unknown response!')
544 self.connect('response', got_response)
546 rox.toplevel_ref()
547 self.connect('destroy', lambda w: rox.toplevel_unref())
549 def set_type(self, type, icon = None):
550 """See SaveArea's method of the same name."""
551 self.save_area.set_type(type, icon)
553 def build_main_area(self):
554 """Place self.save_area somewhere in self.vbox. Override this
555 for more complicated layouts."""
556 self.vbox.add(self.save_area)
558 def set_save_in_progress(self, in_progress):
559 """Called when saving starts and ends. Shade/unshade any widgets as
560 required. Make sure you call the default method too!
561 Not called if box is destroyed from a recursive mainloop inside
562 a save_to_* function."""
563 self.set_response_sensitive(g.RESPONSE_OK, not in_progress)
564 self.save_in_progress = in_progress
566 class StringSaver(SaveBox, Saveable):
567 """A very simple SaveBox which saves the string passed to its constructor."""
568 def __init__(self, string, name):
569 """'string' is the string to save. 'name' is the default filename"""
570 SaveBox.__init__(self, self, name, 'text/plain')
571 self.string = string
573 def save_to_stream(self, stream):
574 stream.write(self.string)
576 class SaveFilter(Saveable):
577 """This Saveable runs a process in the background to generate the
578 save data. Any python streams can be used as the input to and
579 output from the process.
581 The output from the subprocess is saved to the output stream (either
582 directly, for fileno() streams, or via another temporary file).
584 If the process returns a non-zero exit status or writes to stderr,
585 the save fails (messages written to stderr are displayed).
588 command = None
589 stdin = None
591 def set_stdin(self, stream):
592 """Use 'stream' as stdin for the process. If stream is not a
593 seekable fileno() stream then it is copied to a temporary file
594 at this point. If None, the child process will get /dev/null on
595 stdin."""
596 if stream is not None:
597 if hasattr(stream, 'fileno') and hasattr(stream, 'seek'):
598 self.stdin = stream
599 else:
600 import tempfile
601 import shutil
602 self.stdin = tempfile.TemporaryFile()
603 shutil.copyfileobj(stream, self.stdin)
604 else:
605 self.stdin = None
607 def save_to_stream(self, stream):
608 from processes import PipeThroughCommand
610 assert not hasattr(self, 'child_run') # No longer supported
612 self.process = PipeThroughCommand(self.command, self.stdin, stream)
613 self.process.wait()
614 self.process = None
616 def save_cancelled(self):
617 """Send SIGTERM to the child processes."""
618 if self.process:
619 self.killed = 1
620 self.process.kill()