Search code examples
pythonmultithreadingpython-3.xwxpythonwxwidgets

How can I create a non-blocking GUI with cmd2 and wxPython?


How can I create non-blocking wxPython GUI windows from a cmd2 interpreter command even though both the interpreter and wx.App must be run on the main thread?


I am creating a command-line interpreter in Python 3.x using the cmd2 module. One command in my interpreter will allow me to create a "non-blocking" wxPython GUI window with a countdown timer that runs in a separate thread. It needs to run separately from the main interpreter thread because otherwise it will prevent entering additional commands while the timer runs.

When I try to use the wxTimer object to allow my GUI to update its progress bar and check the remaining time, I get the following error:

wx._core.wxAssertionError: C++ assertion "wxThread::IsMain()" failed at ..\..\src\common\timerimpl.cpp(60) in wxTimerImpl::Start(): timer can only be started from the main thread

Any attempt to start the timer breaks the code. For example, the following simple code does not work:

import wx
import threading
import cmd2

class TimerFrame(wx.Frame):
    def __init__(self, time):
        super().__init__(None, title=str(time), size=(300, 300))
        self.InitUI()

    def InitUI(self):
        self.timer = wx.Timer(self, 1)
        self.timer.Start(100)
        self.Bind(wx.EVT_TIMER, self.OnTimer, id=1)
        self.Show()

    def OnTimer(self, event):
        print("Updating")

class Interpreter(cmd2.Cmd):
    def __init__(self):
        super().__init__()
        self.app = wx.App()

    def do_timer(self, _):
        threading.Thread(target=self.createTimer, args=(self.app,)).start()

    def createTimer(self, app):
        TimerFrame("Window Title")
        app.MainLoop()

if __name__ == "__main__":
    Interpreter().cmdloop()

Note that commenting out the line containing timer.Start(100) allows windows to be created in separate threads successfully, but they lack the necessary Timer functionality.


Other things I have tried that do not work:

  • Creating the wx.App in the new thread instead of passing it (results in the same error but for the MainLoop rather than Timer)
  • Creating the wx.App in the main thread and running its MainLoop there (blocks the command line from accepting further commands)
  • Running the interpreter command loop in a separate thread (complains that the cmdloop must be run from the main thread)

Solution

  • The docs have a section on Integrating cmd2 with event loops:

    Many Python concurrency libraries involve or require an event loop which they are in control of such as asyncio, gevent, Twisted, etc.

    While this is specifically talking about networking-focused event loops, it's actually the same issue with GUI event loops like wx's.

    The tl;dr is that instead of calling cmdloop(), blocking the entire main thread until the interpreter exits, you just call preloop(), and then you repeatedly call onecmd() (or onecmd_plus_hooks() or runcmd_plus_hooks(), as appropriate) from a wx callback.

    In other words, you drive the cmd2 event loop from the wx one, so the wx loop can just take over the thread.


    wx also has a way of driving its event loop manually. See wx.EventLoopBase and related classes. (There's probably some good example code out there similar to the code in the cmd2 docs, but you'd have to search for it.) The idea is pretty much the same: instead of running the wx loop and blocking the thread forever, you manually create a wx.EventLoop and wx.EventLoopActivator, and then you repeatedly call while loop.Pending(): loop.Dispatch() (and probably app.ProcessIdle()) from the cmd2 event loop.

    In other words, you drive the wx event loop from the cmd2 one, so the cmd2 loop can just take over the thread.


    If neither of those work for you, you can probably use multiprocessing instead of threading. That way, both event loops are running in the main thread—but one of them is just running in the main thread of a child process.

    That can be a problem for GUI apps, so putting wx in the child process may not work (or, worse, may work on some platforms/setups but not others, or may work but mysteriously fail every so often…), but cmd2 presumably only needs to see stdin/stdout/tty, so it probably will work.