I’ve run into an interesting problem, but before I get to it, I think it would make sense to review my entire architecture, in case there is a fundamentally better approach that still satisfies all my design requirements.
I’m trying to build a Python-scriptable GUI application that does mostly I/O-bound work. Qt seems like the most obvious choice for the GUI, as it’s mature, cross-platform and has Python bindings. The users of the application will mostly interact with the GUI, but they will also need to write small test scripts in Python. However, the users are mostly hardware engineers or embedded software engineers who don’t have a deep knowledge of Python and may not have much experience with concurrent programming.
At first, I thought of using gevent
to handle concurrency, since it would “hide” much of the concurrency. But lately I’ve begun to think that’s a bad choice. In this particular case, I think labeling each possible suspension point with await
is actually a great feature to help teach concurrency. Moreover, the scripts will likely make heavy use of timeouts and cancellation. For that reason, trio
seems like a natural choice, since it’s structured concurrency and cancellation semantics are easy to reason about.
I know I could use trio
in guest-mode to run within the Qt event loop, but in my particular case, I don’t think that’s a great idea. If a user were to accidentally block, I don’t want that to lock up the GUI as well. And that’s a real possibility, since we frequently use Opal Kelly FPGA boards for testing, and many of their Python bindings block while holding the GIL (it’s a frustrating limitation).
For that reason, I decided to run trio
in a separate thread. I think that’s a reasonable choice, because I already want to abstract away any GUI interaction within user scripts, so it’s fairly simple to handle thread crossings there.
Finally, I also want to provide a simple GUI console with a Python REPL, since that has proven very useful with our current workflow. I considered the Qt console for Jupyter, but I don’t think it’s a good fit for my use case. I don’t need all of its features. I don’t want to add a heavy dependency. And I don’t want the REPL to run in a separate process. For that reason, I started looking at pyqtconsole.
Ideally, I want the REPL experience to match the scripting experience as closely as possible, including interaction with trio
channels. But to do that, the REPL needs to be able to execute await
statements. It looks like the Jupyter console can do that, but pyqtconsole
cannot. I started to investigate what it would take to add this capability, and that’s when I ran into a problem.
This StackOverflow answer shows that it is fairly straightforward to eval
top-level await
statements with Python 3.8+ using the ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
flag. First, you compile
your string to a code object. Then, you eval
that code. If the code is synchronous, the code is run immediately, and eval
returns None
. But if the code has any await
statements, eval
will instead create and return a coroutine object.
This all seems great at first. But I quickly ran into trouble when trying to implement it. I’m using a third thread to run the pyqtconsole
code. That thread is responsible for compiling strings to code objects. Then, it submits the code to be run with trio.from_thread.run_sync(eval, code)
. If the code does not contain any await statements, then this approach works fine. However, if the code does contain await
statements, then eval
returns a coroutine object and trio
thinks I’ve accidentally given an async
function to run_sync
.
Alternatively, if the code object contains await
statements and I use trio.from_thread.run(eval, code)
, it works great. But if it does not, then trio
tells me I’ve accidentally given it synchronous function.
The fundamental problem is that eval
is simultaneously a synchronous function and an asynchronous function, depending on the code object you give it. That would normally be fine, but I can’t seem to figure out how to tell whether the code object is synchronous or asynchronous without actually running it. Thus, I’m stuck with Schrödinger’s colored function.
Does anyone know how to resolve this dilemma? I don’t see anything in the trio
docs that would help me.
And as a separate question, I’ve started to wonder how I will handle cancellation of await
statements in the REPL. Ideally, I would like the user to be able to interrupt a call to await trio.sleep(1000)
with CTRL+C. Perhaps I need to run REPL code in an implicit cancel scope that can be canceled by the GUI? That seems reasonable.
Or maybe trying to run an async
REPL is just a fundamentally bad idea? Maybe I should just force users to use send_nowait
and recv_nowait
at the console? That’s an option, but it’s less than ideal from my perspective.