+#! @PYTHON@
+### -*-python-*-
+###
+### Catch input and trap it in an X window
+###
+### (c) 2008 Straylight/Edgeware
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of the Edgeware X tools collection.
+###
+### X tools is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+###
+### X tools is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with X tools; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+VERSION = '@VERSION@'
+
+###--------------------------------------------------------------------------
+### External dependencies.
+
+import optparse as O
+from sys import stdin, stdout, stderr, exit
+import sys as SYS
+import os as OS
+import fcntl as FC
+import errno as E
+import subprocess as S
+import pango as P
+import signal as SIG
+import traceback as TB
+
+import xtoys as XT
+GTK, GDK, GO = XT.GTK, XT.GDK, XT.GO
+
+###--------------------------------------------------------------------------
+### Utilities.
+
+def nonblocking(file):
+ """
+ Make the FILE be nonblocking.
+
+ FILE may be either an integer file descriptor, or something whose fileno
+ method yields up a file descriptor.
+ """
+ if isinstance(file, int):
+ fd = file
+ else:
+ fd = file.fileno()
+ flags = FC.fcntl(fd, FC.F_GETFL)
+ FC.fcntl(fd, FC.F_SETFL, flags | OS.O_NONBLOCK)
+
+class complain (object):
+ """
+ Function decorator: catch exceptions and report them in an error box.
+
+ Example:
+
+ @complain(DEFAULT)
+ def foo(...):
+ ...
+
+ The decorated function is called normally. If no exception occurs (or at
+ least none propagates out of the function) then it returns normally too.
+ Otherwise, the exception is trapped and displayed in a Message window, and
+ the function returns DEFAULT, which defaults to None.
+ """
+
+ def __init__(me, default = None):
+ """Initializer: store the DEFAULT value for later."""
+ me._default = default
+
+ def __call__(me, func):
+ """Decorate the function."""
+ def _(*args, **kw):
+ try:
+ return func(*args, **kw)
+ except:
+ type, info, _ = SYS.exc_info()
+ if isinstance(type, str):
+ head = 'Unexpected exception'
+ msg = type
+ else:
+ head = type.__name__
+ msg = ', '.join(info.args)
+ XT.Message(title = 'Error!', type = 'error', headline = head,
+ buttons = ['gtk-ok'], message = msg).ask()
+ return me._default
+ return _
+
+###--------------------------------------------------------------------------
+### Watching processes.
+
+class Reaper (object):
+ """
+ The Reaper catches SIGCHLD and collects exit statuses.
+
+ There should ideally be only one instance of the class; the reaper method
+ returns the instance, creating it if necessary. (The reaper uses up
+ resources which we can avoid wasting under some circumstances.)
+
+ Call add(KID, FUNC) to watch process-id KID; when it exits, the reaper
+ calls FUNC(KID, STATUS), where STATUS is the exit status directly from
+ wait.
+
+ Even though SIGCHLD occurs at unpredictable times, processes are not reaped
+ until we return to the GTK event loop. This means that you can safely
+ create processes and add them without needing to interlock with the reaper
+ in complicated ways, and it also means that handler functions are not
+ called at unpredictable times.
+ """
+
+ def __init__(me):
+ """
+ Initialize the reaper.
+
+ We create a pipe and register the read end of it with the GTK event
+ system. The SIGCHLD handler writes a byte to the pipe.
+ """
+ me._kidmap = {}
+ me._prd, pwr = OS.pipe()
+ nonblocking(me._prd)
+ SIG.signal(SIG.SIGCHLD, lambda sig, tb: OS.write(pwr, '?'))
+ GO.io_add_watch(me._prd, GO.IO_IN | GO.IO_HUP, me._wake)
+
+ _reaper = None
+ @classmethod
+ def reaper(cls):
+ """Return the instance of the Reaper, creating it if necessary."""
+ if cls._reaper is None:
+ cls._reaper = cls()
+ return cls._reaper
+
+ def add(me, kid, func):
+ """
+ Register the process-id KID with the reaper, calling FUNC when it exits.
+
+ As described, FUNC is called with two arguments, the KID and its exit
+ status.
+ """
+ me._kidmap[kid] = func
+
+ def _wake(me, file, reason):
+ """
+ Called when the event loop notices something in the signal pipe.
+
+ We empty the pipe and then reap any processes which need it.
+ """
+
+ ## Empty the pipe. It doesn't matter how many bytes are stored in the
+ ## pipe, or what their contents are.
+ try:
+ while True:
+ OS.read(me._prd, 16384)
+ except OSError, err:
+ if err.errno != E.EAGAIN:
+ raise
+
+ ## Reap processes and pass their exit statuses on.
+ while True:
+ try:
+ kid, st = OS.waitpid(-1, OS.WNOHANG)
+ except OSError, err:
+ if err.errno == E.ECHILD:
+ break
+ else:
+ raise
+ if kid == 0:
+ break
+ try:
+ func = me._kidmap[kid]
+ del me._kidmap[kid]
+ except KeyError:
+ continue
+ func(kid, st)
+
+ ## Done: call me again.
+ return True
+
+###--------------------------------------------------------------------------
+### Catching and displaying output.
+
+class Catcher (object):
+ """
+ Catcher objects watch an input file and display the results in a window.
+
+ Initialization is a little cumbersome. You make an object, and then add a
+ file and maybe a process-id to watch. The catcher will not create a window
+ until it actually needs to display something.
+
+ The object can be configured by setting attributes before it first opens
+ its window.
+
+ * title: is the title for the window
+ * font: is the font to display text in, as a string
+
+ The rc attribute is set to a suitable exit status.
+ """
+
+ def __init__(me):
+ """Initialize the catcher."""
+ me.title = 'xcatch'
+ me._file = None
+ me._window = None
+ me._buf = None
+ me.font = None
+ me._openp = False
+ me.rc = 0
+
+ def watch_file(me, file):
+ """
+ Watch the FILE for input.
+
+ Any data arriving for the FILE is recoded into UTF-8 and displayed in the
+ window. The file not reaching EOF is considered a reason not to end the
+ program.
+ """
+ XT.addreason()
+ nonblocking(file)
+ me._src = GO.io_add_watch(file, GO.IO_IN | GO.IO_HUP, me._ready)
+ me._file = file
+
+ def watch_kid(me, kid):
+ """
+ Watch the process-id KID for exit.
+
+ If the child dies abnormally then a message is written to the window.
+ """
+ XT.addreason()
+ Reaper.reaper().add(kid, me._exit)
+
+ def make_window(me):
+ """
+ Construct the output window if necessary.
+
+ Having the window open is a reason to continue.
+ """
+
+ ## If the window exists, just make sure it's visible.
+ if me._window is not None:
+ if me._openp == False:
+ me._window.present()
+ XT.addreason()
+ me._openp = True
+ return
+
+ ## Make the buffer.
+ buf = GTK.TextBuffer()
+ me._deftag = buf.create_tag('default')
+ if me.font is not None:
+ me._deftag.set_properties(font = me.font)
+ me._exittag = \
+ buf.create_tag('exit', style_set = True, style = P.STYLE_ITALIC)
+ me._buf = buf
+
+ ## Make the window.
+ win = GTK.Window(GTK.WINDOW_TOPLEVEL)
+ win.set_title(me.title)
+ win.connect('delete-event', me._delete)
+ win.connect('key-press-event', me._keypress)
+ view = GTK.TextView(buf)
+ view.set_editable(False)
+ scr = GTK.ScrolledWindow()
+ scr.set_policy(GTK.POLICY_AUTOMATIC, GTK.POLICY_AUTOMATIC)
+ scr.set_shadow_type(GTK.SHADOW_IN)
+ scr.add(view)
+ win.set_default_size(480, 200)
+ win.add(scr)
+
+ ## All done.
+ win.show_all()
+ XT.addreason()
+ me._openp = True
+ me._window = win
+
+ def _keypress(me, win, event):
+ """
+ Handle a keypress on the window.
+
+ Escape or Q will close the window.
+ """
+ key = GDK.keyval_name(event.keyval)
+ if key in ['Escape', 'q', 'Q']:
+ me._delete()
+ return True
+ return False
+
+ def _delete(me, *_):
+ """
+ Handle a close request on the window.
+
+ Closing the window removes a reason to continue.
+ """
+ me._window.hide()
+ XT.delreason()
+ me._openp = False
+
+ @complain(True)
+ def _ready(me, file, *_):
+ """
+ Process input arriving on the FILE.
+ """
+ try:
+ buf = file.read(16384)
+ except IOError, err:
+ if err.errno == E.EAGAIN:
+ return True
+ me._close()
+ me.rc = 127
+ raise
+ if buf == '':
+ me._close()
+ return True
+ me.make_window()
+ uni = buf.decode(SYS.getdefaultencoding(), 'replace')
+ utf8 = uni.encode('utf-8')
+ end = me._buf.get_end_iter()
+ me._buf.insert_with_tags(end, utf8, me._deftag)
+ return True
+
+ def _close(me):
+ """
+ Close the input file.
+ """
+ XT.delreason()
+ GO.source_remove(me._src)
+ me._file = None
+
+ def _exit(me, kid, st):
+ """
+ Handle the child process exiting.
+ """
+ if st == 0:
+ XT.delreason()
+ return
+ me.make_window()
+ XT.delreason()
+ end = me._buf.get_end_iter()
+ if not end.starts_line():
+ me._buf.insert(end, '\n')
+ if OS.WIFEXITED(st):
+ msg = 'exited with status %d' % OS.WEXITSTATUS(st)
+ me.rc = OS.WEXITSTATUS(st)
+ elif OS.WIFSIGNALED(st):
+ msg = 'killed by signal %d' % OS.WTERMSIG(st)
+ me.rc = OS.WTERMSIG(st) | 128
+ if OS.WCOREDUMP(st):
+ msg += ' (core dumped)'
+ else:
+ msg = 'exited with unknown code 0x%x' % st
+ me.rc = 255
+ me._buf.insert_with_tags(end, '\n[%s]\n' % msg,
+ me._deftag, me._exittag)
+
+###--------------------------------------------------------------------------
+### Option parsing.
+
+def parse_args():
+ """
+ Parse the command line, returning a triple (PARSER, OPTS, ARGS).
+ """
+
+ op = XT.make_optparse \
+ ([('f', 'file',
+ {'dest': 'file',
+ 'help': "Read input from FILE."}),
+ ('F', 'font',
+ {'dest': 'font',
+ 'help': "Display output using FONT."})],
+ version = VERSION,
+ usage = '%prog [-f FILE] [-F FONT] [COMMAND [ARGS...]]')
+
+ op.set_defaults(file = None,
+ font = 'monospace')
+
+ opts, args = op.parse_args()
+ if len(args) > 0 and opts.file is not None:
+ op.error("Can't read from a file and a command simultaneously.")
+ return op, opts, args
+
+###--------------------------------------------------------------------------
+### Main program.
+
+def main():
+
+ ## Check options.
+ op, opts, args = parse_args()
+
+ ## Set up the file to read from.
+ catcher = Catcher()
+ if opts.file is not None:
+ if opts.file == '-':
+ name = '<stdin>'
+ catcher.watch_file(stdin)
+ else:
+ name = file
+ catcher.watch(open(opts.file, 'r'))
+ elif len(args) == 0:
+ name = '<stdin>'
+ catcher.watch_file(stdin)
+ else:
+ name = ' '.join(args)
+ Reaper.reaper()
+ proc = S.Popen(args, stdout = S.PIPE, stderr = S.STDOUT)
+ catcher.watch_file(proc.stdout)
+ catcher.watch_kid(proc.pid)
+
+ catcher.title = 'xcatch: ' + name
+ catcher.font = opts.font
+
+ ## Let things run their course.
+ GTK.main()
+ exit(catcher.rc)
+
+if __name__ == '__main__':
+ main()
+
+###----- That's all, folks --------------------------------------------------