Errors placing wx commands into threads via CallAfter

I am working on a prototype of GUI testing/demo code and I am struggling to execute actions that will be done on modal windows. I do understand a few things: to communicate with a modal window, the code that does that must be in a separate thread, so it starts concurrently with the modal dialog. Also, wx commands should not be executed directly in that separate thread; they should be placed into a CallAfter() so that they get called by the main event loop.

Clearly from how my code is failing there is more I need to know. I’d really love some help from anyone who better understands how to properly write multi-threaded code that properly interacts with wx.

My test is for code to ā€œmanageā€ a stock dialog from my application (here isolated in file dlg.py) by clicking on the ā€œSet Allā€ button and to then close that dialog via the ā€œOKā€ button. The dialog and the concurrent tread are started in response to clicking on the ā€˜Open modal window’ button in the main frame.

I have tried three approaches to doing this and each is failing in a different way.

  1. In test1.py, the active part of the code is
    threading.Thread(target=doInWindow,
        args=('Select atoms for action',['Set All','OK'])).start()
    OpenDialog(choices)
    print('selected=',dlgResults['selected'])

where doInWindow calls invokeButton(), which uses this to invoke each of the two buttons:

    buttonevt = wx.PyCommandEvent(wx.EVT_BUTTON.typeId, btn.GetId())
    frame.ProcessEvent(buttonevt)

This test script runs without errors but fails to work properly. The problem in test1.py is that while the ā€œOKā€ button gets activated by the above, the previous call that is supposed to activate the ā€œSet Allā€ button does not. FWIW, all buttons in the dialog are owned by the main wx.Dialog frame (no panels, etc.) and I’m pretty sure btn.GetId() is giving me the right Id for the ā€œSet Allā€ button.

  1. In test2.py, the only change is that instead of using PyCommandEvent/ProcessEvent to generate an event, I use code like this to use the mouse to invoke the button:
    pos = frame.ClientToScreen(btn.GetPosition() + btn.GetSize()/2)
    sim = wx.UIActionSimulator()
    sim.MouseMove(pos.x,pos.y)
    time.sleep(0.1)
    sim.MouseClick(wx.MOUSE_BTN_LEFT)
    time.sleep(0.1)

This does seem to properly invoke both buttons, but fails at the .ShowModal() with a ā€œwx._core.wxAssertionError: C++ assertion ā€œhandled == 1ā€ failedā€¦ā€ error characteristic of a wx command being called from a thread – but as far as I am aware, I’m not doing that. There are effectively two upper level routines in openSelect (which is called by an event handler and thus is part of the event loop). OpenDialog is called directly by openSelect, so that is not in a thread and doInWindow, is called in a thread, but that uses only CallAfter calls:

def doInWindow(winname,bnlList):
    time.sleep(0.1) # wait for window to open
    wx.CallAfter(getButtons,winname,bnlList)
    count = waitForDone(asyncResults)
    buttons,frame = asyncResults['buttons'],asyncResults['frame']
    for btxt,b in zip(bnlList,buttons):
        wx.CallAfter(invokeButton2,frame,b)
        count = waitForDone(asyncResults)
        print('invoke done',btxt,b,count)

so I don’t see what I’m doing wrong. (waitForDone is plain Python that has some time.sleep() calls, but does not interact with wx.)

  1. My understanding is that since the routine to be run by my callback (openSelect) will take some time to complete, it really should not be run directly by the event loop, but rather I should invoke that in a thread. So in test3.py I place openSelect into a thread and revised openSelect so that it uses CallAfter for all routines that use wx:
    threading.Thread(target=doInWindow,
        args=('Select atoms for action',['Set All','OK'])).start()
    wx.CallAfter(OpenDialog,choices)
    time.sleep(0.1)
    while len(dlgResults) == 0: time.sleep(0.1)

This also fails with the same wx._core.wxAssertionError error as before.

FWIW, I’m developing this on a Mac running Python 3.13 and wx 4.2.3

dlg.py (12.2 KB)
test1.py (4.1 KB)
test2.py (4.2 KB)
test3.py (4.6 KB)

Thanks to everyone who has looked at this. I decided to turn Claude Sonnet 3.5 on this problem (via VS Code), starting with code containing the routines in test1.py and test2.py. When asked to fix the problem with PyCommandEvent/ProcessEvent, it changed my code to call the UIActionSimulator routine, saying that it is better. (At least in some ways it is not, as the UIActionSimulator routine fails if the user inadvertently moves the mouse at the wrong time), but then I got the error I reported previously. Claude Sonnet offered a change to fix that error, but that revised code failed too. After ~14 more cycles of error message reporting followed by changes, Claude Sonnet did actually come up with code that runs properly (uploaded as ClaudeFixed.py. (Note that dlg.py is needed for all the examples.)

I think the take home message is that wx.Dialog.ShowModal causes problems with this sort of usage. It can’t be run inside a thread because it interacts with wx and it can’t be run inside a wx.CallAfter, as that holds up the event loop. This might not be true on platforms other than MacOS.

The code after 16 sets of AI changes was rather messy and at least to me inelegant. After some human-powered cleanup where I also moved what will go into the code library into wxsim.py, I had rework6.py, which also works.

In some ways that version would be well set to use a generator to provide the automation steps, but I’d prefer to have that in one big messy routine, so one further set of revisions and cleanups gives me 2thead.py, where the automation is now done in a thread.

I still have one significant unanswered question: why does my code in test1.py (or for that matter any of the later versions) fail to access the ā€œSet Allā€ button if I use PyCommandEvent/ProcessEvent in invokeButton? I have learned a few things:

  • The PyCommandEvent/ProcessEvent combo will work with buttons with wx.ID_OK and wx.ID_CANCEL,
  • but will not work with wx.ID_ANY or even wx.ID_YES
  • It is not something special about where the button is or what it is labeled, only the id seems to matter.
  • I presume that this has something to do with the additional bindings that Ok and Cancel have that close windows. If so, this is probably not a useful direction to follow for my application.

rework6.py (2.4 KB)
2thread.py (3.1 KB)
wxsim.py (3.2 KB)
ClaudeFixed.py (6.8 KB)

1 Like