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 --------------------------------------------------