chiark / gitweb /
debian: Update changelog for release.
[xtoys] / xcatch.in
1 #! @PYTHON@
2 ### -*-python-*-
3 ###
4 ### Catch input and trap it in an X window
5 ###
6 ### (c) 2008 Straylight/Edgeware
7 ###
8
9 ###----- Licensing notice ---------------------------------------------------
10 ###
11 ### This file is part of the Edgeware X tools collection.
12 ###
13 ### X tools is free software; you can redistribute it and/or modify
14 ### it under the terms of the GNU General Public License as published by
15 ### the Free Software Foundation; either version 2 of the License, or
16 ### (at your option) any later version.
17 ###
18 ### X tools is distributed in the hope that it will be useful,
19 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
20 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21 ### GNU General Public License for more details.
22 ###
23 ### You should have received a copy of the GNU General Public License
24 ### along with X tools; if not, write to the Free Software Foundation,
25 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
26
27 VERSION = '@VERSION@'
28
29 ###--------------------------------------------------------------------------
30 ### External dependencies.
31
32 import optparse as O
33 from sys import stdin, stdout, stderr, exit
34 import sys as SYS
35 import os as OS
36 import fcntl as FC
37 import errno as E
38 import subprocess as S
39 import pango as P
40 import signal as SIG
41 import traceback as TB
42
43 import xtoys as XT
44 GTK, GDK, GO = XT.GTK, XT.GDK, XT.GO
45
46 ###--------------------------------------------------------------------------
47 ### Utilities.
48
49 def nonblocking(file):
50   """
51   Make the FILE be nonblocking.
52
53   FILE may be either an integer file descriptor, or something whose fileno
54   method yields up a file descriptor.
55   """
56   if isinstance(file, int):
57     fd = file
58   else:
59     fd = file.fileno()
60   flags = FC.fcntl(fd, FC.F_GETFL)
61   FC.fcntl(fd, FC.F_SETFL, flags | OS.O_NONBLOCK)
62
63 class complain (object):
64   """
65   Function decorator: catch exceptions and report them in an error box.
66
67   Example:
68
69           @complain(DEFAULT)
70           def foo(...):
71             ...
72
73   The decorated function is called normally.  If no exception occurs (or at
74   least none propagates out of the function) then it returns normally too.
75   Otherwise, the exception is trapped and displayed in a Message window, and
76   the function returns DEFAULT, which defaults to None.
77   """
78
79   def __init__(me, default = None):
80     """Initializer: store the DEFAULT value for later."""
81     me._default = default
82
83   def __call__(me, func):
84     """Decorate the function."""
85     def _(*args, **kw):
86       try:
87         return func(*args, **kw)
88       except:
89         type, info, _ = SYS.exc_info()
90         if isinstance(type, str):
91           head = 'Unexpected exception'
92           msg = type
93         else:
94           head = type.__name__
95           msg = ', '.join(info.args)
96         XT.Message(title = 'Error!', type = 'error', headline = head,
97                    buttons = ['gtk-ok'], message = msg).ask()
98         return me._default
99     return _
100
101 ###--------------------------------------------------------------------------
102 ### Watching processes.
103
104 class Reaper (object):
105   """
106   The Reaper catches SIGCHLD and collects exit statuses.
107
108   There should ideally be only one instance of the class; the reaper method
109   returns the instance, creating it if necessary.  (The reaper uses up
110   resources which we can avoid wasting under some circumstances.)
111
112   Call add(KID, FUNC) to watch process-id KID; when it exits, the reaper
113   calls FUNC(KID, STATUS), where STATUS is the exit status directly from
114   wait.
115
116   Even though SIGCHLD occurs at unpredictable times, processes are not reaped
117   until we return to the GTK event loop.  This means that you can safely
118   create processes and add them without needing to interlock with the reaper
119   in complicated ways, and it also means that handler functions are not
120   called at unpredictable times.
121   """
122
123   def __init__(me):
124     """
125     Initialize the reaper.
126
127     We create a pipe and register the read end of it with the GTK event
128     system.  The SIGCHLD handler writes a byte to the pipe.
129     """
130     me._kidmap = {}
131     me._prd, pwr = OS.pipe()
132     nonblocking(me._prd)
133     SIG.signal(SIG.SIGCHLD, lambda sig, tb: OS.write(pwr, '?'))
134     GO.io_add_watch(me._prd, GO.IO_IN | GO.IO_HUP, me._wake)
135
136   _reaper = None
137   @classmethod
138   def reaper(cls):
139     """Return the instance of the Reaper, creating it if necessary."""
140     if cls._reaper is None:
141       cls._reaper = cls()
142     return cls._reaper
143
144   def add(me, kid, func):
145     """
146     Register the process-id KID with the reaper, calling FUNC when it exits.
147
148     As described, FUNC is called with two arguments, the KID and its exit
149     status.
150     """
151     me._kidmap[kid] = func
152
153   def _wake(me, file, reason):
154     """
155     Called when the event loop notices something in the signal pipe.
156
157     We empty the pipe and then reap any processes which need it.
158     """
159
160     ## Empty the pipe.  It doesn't matter how many bytes are stored in the
161     ## pipe, or what their contents are.
162     try:
163       while True:
164         OS.read(me._prd, 16384)
165     except OSError, err:
166       if err.errno != E.EAGAIN:
167         raise
168
169     ## Reap processes and pass their exit statuses on.
170     while True:
171       try:
172         kid, st = OS.waitpid(-1, OS.WNOHANG)
173       except OSError, err:
174         if err.errno == E.ECHILD:
175           break
176         else:
177           raise
178       if kid == 0:
179         break
180       try:
181         func = me._kidmap[kid]
182         del me._kidmap[kid]
183       except KeyError:
184         continue
185       func(kid, st)
186
187     ## Done: call me again.
188     return True
189
190 ###--------------------------------------------------------------------------
191 ### Catching and displaying output.
192
193 class Catcher (object):
194   """
195   Catcher objects watch an input file and display the results in a window.
196
197   Initialization is a little cumbersome.  You make an object, and then add a
198   file and maybe a process-id to watch.  The catcher will not create a window
199   until it actually needs to display something.
200
201   The object can be configured by setting attributes before it first opens
202   its window.
203
204     * title: is the title for the window
205     * font: is the font to display text in, as a string
206
207   The rc attribute is set to a suitable exit status.
208   """
209
210   def __init__(me):
211     """Initialize the catcher."""
212     me.title = 'xcatch'
213     me._file = None
214     me._window = None
215     me._buf = None
216     me.font = None
217     me._openp = False
218     me.rc = 0
219
220   def watch_file(me, file):
221     """
222     Watch the FILE for input.
223
224     Any data arriving for the FILE is recoded into UTF-8 and displayed in the
225     window.  The file not reaching EOF is considered a reason not to end the
226     program.
227     """
228     XT.addreason()
229     nonblocking(file)
230     me._src = GO.io_add_watch(file, GO.IO_IN | GO.IO_HUP, me._ready)
231     me._file = file
232
233   def watch_kid(me, kid):
234     """
235     Watch the process-id KID for exit.
236
237     If the child dies abnormally then a message is written to the window.
238     """
239     XT.addreason()
240     Reaper.reaper().add(kid, me._exit)
241
242   def make_window(me):
243     """
244     Construct the output window if necessary.
245
246     Having the window open is a reason to continue.
247     """
248
249     ## If the window exists, just make sure it's visible.
250     if me._window is not None:
251       if me._openp == False:
252         me._window.present()
253         XT.addreason()
254         me._openp = True
255       return
256
257     ## Make the buffer.
258     buf = GTK.TextBuffer()
259     me._deftag = buf.create_tag('default')
260     if me.font is not None:
261       me._deftag.set_properties(font = me.font)
262     me._exittag = \
263       buf.create_tag('exit', style_set = True, style = P.STYLE_ITALIC)
264     me._buf = buf
265
266     ## Make the window.
267     win = GTK.Window(GTK.WINDOW_TOPLEVEL)
268     win.set_title(me.title)
269     win.connect('delete-event', me._delete)
270     win.connect('key-press-event', me._keypress)
271     view = GTK.TextView(buf)
272     view.set_editable(False)
273     scr = GTK.ScrolledWindow()
274     scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
275     scr.set_shadow_type(GTK.SHADOW_IN)
276     scr.add(view)
277     win.set_default_size(480, 200)
278     win.add(scr)
279
280     ## All done.
281     win.show_all()
282     XT.addreason()
283     me._openp = True
284     me._window = win
285
286   def _keypress(me, win, event):
287     """
288     Handle a keypress on the window.
289
290     Escape or Q will close the window.
291     """
292     key = GDK.keyval_name(event.keyval)
293     if key in ['Escape', 'q', 'Q']:
294       me._delete()
295       return True
296     return False
297
298   def _delete(me, *_):
299     """
300     Handle a close request on the window.
301
302     Closing the window removes a reason to continue.
303     """
304     me._window.hide()
305     XT.delreason()
306     me._openp = False
307
308   @complain(True)
309   def _ready(me, file, *_):
310     """
311     Process input arriving on the FILE.
312     """
313     try:
314       buf = file.read(16384)
315     except IOError, err:
316       if err.errno == E.EAGAIN:
317         return True
318       me._close()
319       me.rc = 127
320       raise
321     if buf == '':
322       me._close()
323       return True
324     me.make_window()
325     uni = buf.decode(SYS.getdefaultencoding(), 'replace')
326     utf8 = uni.encode('utf-8')
327     end = me._buf.get_end_iter()
328     me._buf.insert_with_tags(end, utf8, me._deftag)
329     return True
330
331   def _close(me):
332     """
333     Close the input file.
334     """
335     XT.delreason()
336     GO.source_remove(me._src)
337     me._file = None
338
339   def _exit(me, kid, st):
340     """
341     Handle the child process exiting.
342     """
343     if st == 0:
344       XT.delreason()
345       return
346     me.make_window()
347     XT.delreason()
348     end = me._buf.get_end_iter()
349     if not end.starts_line():
350       me._buf.insert(end, '\n')
351     if OS.WIFEXITED(st):
352       msg = 'exited with status %d' % OS.WEXITSTATUS(st)
353       me.rc = OS.WEXITSTATUS(st)
354     elif OS.WIFSIGNALED(st):
355       msg = 'killed by signal %d' % OS.WTERMSIG(st)
356       me.rc = OS.WTERMSIG(st) | 128
357       if OS.WCOREDUMP(st):
358         msg += ' (core dumped)'
359     else:
360       msg = 'exited with unknown code 0x%x' % st
361       me.rc = 255
362     me._buf.insert_with_tags(end, '\n[%s]\n' % msg,
363                              me._deftag, me._exittag)
364
365 ###--------------------------------------------------------------------------
366 ### Option parsing.
367
368 def parse_args():
369   """
370   Parse the command line, returning a triple (PARSER, OPTS, ARGS).
371   """
372
373   op = XT.make_optparse \
374        ([('f', 'file',
375           {'dest': 'file',
376            'help': "Read input from FILE."}),
377          ('F', 'font',
378           {'dest': 'font',
379            'help': "Display output using FONT."})],
380         version = VERSION,
381         usage = '%prog [-f FILE] [-F FONT] [COMMAND [ARGS...]]')
382
383   op.set_defaults(file = None,
384                   font = 'monospace')
385
386   opts, args = op.parse_args()
387   if len(args) > 0 and opts.file is not None:
388     op.error("Can't read from a file and a command simultaneously.")
389   return op, opts, args
390
391 ###--------------------------------------------------------------------------
392 ### Main program.
393
394 def main():
395
396   ## Check options.
397   op, opts, args = parse_args()
398
399   ## Set up the file to read from.
400   catcher = Catcher()
401   if opts.file is not None:
402     if opts.file == '-':
403       name = '<stdin>'
404       catcher.watch_file(stdin)
405     else:
406       name = file
407       catcher.watch(open(opts.file, 'r'))
408   elif len(args) == 0:
409     name = '<stdin>'
410     catcher.watch_file(stdin)
411   else:
412     name = ' '.join(args)
413     Reaper.reaper()
414     proc = S.Popen(args, stdout = S.PIPE, stderr = S.STDOUT)
415     catcher.watch_file(proc.stdout)
416     catcher.watch_kid(proc.pid)
417
418   catcher.title = 'xcatch: ' + name
419   catcher.font = opts.font
420
421   ## Let things run their course.
422   GTK.main()
423   exit(catcher.rc)
424
425 if __name__ == '__main__':
426   main()
427
428 ###----- That's all, folks --------------------------------------------------