Schrödinger's colored function? How to `eval` the output of `compile` with the `PyCF_ALLOW_TOP_LEVEL_AWAIT` flag

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.

Perhaps I can solve this problem and the cancellation problem with the same solution.

I could take the user code and transform it to be included within an async function. Then, I could eval the declaration of that function in the console thread before running it on the trio thread, this time unconditionally with trio.from_thread.run. That would also let me define an implicit cancel scope within the async function.

I’m not quite sure how to handle locals and globals for this solution, but I think it should be feasible. I’ll have to play with it.