Make pychecker happier.
[rox-lib.git] / python / rox / saving.py
blob2bc93ccdefc49d7e97ab2dfa5536a11a476a7136
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 type, value = sys.exc_info()[:2]
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 dir = os.path.dirname(path)
107 if not os.path.isdir(dir):
108 from rox import fileutils
109 try:
110 fileutils.makedirs(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(dir, tmp)
118 def open(path):
119 return os.fdopen(os.open(path, os.O_CREAT | os.O_WRONLY, 0600), 'wb')
121 try:
122 file = open(tmp)
123 except:
124 # Can't create backup... try a direct write
125 tmp = None
126 file = open(path)
127 try:
128 try:
129 self.save_to_stream(file)
130 finally:
131 file.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."""
213 def __init__(self, document, uri, type):
214 """'document' must be a subclass of Saveable.
215 'uri' is the file's current location, or a simple name (eg 'TextFile')
216 if it has never been saved.
217 'type' is the MIME-type to use (eg 'text/plain').
219 g.VBox.__init__(self, False, 0)
221 self.document = document
222 self.initial_uri = uri
224 drag_area = self._create_drag_area(type)
225 self.pack_start(drag_area, True, True, 0)
226 drag_area.show_all()
228 entry = g.Entry()
229 entry.connect('activate', lambda w: self.save_to_file_in_entry())
230 self.entry = entry
231 self.pack_start(entry, False, True, 4)
232 entry.show()
234 entry.set_text(uri)
236 def _set_icon(self, type):
237 pixbuf = image_for_type(type)
238 if pixbuf:
239 self.icon.set_from_pixbuf(pixbuf)
240 else:
241 self.icon.set_from_stock(g.STOCK_MISSING_IMAGE, g.ICON_SIZE_DND)
243 def _create_drag_area(self, type):
244 align = g.Alignment()
245 align.set(.5, .5, 0, 0)
247 self.drag_box = g.EventBox()
248 self.drag_box.set_border_width(4)
249 self.drag_box.add_events(gdk.BUTTON_PRESS_MASK)
250 align.add(self.drag_box)
252 self.icon = g.Image()
253 self._set_icon(type)
255 self._set_drag_source(type)
256 self.drag_box.connect('drag_begin', self.drag_begin)
257 self.drag_box.connect('drag_end', self.drag_end)
258 self.drag_box.connect('drag_data_get', self.drag_data_get)
259 self.drag_in_progress = 0
261 self.drag_box.add(self.icon)
263 return align
265 def set_type(self, type, icon = None):
266 """Change the icon and drag target to 'type'.
267 If 'icon' is given (as a GtkImage) then that icon is used,
268 otherwise an appropriate icon for the type is used."""
269 if icon:
270 self.icon.set_from_pixbuf(icon.get_pixbuf())
271 else:
272 self._set_icon(type)
273 self._set_drag_source(type)
275 def _set_drag_source(self, type):
276 if self.document.can_save_to_file():
277 targets = [('XdndDirectSave0', 0, TARGET_XDS)]
278 else:
279 targets = []
280 if self.document.can_save_to_selection():
281 targets = targets + [(type, 0, TARGET_RAW),
282 ('application/octet-stream', 0, TARGET_RAW)]
284 if not targets:
285 raise Exception("Document %s can't save!" % self.document)
286 self.drag_box.drag_source_set(gdk.BUTTON1_MASK | gdk.BUTTON3_MASK,
287 targets,
288 gdk.ACTION_COPY | gdk.ACTION_MOVE)
290 def save_to_file_in_entry(self):
291 """Call this when the user clicks on an OK button you provide."""
292 uri = self.entry.get_text()
293 path = get_local_path(escape(uri))
295 if path:
296 if not self.confirm_new_path(path):
297 return
298 try:
299 self.set_sensitive(False)
300 try:
301 self.document.save_to_file(path)
302 finally:
303 self.set_sensitive(True)
304 self.set_uri(path)
305 self.save_done()
306 except:
307 _report_save_error()
308 else:
309 rox.info(_("Drag the icon to a directory viewer\n"
310 "(or enter a full pathname)"))
312 def drag_begin(self, drag_box, context):
313 self.drag_in_progress = 1
314 self.destroy_on_drag_end = 0
315 self.using_xds = 0
316 self.data_sent = 0
318 try:
319 pixbuf = self.icon.get_pixbuf()
320 if pixbuf:
321 drag_box.drag_source_set_icon_pixbuf(pixbuf)
322 except:
323 # This can happen if we set the broken image...
324 import traceback
325 traceback.print_exc()
327 uri = self.entry.get_text()
328 if uri:
329 i = uri.rfind('/')
330 if (i == -1):
331 leaf = uri
332 else:
333 leaf = uri[i + 1:]
334 else:
335 leaf = _('Unnamed')
336 _write_xds_property(context, leaf)
338 def drag_data_get(self, widget, context, selection_data, info, time):
339 if info == TARGET_RAW:
340 try:
341 self.set_sensitive(False)
342 try:
343 self.document.save_to_selection(selection_data)
344 finally:
345 self.set_sensitive(True)
346 except:
347 _report_save_error()
348 _write_xds_property(context, None)
349 return
351 self.data_sent = 1
352 _write_xds_property(context, None)
354 if self.drag_in_progress:
355 self.destroy_on_drag_end = 1
356 else:
357 self.save_done()
358 return
359 elif info != TARGET_XDS:
360 _write_xds_property(context, None)
361 alert("Bad target requested!")
362 return
364 # Using XDS:
366 # Get the path that the destination app wants us to save to.
367 # If it's local, save and return Success
368 # (or Error if save fails)
369 # If it's remote, return Failure (remote may try another method)
370 # If no URI is given, return Error
371 to_send = 'E'
372 uri = _read_xds_property(context, False)
373 if uri:
374 path = get_local_path(uri)
375 if path:
376 if not self.confirm_new_path(path):
377 to_send = 'E'
378 else:
379 try:
380 self.set_sensitive(False)
381 try:
382 self.document.save_to_file(path)
383 finally:
384 self.set_sensitive(True)
385 self.data_sent = True
386 except:
387 _report_save_error()
388 self.data_sent = False
389 if self.data_sent:
390 to_send = 'S'
391 # (else Error)
392 else:
393 to_send = 'F' # Non-local transfer
394 else:
395 alert("Remote application wants to use " +
396 "Direct Save, but I can't read the " +
397 "XdndDirectSave0 (type text/plain) " +
398 "property.")
400 selection_data.set(selection_data.target, 8, to_send)
402 if to_send != 'E':
403 _write_xds_property(context, None)
404 path = get_local_path(uri)
405 if path:
406 self.set_uri(path)
407 else:
408 self.set_uri(uri)
409 if self.data_sent:
410 self.save_done()
412 def confirm_new_path(self, path):
413 """User wants to save to this path. If it's different to the original path,
414 check that it doesn't exist and ask for confirmation if it does.
415 If document.save_last_stat is set, compare with os.stat for an existing file
416 and warn about changes.
417 Returns true to go ahead with the save."""
418 if not os.path.exists(path):
419 return True
420 if path == self.initial_uri:
421 if self.document.save_last_stat is None:
422 return True # OK. Nothing to compare with.
423 last = self.document.save_last_stat
424 stat = os.stat(path)
425 msg = []
426 if stat.st_mode != last.st_mode:
427 msg.append(_("Permissions changed from %o to %o.") % \
428 (last.st_mode, stat.st_mode))
429 if stat.st_size != last.st_size:
430 msg.append(_("Size was %d bytes; now %d bytes.") % \
431 (last.st_size, stat.st_size))
432 if stat.st_mtime != last.st_mtime:
433 msg.append(_("Modification time changed."))
434 if not msg:
435 return True # No change detected
436 return rox.confirm("File '%s' edited by another program since last load/save. "
437 "Really save (discarding other changes)?\n\n%s" %
438 (path, '\n'.join(msg)), g.STOCK_DELETE)
439 return rox.confirm(_("File '%s' already exists -- overwrite it?") % path,
440 g.STOCK_DELETE, _('_Overwrite'))
442 def set_uri(self, uri):
443 """Data is safely saved somewhere. Update the document's URI and save_last_stat (for local saves).
444 Internal."""
445 path = get_local_path(uri)
446 if path is not None:
447 self.document.save_last_stat = os.stat(path) # Record for next time
448 self.document.set_uri(uri)
450 def drag_end(self, widget, context):
451 self.drag_in_progress = 0
452 if self.destroy_on_drag_end:
453 self.save_done()
455 def save_done(self):
456 self.document.save_done()
458 class SaveBox(g.Dialog):
459 """A SaveBox is a GtkDialog that contains a SaveArea and, optionally, a Discard button.
460 Calls rox.toplevel_(un)ref automatically.
463 def __init__(self, document, uri, type = 'text/plain', discard = False):
464 """See SaveArea.__init__.
465 If discard is True then an extra discard button is added to the dialog."""
466 g.Dialog.__init__(self)
467 self.set_has_separator(False)
469 self.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
470 self.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
471 self.set_default_response(g.RESPONSE_OK)
473 if discard:
474 discard_area = g.HButtonBox()
476 def discard_clicked(event):
477 document.discard()
478 self.destroy()
479 button = rox.ButtonMixed(g.STOCK_DELETE, _('_Discard'))
480 discard_area.pack_start(button, False, True, 2)
481 button.connect('clicked', discard_clicked)
482 button.unset_flags(g.CAN_FOCUS)
483 button.set_flags(g.CAN_DEFAULT)
484 self.vbox.pack_end(discard_area, False, True, 0)
485 self.vbox.reorder_child(discard_area, 0)
487 discard_area.show_all()
489 self.set_title(_('Save As:'))
490 self.set_position(g.WIN_POS_MOUSE)
491 self.set_wmclass('savebox', 'Savebox')
492 self.set_border_width(1)
494 # Might as well make use of the new nested scopes ;-)
495 self.set_save_in_progress(0)
496 class BoxedArea(SaveArea):
497 def set_uri(area, uri):
498 SaveArea.set_uri(area, uri)
499 if discard:
500 document.discard()
501 def save_done(area):
502 document.save_done()
503 self.destroy()
505 def set_sensitive(area, sensitive):
506 if self.window:
507 # Might have been destroyed by now...
508 self.set_save_in_progress(not sensitive)
509 SaveArea.set_sensitive(area, sensitive)
510 save_area = BoxedArea(document, uri, type)
511 self.save_area = save_area
513 save_area.show_all()
514 self.build_main_area()
516 i = uri.rfind('/')
517 i = i + 1
518 # Have to do this here, or the selection gets messed up
519 save_area.entry.grab_focus()
520 g.Editable.select_region(save_area.entry, i, -1) # PyGtk bug
521 #save_area.entry.select_region(i, -1)
523 def got_response(widget, response):
524 if self.save_in_progress:
525 try:
526 document.save_cancelled()
527 except:
528 rox.report_exception()
529 return
530 if response == g.RESPONSE_CANCEL:
531 self.destroy()
532 elif response == g.RESPONSE_OK:
533 self.save_area.save_to_file_in_entry()
534 elif response == g.RESPONSE_DELETE_EVENT:
535 pass
536 else:
537 raise Exception('Unknown response!')
538 self.connect('response', got_response)
540 rox.toplevel_ref()
541 self.connect('destroy', lambda w: rox.toplevel_unref())
543 def set_type(self, type, icon = None):
544 """See SaveArea's method of the same name."""
545 self.save_area.set_type(type, icon)
547 def build_main_area(self):
548 """Place self.save_area somewhere in self.vbox. Override this
549 for more complicated layouts."""
550 self.vbox.add(self.save_area)
552 def set_save_in_progress(self, in_progress):
553 """Called when saving starts and ends. Shade/unshade any widgets as
554 required. Make sure you call the default method too!
555 Not called if box is destroyed from a recursive mainloop inside
556 a save_to_* function."""
557 self.set_response_sensitive(g.RESPONSE_OK, not in_progress)
558 self.save_in_progress = in_progress
560 class StringSaver(SaveBox, Saveable):
561 """A very simple SaveBox which saves the string passed to its constructor."""
562 def __init__(self, string, name):
563 """'string' is the string to save. 'name' is the default filename"""
564 SaveBox.__init__(self, self, name, 'text/plain')
565 self.string = string
567 def save_to_stream(self, stream):
568 stream.write(self.string)
570 class SaveFilter(Saveable):
571 """This Saveable runs a process in the background to generate the
572 save data. Any python streams can be used as the input to and
573 output from the process.
575 The output from the subprocess is saved to the output stream (either
576 directly, for fileno() streams, or via another temporary file).
578 If the process returns a non-zero exit status or writes to stderr,
579 the save fails (messages written to stderr are displayed).
582 stdin = None
584 def set_stdin(self, stream):
585 """Use 'stream' as stdin for the process. If stream is not a
586 seekable fileno() stream then it is copied to a temporary file
587 at this point. If None, the child process will get /dev/null on
588 stdin."""
589 if stream is not None:
590 if hasattr(stream, 'fileno') and hasattr(stream, 'seek'):
591 self.stdin = stream
592 else:
593 import tempfile
594 import shutil
595 self.stdin = tempfile.TemporaryFile()
596 shutil.copyfileobj(stream, self.stdin)
597 else:
598 self.stdin = None
600 def save_to_stream(self, stream):
601 from processes import PipeThroughCommand
603 assert not hasattr(self, 'child_run') # No longer supported
605 self.process = PipeThroughCommand(self.command, self.stdin, stream)
606 self.process.wait()
607 self.process = None
609 def save_cancelled(self):
610 """Send SIGTERM to the child processes."""
611 if self.process:
612 self.killed = 1
613 self.process.kill()