chiark / gitweb /
Rewrite graphical tools in Python.
[xtoys] / xcatch.in
diff --git a/xcatch.in b/xcatch.in
new file mode 100644 (file)
index 0000000..0f49a80
--- /dev/null
+++ b/xcatch.in
@@ -0,0 +1,428 @@
+#! @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 --------------------------------------------------