Better help for processes.
[rox-lib.git] / python / rox / saving.py
blobc983f8d48e561a7806a3f0ee1f0ea1e568770909
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
11 from rox import choices, get_local_path, TRUE, FALSE
12 from icon_theme import rox_theme
14 gdk = g.gdk
16 TARGET_XDS = 0
17 TARGET_RAW = 1
19 def _write_xds_property(context, value):
20 win = context.source_window
21 if value:
22 win.property_change('XdndDirectSave0', 'text/plain', 8,
23 gdk.PROP_MODE_REPLACE,
24 value)
25 else:
26 win.property_delete('XdndDirectSave0')
28 def _read_xds_property(context, delete):
29 win = context.source_window
30 retval = win.property_get('XdndDirectSave0', 'text/plain', delete)
31 if retval:
32 return retval[2]
33 return None
35 def image_for_type(type):
36 'Search <Choices> for a suitable icon. Returns a pixbuf, or None.'
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."""
81 def set_uri(self, uri):
82 """When the data is safely saved somewhere this is called
83 with its new name. Mark your data as unmodified and update
84 the filename for next time. Saving to another application
85 won't call this method. Default method does nothing."""
86 pass
88 def save_to_stream(self, stream):
89 """Write the data to save to the stream. When saving to a
90 local file, stream will be the actual file, otherwise it is a
91 cStringIO object."""
92 raise Exception('You forgot to write the save_to_stream() method...'
93 'silly programmer!')
95 def save_to_file(self, path):
96 """Write data to file. Raise an exception on error.
97 The default creates a temporary file, uses save_to_stream() to
98 write to it, then renames it over the original. If the temporary file
99 can't be created, it writes directly over the original."""
101 # Ensure the directory exists...
102 dir = os.path.dirname(path)
103 if not os.path.isdir(dir):
104 from rox import fileutils
105 try:
106 fileutils.makedirs(dir)
107 except OSError:
108 raise AbortSave(None) # (message already shown)
110 import random
111 tmp = 'tmp-' + `random.randrange(1000000)`
112 tmp = os.path.join(dir, tmp)
114 def open(path):
115 return os.fdopen(os.open(path, os.O_CREAT | os.O_WRONLY, 0600), 'wb')
117 try:
118 file = open(tmp)
119 except:
120 # Can't create backup... try a direct write
121 tmp = None
122 file = open(path)
123 try:
124 try:
125 self.save_to_stream(file)
126 finally:
127 file.close()
128 if tmp:
129 os.rename(tmp, path)
130 except:
131 _report_save_error()
132 if tmp and os.path.exists(tmp):
133 if os.path.getsize(tmp) == 0 or \
134 rox.confirm(_("Delete temporary file '%s'?") % tmp,
135 g.STOCK_DELETE):
136 os.unlink(tmp)
137 raise AbortSave(None)
138 self.save_set_permissions(path)
139 filer.examine(path)
141 def save_to_selection(self, selection_data):
142 """Write data to the selection. The default method uses save_to_stream()."""
143 from cStringIO import StringIO
144 stream = StringIO()
145 self.save_to_stream(stream)
146 selection_data.set(selection_data.target, 8, stream.getvalue())
148 save_mode = None
149 def save_set_permissions(self, path):
150 """The default save_to_file() creates files with the mode 0600
151 (user read/write only). After saving has finished, it calls this
152 method to set the final permissions. The save_set_permissions():
153 - sets it to 0666 masked with the umask (if save_mode is None), or
154 - sets it to save_mode (not masked) otherwise."""
155 if self.save_mode is not None:
156 os.chmod(path, self.save_mode)
157 else:
158 mask = os.umask(0077) # Get the current umask
159 os.umask(mask) # Set it back how it was
160 os.chmod(path, 0666 & ~mask)
162 def save_done(self):
163 """Time to close the savebox. Default method does nothing."""
164 pass
166 def discard(self):
167 """Discard button clicked, or document safely saved. Only called if a SaveBox
168 was created with discard=1.
169 The user doesn't want the document any more, even if it's modified and unsaved.
170 Delete it."""
171 raise Exception("Sorry... my programmer forgot to tell me how to handle Discard!")
173 save_to_stream._rox_default = 1
174 save_to_file._rox_default = 1
175 save_to_selection._rox_default = 1
176 def can_save_to_file(self):
177 """Indicates whether we have a working save_to_stream or save_to_file
178 method (ie, whether we can save to files). Default method checks that
179 one of these two methods has been overridden."""
180 if not hasattr(self.save_to_stream, '_rox_default'):
181 return 1 # Have user-provided save_to_stream
182 if not hasattr(self.save_to_file, '_rox_default'):
183 return 1 # Have user-provided save_to_file
184 return 0
185 def can_save_to_selection(self):
186 """Indicates whether we have a working save_to_stream or save_to_selection
187 method (ie, whether we can save to selections). Default methods 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_selection, '_rox_default'):
192 return 1 # Have user-provided save_to_file
193 return 0
195 def save_cancelled(self):
196 """If you multitask during a save (using a recursive mainloop) then the
197 user may click on the Cancel button. This function gets called if so, and
198 should cause the recursive mainloop to return."""
199 raise Exception("Lazy programmer error: can't abort save!")
201 class SaveArea(g.VBox):
202 """A SaveArea contains the widgets used in a save box. You can use
203 this to put a savebox area in a larger window."""
204 def __init__(self, document, uri, type):
205 """'document' must be a subclass of Saveable.
206 'uri' is the file's current location, or a simple name (eg 'TextFile')
207 if it has never been saved.
208 'type' is the MIME-type to use (eg 'text/plain').
210 g.VBox.__init__(self, FALSE, 0)
212 self.document = document
213 self.initial_uri = uri
215 drag_area = self._create_drag_area(type)
216 self.pack_start(drag_area, TRUE, TRUE, 0)
217 drag_area.show_all()
219 entry = g.Entry()
220 entry.connect('activate', lambda w: self.save_to_file_in_entry())
221 self.entry = entry
222 self.pack_start(entry, FALSE, TRUE, 4)
223 entry.show()
225 entry.set_text(uri)
227 def _set_icon(self, type):
228 pixbuf = image_for_type(type)
229 if pixbuf:
230 self.icon.set_from_pixbuf(pixbuf)
231 else:
232 self.icon.set_from_stock(g.STOCK_MISSING_IMAGE, g.ICON_SIZE_DND)
234 def _create_drag_area(self, type):
235 align = g.Alignment()
236 align.set(.5, .5, 0, 0)
238 self.drag_box = g.EventBox()
239 self.drag_box.set_border_width(4)
240 self.drag_box.add_events(gdk.BUTTON_PRESS_MASK)
241 align.add(self.drag_box)
243 self.icon = g.Image()
244 self._set_icon(type)
246 self._set_drag_source(type)
247 self.drag_box.connect('drag_begin', self.drag_begin)
248 self.drag_box.connect('drag_end', self.drag_end)
249 self.drag_box.connect('drag_data_get', self.drag_data_get)
250 self.drag_in_progress = 0
252 self.drag_box.add(self.icon)
254 return align
256 def set_type(self, type, icon = None):
257 """Change the icon and drag target to 'type'.
258 If 'icon' is given (as a GtkImage) then that icon is used,
259 otherwise an appropriate icon for the type is used."""
260 if icon:
261 self.icon.set_from_pixbuf(icon.get_pixbuf())
262 else:
263 self._set_icon(type)
264 self._set_drag_source(type)
266 def _set_drag_source(self, type):
267 if self.document.can_save_to_file():
268 targets = [('XdndDirectSave0', 0, TARGET_XDS)]
269 else:
270 targets = []
271 if self.document.can_save_to_selection():
272 targets = targets + [(type, 0, TARGET_RAW),
273 ('application/octet-stream', 0, TARGET_RAW)]
275 if not targets:
276 raise Exception("Document %s can't save!" % self.document)
277 self.drag_box.drag_source_set(gdk.BUTTON1_MASK | gdk.BUTTON3_MASK,
278 targets,
279 gdk.ACTION_COPY | gdk.ACTION_MOVE)
281 def save_to_file_in_entry(self):
282 """Call this when the user clicks on an OK button you provide."""
283 uri = self.entry.get_text()
284 path = get_local_path(uri)
286 if path:
287 if not self.confirm_new_path(path):
288 return
289 try:
290 self.set_sensitive(FALSE)
291 try:
292 self.document.save_to_file(path)
293 finally:
294 self.set_sensitive(TRUE)
295 self.set_uri(path)
296 self.save_done()
297 except:
298 _report_save_error()
299 else:
300 rox.info(_("Drag the icon to a directory viewer\n"
301 "(or enter a full pathname)"))
303 def drag_begin(self, drag_box, context):
304 self.drag_in_progress = 1
305 self.destroy_on_drag_end = 0
306 self.using_xds = 0
307 self.data_sent = 0
309 pixbuf = self.icon.get_pixbuf()
310 if pixbuf:
311 drag_box.drag_source_set_icon_pixbuf(pixbuf)
313 uri = self.entry.get_text()
314 if uri:
315 i = uri.rfind('/')
316 if (i == -1):
317 leaf = uri
318 else:
319 leaf = uri[i + 1:]
320 else:
321 leaf = _('Unnamed')
322 _write_xds_property(context, leaf)
324 def drag_data_get(self, widget, context, selection_data, info, time):
325 if info == TARGET_RAW:
326 try:
327 self.set_sensitive(FALSE)
328 try:
329 self.document.save_to_selection(selection_data)
330 finally:
331 self.set_sensitive(TRUE)
332 except:
333 _report_save_error()
334 _write_xds_property(context, None)
335 return
337 self.data_sent = 1
338 _write_xds_property(context, None)
340 if self.drag_in_progress:
341 self.destroy_on_drag_end = 1
342 else:
343 self.save_done()
344 return
345 elif info != TARGET_XDS:
346 _write_xds_property(context, None)
347 alert("Bad target requested!")
348 return
350 # Using XDS:
352 # Get the path that the destination app wants us to save to.
353 # If it's local, save and return Success
354 # (or Error if save fails)
355 # If it's remote, return Failure (remote may try another method)
356 # If no URI is given, return Error
357 to_send = 'E'
358 uri = _read_xds_property(context, FALSE)
359 if uri:
360 path = get_local_path(uri)
361 if path:
362 if not self.confirm_new_path(path):
363 to_send = 'E'
364 else:
365 try:
366 self.set_sensitive(FALSE)
367 try:
368 self.document.save_to_file(path)
369 finally:
370 self.set_sensitive(TRUE)
371 self.data_sent = TRUE
372 except:
373 _report_save_error()
374 self.data_sent = FALSE
375 if self.data_sent:
376 to_send = 'S'
377 # (else Error)
378 else:
379 to_send = 'F' # Non-local transfer
380 else:
381 alert("Remote application wants to use " +
382 "Direct Save, but I can't read the " +
383 "XdndDirectSave0 (type text/plain) " +
384 "property.")
386 selection_data.set(selection_data.target, 8, to_send)
388 if to_send != 'E':
389 _write_xds_property(context, None)
390 path = get_local_path(uri)
391 if path:
392 self.set_uri(path)
393 else:
394 self.set_uri(uri)
395 if self.data_sent:
396 self.save_done()
398 def confirm_new_path(self, path):
399 """Use wants to save to this path. If it's different to the original path,
400 check that it doesn't exist and ask for confirmation if it does. Returns true
401 to go ahead with the save."""
402 if path == self.initial_uri:
403 return 1
404 if not os.path.exists(path):
405 return 1
406 return rox.confirm(_("File '%s' already exists -- overwrite it?") % path,
407 g.STOCK_DELETE, _('_Overwrite'))
409 def set_uri(self, uri):
410 "Data is safely saved somewhere. Update the document's URI. Internal."
411 self.document.set_uri(uri)
413 def drag_end(self, widget, context):
414 self.drag_in_progress = 0
415 if self.destroy_on_drag_end:
416 self.save_done()
418 def save_done(self):
419 self.document.save_done()
421 class SaveBox(g.Dialog):
422 """A SaveBox is a GtkDialog that contains a SaveArea and, optionally, a Discard button.
423 Calls rox.toplevel_(un)ref automatically.
426 def __init__(self, document, uri, type = 'text/plain', discard = FALSE):
427 """See SaveArea.__init__.
428 If discard is TRUE then an extra discard button is added to the dialog."""
429 g.Dialog.__init__(self)
430 self.set_has_separator(FALSE)
432 self.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
433 self.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
434 self.set_default_response(g.RESPONSE_OK)
436 if discard:
437 discard_area = g.HButtonBox()
439 def discard_clicked(event):
440 document.discard()
441 self.destroy()
442 button = rox.ButtonMixed(g.STOCK_DELETE, _('_Discard'))
443 discard_area.pack_start(button, FALSE, TRUE, 2)
444 button.connect('clicked', discard_clicked)
445 button.unset_flags(g.CAN_FOCUS)
446 button.set_flags(g.CAN_DEFAULT)
447 self.vbox.pack_end(discard_area, FALSE, TRUE, 0)
448 self.vbox.reorder_child(discard_area, 0)
450 discard_area.show_all()
452 self.set_title(_('Save As:'))
453 self.set_position(g.WIN_POS_MOUSE)
454 self.set_wmclass('savebox', 'Savebox')
455 self.set_border_width(1)
457 # Might as well make use of the new nested scopes ;-)
458 self.set_save_in_progress(0)
459 class BoxedArea(SaveArea):
460 def set_uri(area, uri):
461 document.set_uri(uri)
462 if discard:
463 document.discard()
464 def save_done(area):
465 document.save_done()
466 self.destroy()
468 def set_sensitive(area, sensitive):
469 if self.window:
470 # Might have been destroyed by now...
471 self.set_save_in_progress(not sensitive)
472 SaveArea.set_sensitive(area, sensitive)
473 save_area = BoxedArea(document, uri, type)
474 self.save_area = save_area
476 save_area.show_all()
477 self.build_main_area()
479 i = uri.rfind('/')
480 i = i + 1
481 # Have to do this here, or the selection gets messed up
482 save_area.entry.grab_focus()
483 g.Editable.select_region(save_area.entry, i, -1) # PyGtk bug
484 #save_area.entry.select_region(i, -1)
486 def got_response(widget, response):
487 if self.save_in_progress:
488 try:
489 document.save_cancelled()
490 except:
491 rox.report_exception()
492 return
493 if response == g.RESPONSE_CANCEL:
494 self.destroy()
495 elif response == g.RESPONSE_OK:
496 self.save_area.save_to_file_in_entry()
497 elif response == g.RESPONSE_DELETE_EVENT:
498 pass
499 else:
500 raise Exception('Unknown response!')
501 self.connect('response', got_response)
503 rox.toplevel_ref()
504 self.connect('destroy', lambda w: rox.toplevel_unref())
506 def set_type(self, type, icon = None):
507 """See SaveArea's method of the same name."""
508 self.save_area.set_type(type, icon)
510 def build_main_area(self):
511 """Place self.save_area somewhere in self.vbox. Override this
512 for more complicated layouts."""
513 self.vbox.add(self.save_area)
515 def set_save_in_progress(self, in_progress):
516 """Called when saving starts and ends. Shade/unshade any widgets as
517 required. Make sure you call the default method too!
518 Not called if box is destroyed from a recursive mainloop inside
519 a save_to_* function."""
520 self.set_response_sensitive(g.RESPONSE_OK, not in_progress)
521 self.save_in_progress = in_progress
523 class StringSaver(SaveBox, Saveable):
524 """A very simple SaveBox which saves the string passed to its constructor."""
525 def __init__(self, string, name):
526 """'string' is the string to save. 'name' is the default filename"""
527 SaveBox.__init__(self, self, name, 'text/plain')
528 self.string = string
530 def save_to_stream(self, stream):
531 stream.write(self.string)
533 class SaveFilter(Saveable):
534 """This Saveable runs a process in the background to generate the
535 save data. Any python streams can be used as the input to and
536 output from the process.
538 The output from the subprocess is saved to the output stream (either
539 directly, for fileno() streams, or via another temporary file).
541 If the process returns a non-zero exit status or writes to stderr,
542 the save fails (messages written to stderr are displayed).
545 stdin = None
547 def set_stdin(self, stream):
548 """Use 'stream' as stdin for the process. If stream is not a
549 seekable fileno() stream then it is copied to a temporary file
550 at this point. If None, the child process will get /dev/null on
551 stdin."""
552 if stream is not None:
553 if hasattr(stream, 'fileno') and hasattr(stream, 'seek'):
554 self.stdin = stream
555 else:
556 import tempfile
557 import shutil
558 self.stdin = tempfile.TemporaryFile()
559 shutil.copyfileobj(stream, self.stdin)
560 else:
561 self.stdin = None
563 def save_to_stream(self, stream):
564 from processes import Process
565 from cStringIO import StringIO
566 errors = StringIO()
567 done = []
569 # Get the FD for the output, creating a tmp file if needed
570 if hasattr(stream, 'fileno'):
571 stdout_fileno = stream.fileno()
572 tmp = None
573 else:
574 import tempfile
575 tmp = tempfile.TemporaryFile()
576 stdout_fileno = tmp.fileno()
578 # Get the FD for the input
579 if self.stdin:
580 stdin_fileno = self.stdin.fileno()
581 self.stdin.seek(0)
582 else:
583 stdin_fileno = os.open('/dev/null', os.O_RDONLY)
585 class FilterProcess(Process):
586 def child_post_fork(self):
587 if stdout_fileno != 1:
588 os.dup2(stdout_fileno, 1)
589 os.close(stdout_fileno)
590 if stdin_fileno is not None and stdin_fileno != 0:
591 os.dup2(stdin_fileno, 0)
592 os.close(stdin_fileno)
593 def got_error_output(self, data):
594 errors.write(data)
595 def child_died(self, status):
596 done.append(status)
597 g.mainquit()
598 def child_run(proc):
599 self.child_run()
600 self.process = FilterProcess()
601 self.killed = 0
602 self.process.start()
603 while not done:
604 g.mainloop()
605 self.process = None
606 status = done[0]
607 if self.killed:
608 print >> errors, '\nProcess terminated at user request'
609 error = errors.getvalue().strip()
610 if error:
611 raise AbortSave(error)
612 if status:
613 raise AbortSave('child_run() returned an error code, but no error message!')
614 if tmp:
615 # Data went to a temp file
616 tmp.seek(0)
617 stream.write(tmp.read())
619 def child_run(self):
620 """This is run in the child process. The default method runs 'self.command'
621 using os.system() and prints a message to stderr if the exit code is non-zero.
622 DO NOT call gtk functions here!
624 Be careful to escape shell special characters when inserting filenames!
626 command = self.command
627 if os.system(command):
628 print >>sys.stderr, "Command:\n%s\nreturned an error code" % command
629 os._exit(0) # Writing to stderr indicates error...
631 def save_cancelled(self):
632 """Send SIGTERM to the child processes."""
633 if self.process:
634 self.killed = 1
635 self.process.kill()