chiark / gitweb /
xscsize.c, etc.: Report geometry of individual monitors.
[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     return True
308
309   @complain(True)
310   def _ready(me, file, *_):
311     """
312     Process input arriving on the FILE.
313     """
314     try:
315       buf = file.read(16384)
316     except IOError, err:
317       if err.errno == E.EAGAIN:
318         return True
319       me._close()
320       me.rc = 127
321       raise
322     if buf == '':
323       me._close()
324       return True
325     me.make_window()
326     uni = buf.decode(SYS.getdefaultencoding(), 'replace')
327     utf8 = uni.encode('utf-8')
328     end = me._buf.get_end_iter()
329     me._buf.insert_with_tags(end, utf8, me._deftag)
330     return True
331
332   def _close(me):
333     """
334     Close the input file.
335     """
336     XT.delreason()
337     GO.source_remove(me._src)
338     me._file = None
339
340   def _exit(me, kid, st):
341     """
342     Handle the child process exiting.
343     """
344     if st == 0:
345       XT.delreason()
346       return
347     me.make_window()
348     XT.delreason()
349     end = me._buf.get_end_iter()
350     if not end.starts_line():
351       me._buf.insert(end, '\n')
352     if OS.WIFEXITED(st):
353       msg = 'exited with status %d' % OS.WEXITSTATUS(st)
354       me.rc = OS.WEXITSTATUS(st)
355     elif OS.WIFSIGNALED(st):
356       msg = 'killed by signal %d' % OS.WTERMSIG(st)
357       me.rc = OS.WTERMSIG(st) | 128
358       if OS.WCOREDUMP(st):
359         msg += ' (core dumped)'
360     else:
361       msg = 'exited with unknown code 0x%x' % st
362       me.rc = 255
363     me._buf.insert_with_tags(end, '\n[%s]\n' % msg,
364                              me._deftag, me._exittag)
365
366 ###--------------------------------------------------------------------------
367 ### Option parsing.
368
369 def parse_args():
370   """
371   Parse the command line, returning a triple (PARSER, OPTS, ARGS).
372   """
373
374   op = XT.make_optparse \
375        ([('f', 'file',
376           {'dest': 'file',
377            'help': "Read input from FILE."}),
378          ('F', 'font',
379           {'dest': 'font',
380            'help': "Display output using FONT."})],
381         version = VERSION,
382         usage = '%prog [-f FILE] [-F FONT] [COMMAND [ARGS...]]')
383
384   op.set_defaults(file = None,
385                   font = 'monospace')
386
387   opts, args = op.parse_args()
388   if len(args) > 0 and opts.file is not None:
389     op.error("Can't read from a file and a command simultaneously.")
390   return op, opts, args
391
392 ###--------------------------------------------------------------------------
393 ### Main program.
394
395 def main():
396
397   ## Check options.
398   op, opts, args = parse_args()
399
400   ## Set up the file to read from.
401   catcher = Catcher()
402   if opts.file is not None:
403     if opts.file == '-':
404       name = '<stdin>'
405       catcher.watch_file(stdin)
406     else:
407       name = opts.file
408       catcher.watch_file(open(opts.file, 'r'))
409   elif len(args) == 0:
410     name = '<stdin>'
411     catcher.watch_file(stdin)
412   else:
413     name = ' '.join(args)
414     Reaper.reaper()
415     proc = S.Popen(args, stdout = S.PIPE, stderr = S.STDOUT)
416     catcher.watch_file(proc.stdout)
417     catcher.watch_kid(proc.pid)
418
419   catcher.title = 'xcatch: ' + name
420   catcher.font = opts.font
421
422   ## Let things run their course.
423   GTK.main()
424   exit(catcher.rc)
425
426 if __name__ == '__main__':
427   main()
428
429 ###----- That's all, folks --------------------------------------------------