From 8022653de859a8c4b2dd88a105265aa11dafbddf Mon Sep 17 00:00:00 2001
From: Paul McCarthy <pauld.mccarthy@gmail.com>
Date: Wed, 16 Apr 2014 10:35:36 +0100
Subject: [PATCH] Refactored runwindow.py a bit, made code cleaner. The
 ProcessManger thread class may even be better off as a separate module, as it
 will probably grow to be quite important.

---
 fsl/tools/bet.py       |   4 +-
 fsl/utils/runwindow.py | 348 +++++++++++++++++++++++------------------
 2 files changed, 199 insertions(+), 153 deletions(-)

diff --git a/fsl/tools/bet.py b/fsl/tools/bet.py
index 6b4b5d4b5..3d73bda7a 100644
--- a/fsl/tools/bet.py
+++ b/fsl/tools/bet.py
@@ -281,8 +281,8 @@ def runBet(parent, opts):
         
     runwindow.checkAndRun('BET', opts, parent, Options.genBetCmd,
                           optLabels=optLabels,
-                          modal=False,
-                          onFinish=onFinish)
+                          onFinish=onFinish,
+                          modal=False)
 
 
 FSL_TOOLNAME  = 'BET'
diff --git a/fsl/utils/runwindow.py b/fsl/utils/runwindow.py
index 2c9b796b2..2f4d8604c 100644
--- a/fsl/utils/runwindow.py
+++ b/fsl/utils/runwindow.py
@@ -1,6 +1,8 @@
 #!/usr/bin/env python
 #
 # runwindow.py - Run a process, display its output in a wx window.
+#                The checkAndRun() and run() functions are the entry
+#                points for this module.
 #
 # Author: Paul McCarthy <pauldmccarthy@gmail.com>
 #
@@ -21,18 +23,25 @@ log = logging.getLogger(__name__)
 
 class RunPanel(wx.Panel):
     """
-    A panel which displays a multiline text control, and a collection of
+    A panel which displays a multiline text control, and a couple of
     buttons along the bottom.
     """
 
-    def __init__(self, parent, buttons=[]):
+    def __init__(self, parent):
         """
-        Creates and lays out a text control, and the buttons specified
-        in the given (label, callback function) tuple list.
+        Creates and lays out a text control, and two buttons. One of
+        the buttons is intended to closes the window in which this
+        panel is contained. The second button is intended to terminate
+        the running process. Both buttons are unbound by default, so
+        must be manually bound to callback functions.
         """
         wx.Panel.__init__(self, parent)
 
         self.sizer = wx.BoxSizer(wx.VERTICAL)
+        self.SetSizer(self.sizer) 
+        
+        # Horizontal scrolling no work in OSX
+        # mavericks. I think it's a wxwidgets bug.
         self.text  = wx.TextCtrl(self,
                                  style=wx.TE_MULTILINE | \
                                        wx.TE_READONLY  | \
@@ -41,176 +50,174 @@ class RunPanel(wx.Panel):
 
         self.sizer.Add(self.text, flag=wx.EXPAND, proportion=1)
 
-        if len(buttons) > 0:
-
-            self.btnPanel = wx.Panel(self)
-            self.btnSizer = wx.BoxSizer(wx.HORIZONTAL)
-            self.buttons  = {}
-
-            self.btnPanel.SetSizer(self.btnSizer)
-
-            self.sizer.Add(self.btnPanel, flag=wx.EXPAND)
-
-            for btnSpec in buttons:
-
-                label, callback = btnSpec
-
-                btn = wx.Button(self.btnPanel, label=label)
-                btn.Bind(wx.EVT_BUTTON, lambda e,cb=callback: cb())
+        self.btnPanel = wx.Panel(self)
+        self.btnSizer = wx.BoxSizer(wx.HORIZONTAL)
+        self.btnPanel.SetSizer(self.btnSizer)
+        
+        self.sizer.Add(self.btnPanel, flag=wx.EXPAND)
 
-                self.buttons[label] = btn
-                self.btnSizer.Add(btn, flag=wx.EXPAND, proportion=1)
+        self.killButton  = wx.Button(self.btnPanel, label='Terminate process')
+        self.closeButton = wx.Button(self.btnPanel, label='Close window')
 
-        self.SetSizer(self.sizer)
+        self.btnSizer.Add(self.killButton,  flag=wx.EXPAND, proportion=1)
+        self.btnSizer.Add(self.closeButton, flag=wx.EXPAND, proportion=1)
 
 
-def checkAndRun(toolName, opts, parent, cmdFunc,
-                optLabels={},
-                modal=True,
-                onFinish=None):
+class ProcessManager(thread.Thread):
     """
-    Validates the given options. If invalid, a dialog is shown, informing
-    the user about the errors. Otherwise, the tool is executed, and its
-    output shown in a dialog window. Parameters:
-    
-      - toolName:  Name of the tool, used in the window title
-    
-      - opts:      HasProperties object to be validated
-    
-      - parent:    wx object to be used as parent
-    
-      - cmdFunc:   Function which takes a HasProperties object, and returns
-                   a command to be executed (as a list of strings), which
-                   will be passed to the run() function.
-    
-      - optLabels: Dictionary containing property name -> label mappings.
-    
-      - modal:     If true, the command window will be modal.
-
-      - onFinish:  Function to be called when the process ends.
+    A thread which manages the execution of a child process, and
+    capture of its output.
     """
 
-    errors = opts.validateAll()
-
-    if len(errors) > 0:
-
-        msg = 'There are numerous errors which need '\
-              'to be fixed before {} can be run:\n'.format(toolName)
-
-        for name,error in errors:
-            
-            if name in optLabels: name = optLabels[name]
-            msg = msg + '\n - {}: {}'.format(name, error)
-
-        wx.MessageDialog(
-            parent,
-            message=msg,
-            style=wx.OK | wx.ICON_ERROR).ShowModal()
+    def __init__(self, cmd, parent, runPanel, onFinish):
+        """
+        Create a ProcessManager thread object. Does nothing special.
+        Parameters:
+          - cmd:      String or list of strings, the command to be
+                      executed.
         
-    else:
-        cmd = cmdFunc(opts)
-        run(toolName, cmd, parent, modal, onFinish)
-
-
-def run(name, cmd, parent, modal=True, onFinish=None, actions=None):
-    """
-    Runs the given command, displaying the output in a wx window.
-    Parameters:
-    
-      - name:     Name of the tool to be run, used in the window title.
-    
-      - cmd:      List of strings, specifying the command (+args) to be
-                  executed.
-    
-      - parent:   wx parent object.
-    
-      - modal:    If true, the command window will be modal.
-
-      - onFinish: Function to be called when the process ends. Must
-                  accept two parameters - a reference to the wx
-                  frame/dialog displaying the process output, and
-                  the exit code of the application.
-          
-    """
-
-    if actions is None: actions = []
-
-    frame = None # wx.Frame or Dialog, the parent window
-    panel = None # Panel containing process output and control buttons
-    proc  = None # Object representing the process
-    outq  = None # Queue used for reading process output
-    
-    def writeToDialog():
+          - parent:   GUI parent object.
+        
+          - runPanel: RunPanel object, for displaying the child process
+                      output.
+        
+          - onFinish: Callback function to be called when the process
+                      finishes. May be None. Must accept two parameters,
+                      the GUI parent object, and the process return code.
+        """
+        thread.Thread.__init__(self, name=cmd[0])
+        
+        self.cmd      = cmd
+        self.parent   = parent
+        self.runPanel = runPanel
+        self.onFinish = onFinish
+
+        # Handle to the Popen object which represents
+        # the child process. Created in run().
+        self.proc = None
+
+        # A queue for sharing data between the thread which
+        # is blocking on process output (this thread object),
+        # and the wx main thread which writes that output to
+        # the runPanel
+        self.outq = queue.Queue()
+ 
+        # Put the command string at the top of the text control
+        self.outq.put(' '.join(self.cmd) + '\n\n')
+        wx.CallAfter(self.writeToPanel)
+
+
+    def writeToPanel(self):
         """
-        Reads a string from the output queue,
-        and appends it to the interface.
+        Reads a string from the output queue, and appends it
+        to the runPanel. This method is intended to be
+        executed via wx.CallAfter.
         """
         
-        try:                output = outq.get_nowait()
+        try:                output = self.outq.get_nowait()
         except queue.Empty: output = None
 
-        if output is not None:
+        if output is None: return
 
-            # ignore errors - the user may have closed the
-            # dialog window before the process has completed
-            try:    panel.text.WriteText(output)
-            except: pass
+        # ignore errors - the user may have closed the
+        # runPanel window before the process has completed
+        try:    self.runPanel.text.WriteText(output)
+        except: pass
+ 
 
-            
-    def pollOutput():
+    def run(self):
         """
-        Reads the output of the process, line by line, and
-        writes it (asynchronously) to the interface. When
-        the process ends, the onFinish method (if there is
-        one) is called.
+        Starts the process, then reads its output line by
+        line, writing each line asynchronously to the runPanel.
+        When the process ends, the onFinish method (if there is
+        one) is called. If the process finishes abnormally (with
+        a non-0 exit code) a warning dialog is displayed.
         """
-        
-        for line in proc.stdout:
+
+        # Run the command. The preexec_fn parameter creates
+        # a process group, so we are able to kill the child
+        # process, and all of its children, if necessary. 
+        log.debug('Running process: "{}"'.format(' '.join(self.cmd)))
+        self.proc = subp.Popen(self.cmd,
+                               stdout=subp.PIPE,
+                               bufsize=1,
+                               stderr=subp.STDOUT,
+                               preexec_fn=os.setsid)
+
+        # read process output, line by line, pushing
+        # each line onto the output queue and
+       # asynchronously writing it to the runPanel
+        for line in self.proc.stdout:
             
             log.debug('Process output: {}'.format(line.strip()))
-            outq.put(line)
-            wx.CallAfter(writeToDialog)
+            self.outq.put(line)
+            wx.CallAfter(self.writeToPanel)
 
         # When the above for loop ends, it means that the stdout
         # pipe has been broken. But it doesn't mean that the
         # subprocess is finished. So here, we wait until the
         # subprocess terminates, before continuing,
-        proc.wait()
+        self.proc.wait()
 
-        retcode = proc.returncode
+        retcode = self.proc.returncode
         
-        log.debug('Process finished with return code {}'.format(retcode))
-            
-        outq.put('\nProcess finished\n')
-        wx.CallAfter(writeToDialog)
+        log.debug(    'Process finished with return code {}'.format(retcode))
+        self.outq.put('Process finished with return code {}'.format(retcode))
 
-        if onFinish is not None:
-            wx.CallAfter(onFinish, frame, retcode)
+        wx.CallAfter(self.writeToPanel)
 
+        # Disable the 'terminate' button on the run panel
         def updateKillButton():
 
-            # ignore errors - see writeToDialog
-            try:    panel.buttons['Terminate process'].Enable(False)
+            # ignore errors - see writeToPanel
+            try:    self.runPanel.killButton.Enable(False)
             except: pass
 
         wx.CallAfter(updateKillButton)
 
+        # Run the onFinish handler, if there is one
+        if self.onFinish is not None:
+            wx.CallAfter(self.onFinish, self.parent, retcode)
 
-    def termProc():
+        
+    def termProc(self):
         """
-        Callback function for the 'Kill process' button. 
+        Attempts to kill the running child process.
         """
         try:
             log.debug('Attempting to send SIGTERM to '\
-                      'process group with pid {}'.format(proc.pid))
-            os.killpg(proc.pid, signal.SIGTERM)
-            outq.put('\nSIGTERM sent to process\n\n')
-            wx.CallAfter(writeToDialog)
+                      'process group with pid {}'.format(self.proc.pid))
+            os.killpg(self.proc.pid, signal.SIGTERM)
+
+            # put a message on the runPanel
+            self.outq.put('\nSIGTERM sent to process\n\n')
+            wx.CallAfter(self.writeToPanel)
             
         except:
             pass # why am i ignoring errors here?
-            
-    # Create the GUI
+
+
+def run(name, cmd, parent, onFinish=None, modal=True):
+    """
+    Runs the given command, displaying the output in a wx window.
+    Parameters:
+    
+      - name:     Name of the tool to be run, used in the window title.
+    
+      - cmd:      String or list of strings, specifying the command to be
+                  executed.
+    
+      - parent:   wx parent object.
+    
+      - modal:    If True, the command window will be modal.
+
+      - onFinish: Function to be called when the process ends. Must
+                  accept two parameters - a reference to the wx
+                  frame/dialog displaying the process output, and
+                  the exit code of the application.
+    """
+
+    # Create the GUI - if modal, the easiest approach is to use a wx.Dialog
     if modal:
         frame = wx.Dialog(
             parent,
@@ -219,30 +226,69 @@ def run(name, cmd, parent, modal=True, onFinish=None, actions=None):
     else:
         frame = wx.Frame(parent, title=name)
 
-    # Default buttons 
-    actions.append(('Terminate process', termProc))
-    actions.append(('Close window',      frame.Close))
+    panel = RunPanel(frame)
 
-    panel = RunPanel(frame, actions)
+    # Create the thread which runs the child process
+    mgr = ProcessManager(cmd, parent, panel, onFinish)
 
-    # Put the command string at the top of the text control
-    panel.text.WriteText(' '.join(cmd) + '\n\n')
+    # Bind the panel control buttons so they do stuff
+    panel.closeButton.Bind(wx.EVT_BUTTON, lambda e: frame.Close())
+    panel.killButton .Bind(wx.EVT_BUTTON, lambda e: mgr.termProc())
 
-    # Run the command
-    log.debug('Running process: "{}"'.format(' '.join(cmd)))
-    proc = subp.Popen(cmd,
-                      stdout=subp.PIPE,
-                      bufsize=1,
-                      stderr=subp.STDOUT,
-                      preexec_fn=os.setsid)
-        
-    # poll the process output on a separate thread
-    outq                = queue.Queue()
-    outputThread        = thread.Thread(target=pollOutput)
-    outputThread.daemon = True
-    outputThread.start()
+    # Run the thread which runs the child process
+    mgr.start()
 
     # layout and show the window
     frame.Layout()
     if modal: frame.ShowModal()
     else:     frame.Show()
+ 
+
+def checkAndRun(name, opts, parent, cmdFunc,
+                optLabels={},
+                modal=True,
+                onFinish=None):
+    """
+    Validates the given options. If invalid, a dialog is shown, informing
+    the user about the errors. Otherwise, the tool is executed, and its
+    output shown in a dialog window. Parameters:
+    
+      - name:      Name of the tool, used in the window title
+    
+      - opts:      HasProperties object to be validated
+    
+      - parent:    wx object to be used as parent
+    
+      - cmdFunc:   Function which takes a HasProperties object, and
+                   returns a command to be executed (as a list of
+                   strings), which will be passed to the run() function.
+    
+      - optLabels: Dictionary containing property name -> label mappings.
+                   Used in the error dialog, if any options are invalid.
+    
+      - modal:     If true, the command window will be modal.
+
+      - onFinish:  Function to be called when the process ends.
+    """
+
+    errors = opts.validateAll()
+
+    if len(errors) > 0:
+
+        msg = 'There are numerous errors which need '\
+              'to be fixed before {} can be run:\n'.format(name)
+
+        for opt,error in errors:
+            
+            if opt in optLabels: name = optLabels[opt]
+            msg = msg + '\n - {}: {}'.format(opt, error)
+
+        wx.MessageDialog(
+            parent,
+            message=msg,
+            style=wx.OK | wx.ICON_ERROR).ShowModal()
+        
+    else:
+        cmd = cmdFunc(opts)
+        run(name, cmd, parent, onFinish, modal)
+
-- 
GitLab