Release 1.6.0.
[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 --------------------------------------------------