Embedding cmd.exe into a Tkinter.Frame

Getting process output after it finishes is easy, this is not the problem being solved here, so if you want to do this you will be better served by the subprocess module included with Python. It starts getting problematic when you want to read process output during its execution.

The first problem is that file descriptors block, and you can't use the fcntl module to make them not block (because there isn't such fcntl under Windows). Instead, you have to use a combination of subprocess, process polling, msvcrt.get_osfhandle and win32pipe.PeekNamedPipe.

The second problem, and the bigger one, is when you want to read the output as it happens. If the process you want to watch happens to be a python program then this is actually simple, just run python in unbuffered mode (python -u), and you are done. If it is not, but if you are in a very good position to be able to modify its source and disable stdout buffering just because you want then, if setvbuf (or whatever else the language has for doing this) is available, things are fine again (or mostly fine). But I suspect in most situations you won't be able to solve the problem like this. This is where embedding a cmd.exe saves you, and it is actually very simple (since there is a module that does everything for us).

Bugs

There are probably many other things, but these are the ones I noted on some quick usage.

Missing features

The code

As you may notice the pyconsole module does all the job, so be sure to get it.

(The code could be much shorter)

   1 from Tkinter import Tk, Text, Frame, Scrollbar
   2 try:
   3     from ttk import Frame, Scrollbar
   4 except ImportError:
   5     pass
   6 
   7 import pyconsole
   8 
   9 class History(list):
  10     def __init__(self):
  11         self.reset_bookkepping()
  12 
  13     def reset_bookkepping(self):
  14         self._last_req = None
  15 
  16     def get_item(self):
  17         ret = ''
  18         if self:
  19             return self[self._last_req]
  20         return ret
  21 
  22     def next_relative(self):
  23         if self:
  24             if self._last_req is None:
  25                 self._last_req = 0
  26             else:
  27                 self._last_req = (self._last_req + 1) % len(self)
  28         return self.get_item()
  29 
  30     def prev_relative(self):
  31         if self:
  32             if self._last_req is None:
  33                 self._last_req = -1
  34             else:
  35                 self._last_req = -((-self._last_req + 1) % len(self))
  36             return self.get_item()
  37 
  38 
  39 class ConsoleProcessWindow(Text):
  40 
  41     def __init__(self, master=None, **kwargs):
  42         Text.__init__(self, **kwargs)
  43         self.bind('<KeyPress>', self._evt_keypress)
  44 
  45         # You can actually change this 'cmd.exe' to 'powershell.exe'
  46         # or even something like 'ping -n 20 localhost' (if you use
  47         # this one then be sure to not use console_process_end).
  48         self.console_process = pyconsole.ConsoleProcess('cmd.exe',
  49                 console_update=self._console_update,
  50                 console_process_end=self._console_process_end)
  51 
  52         self.history = History()
  53         self._complete = False
  54         self._last_line = 0
  55 
  56     def send(self, command, partial=False):
  57         """Send command to the cmd.exe."""
  58         if partial:
  59             end = '\t'
  60         else:
  61             end = '\n'
  62             self.clear_cmd_line()
  63 
  64         self.console_process.write(command + end)
  65 
  66     def clear_cmd_line(self):
  67         """Clear current text written in the command line."""
  68         right_after_ps1, line = self._right_after_ps1()
  69         self.delete(right_after_ps1, '%d.end' % line)
  70 
  71     def get_cmd_line(self):
  72         """Get current text written in the command line."""
  73         right_after_ps1, line = self._right_after_ps1()
  74         return self.get(right_after_ps1, '%d.end' % line)
  75 
  76     def set_cmd_line(self, text):
  77         """Write some text on the command line."""
  78         if text is None:
  79             return
  80         self.insert(self._right_after_ps1()[0], text)
  81 
  82 
  83     def _right_after_ps1(self):
  84         index = str(self.tag_ranges('ps1')[1])
  85         line = int(index.split('.')[0])
  86         return index, line
  87 
  88     def _evt_keypress(self, event):
  89         self._complete = False
  90 
  91         try:
  92             right_after_ps1, line = self._right_after_ps1()
  93         except IndexError:
  94             return "break"
  95 
  96         keysym = event.keysym.lower()
  97         disallow = self.compare(self.index('insert'), '<',
  98                 right_after_ps1)
  99         limit = self.compare(self.index('insert'), '==',
 100                 right_after_ps1)
 101 
 102         if keysym in ('left', 'backspace') and limit:
 103             custom_sel = self.tag_ranges('my_sel')
 104             if keysym == 'backspace' and custom_sel:
 105                 # Assuming there is just one index selected
 106                 self.delete(custom_sel[0], custom_sel[1])
 107                 self.tag_delete('my_sel')
 108             disallow = True
 109 
 110         elif keysym in ('c', 'v') and event.state & 4:
 111             # Allow Ctrl-C, Ctrl-V anywhere (take care with multiline Ctrl-V)
 112             disallow = False
 113 
 114         elif keysym == 'home':
 115             if event.state & 1:
 116                 # Perform Shift-Home
 117                 self.tag_add('my_sel', right_after_ps1, '%d.end' % line)
 118                 self.tk.call('tk::TextKeySelect', event.widget,
 119                         right_after_ps1)
 120             else:
 121                 # Unselect anything that might be selected by an earlier
 122                 # Shift-Home
 123                 self.tk.call('tk::TextSetCursor', event.widget,
 124                         right_after_ps1)
 125             self.see('%d.0' % line)
 126             disallow = True
 127 
 128         elif keysym == 'up':
 129             command = self.history.prev_relative()
 130             self.clear_cmd_line()
 131             self.set_cmd_line(command)
 132             disallow = True
 133 
 134         elif keysym == 'down':
 135             command = self.history.next_relative()
 136             self.clear_cmd_line()
 137             self.set_cmd_line(command)
 138             disallow = True
 139 
 140         #elif keysym == 'tab':
 141         #    # let the shell complete
 142         #    self._complete = True
 143         #    command = self.get_cmd_line()
 144         #    self.send(command, partial=True)
 145         #    disallow = True
 146 
 147         elif keysym == 'return':
 148             # store command in the history and send it to the console process
 149             command = self.get_cmd_line()
 150             self.history.append(command)
 151             self.history.reset_bookkepping()
 152             self.send(command)
 153             disallow = True
 154 
 155         if disallow:
 156             return "break"
 157 
 158     def _console_process_end(self):
 159         self.master.destroy()
 160 
 161     def _console_update(self, x, y, text):
 162         self.after_idle(lambda: self._update_text(x, y, text))
 163 
 164     def _update_text(self, x, y, text):
 165         y += 1
 166 
 167         for i in xrange(y - self._last_line):
 168             self.insert('%d.0' % (self._last_line + i + 1), '\n')
 169 
 170         # XXX Allow auto complete
 171 
 172         self._last_line = y
 173 
 174         self.insert('%d.%d' % (y, x), text)
 175         self.mark_set('insert', '%d.%d' % (y, x + len(text)))
 176         self.see('insert')
 177 
 178         # remove the extra newline
 179         self.delete('end - 1 char')
 180 
 181         self.tag_delete('ps1')
 182         self.tag_add('ps1',
 183                 '%d.0' % y,
 184                 '%d.%d' % (y, text.find('>') + 1))
 185 
 186         self.update_idletasks()
 187 
 188 
 189 class ConsoleFrame(Frame):
 190     def __init__(self):
 191         Frame.__init__(self)
 192 
 193         self.console = ConsoleProcessWindow(self, wrap='none')
 194         self.console.focus()
 195         hscroll = Scrollbar(orient='horizontal', command=self.console.xview)
 196         vscroll = Scrollbar(orient='vertical', command=self.console.yview)
 197         self.console.configure(
 198                 yscrollcommand=vscroll.set,
 199                 xscrollcommand=hscroll.set)
 200 
 201         self.console.grid(sticky='news')
 202         vscroll.grid(sticky='ns', row=0, column=1)
 203         hscroll.grid(sticky='ew', row=1, column=0)
 204 
 205 
 206 if __name__ == "__main__":
 207     root = Tk()
 208     frame = ConsoleFrame()
 209     frame.grid()
 210     root.grid_columnconfigure(0, weight=1)
 211     root.grid_rowconfigure(0, weight=1)
 212     root.mainloop()

And this is how it looks here (using ttk)

cmdtk_ping.png

tkinter: CmdTk (last edited 2010-07-26 11:59:12 by localhost)