I recently published ZyncIO, a small pure-Python library that aims to solve the code-duplication problem for libraries that wish to provide both a sync and a async interface to users.
The core idea is that you can write everything as coroutines, which internally branch into sync/async code paths only where necessary.
A combination of decorators and marker mixin classes perform the magic that turns this “multi-colored” code into distinct, fully typed, sync and async interfaces.
I haven’t performed a rigorous performance test yet, but from some simple tests that I ran, the overhead appears to be insignificant once there’s actual IO being performed (which would be the case for any real-world usage).
This limitation is a true non-starter for many large codebases.
Awaiting asyncio.gather will fail, as it’s a function that returns a future subclass.
Use of asyncio.Event, asyncio.Lock, asyncio.Queue, & asyncio.Semaphore will fail (these all wait on futures internally)
There’s definitely more than that just in the standard library, but it’s a serious limitation.
The idea of the implementation wrapping both sync and async paths also has some impact on performance and introspection outcomes, though it’s reasonable to ignore the introspection outcomes as a documented caveat.
The problem is, you still HAVE the overhead, even if you’re hiding a lot of it. In the example you posted, you show a get_status() that does an API call and then extracts information from it. What I want to know is, with these sorts of intermediate functions, how much extra overhead is there? This overhead is extremely low with synchronous and threaded code, as it’s just basic function calls.
The overhead is a number of extra function calls (__get__ → bound method construction (including some attribute lookups) → __call__ → mode lookup (including isinstance checks)).
The actual function call overhead is significant, but it all comes down to what the actual function does. If you’re already optimizing your call stack depth, I definitely wouldn’t use this approach.
I see ZyncIO as being primarily useful for projects like web API clients (as opposed to, say, HTTP clients), and in general projects where convenience is a higher priority than performance.
The main impetus for this project was to add async support to a large project that sees a lot of interactive (i.e. on the Python console) use. Maintaining the old sync interface was imperative, but we’re trying to integrate the project into a larger, async project, and running everything in threads wasn’t a very appealing option.
Ah, then that raises a slightly different question: How does this handle re-entrancy and crossing between async functions decorated by your library and those that aren’t?
I’ll take a look myself in more depth later, but that clarity on the intention of the limitation does make it slightly more likely this can be useful for some projects than how I read it initially, and makes it more interesting as a concept.
I have long held that for projects unwilling to maintain both paths manually, that I think making the primary path async when wanting to support both equally is actually sufficient, so long as the ecosystem adopts the knowledge of how to schedule onto an event loop in another thread (effectively turning the async libraries in use into something that runs on a single worker thread for sync use, and the event loop is merely an implementation detail at that point): I provide a wrapping of this already here; However, I don’t think it helps in your case where the sync path both came first and is the path viewed as imperative to maintain. It’s also less helpful for interactive use, which is your specific need, as library wrappings of this will want to use context managers to ensure proper cleanup at application close.
It looks like this is already maintaining two paths now that I correctly understand your intent, just merging them into one. I don’t particularly like this for performance reasons, but I wonder if this becomes a more viable way to combine with the usage of an AST transform during a build step to get two distinct paths in an installed library, rather than what people have previously experimented with to generate one path from the other.
Not in this case (we use IPython, which has top-level async support anyway). Needing to type out await over and over would be a very significant ergonomics hit.
I’m not sure I understand your question. The interaction should be transparent; once a method is bound to an instance, it’s either a sync callable or an async callable, and can be used anywhere a normal callable of that type can be used.
Prior messages in this thread were prior to doing that, as I like to review ideas as concepts before reviewing the specifics of the implementation of the concept. It avoids relying on implementation details that the concept doesn’t require, reading something the implementation does that might be a bug as the intended behavior, and many more such issues.
The examples you gave, along with referring to this as “multi-colored functions” gave the impression that the intent was that the resulting functions are usable as either sync or async functions: This isn’t how it is implemented, so a few of my questions aren’t relevant unless you intend to work toward them being usable directly in either context without the extra layer of wrapping your library currently does via mixin or removing the need to call via library function for non-class based use.
The result currently is always something that can’t be used directly. In class based mode, you have to then use a mixin that binds it to one of the operation modes. when wrapping functions rather than classmethods/properties, you have to call it using a library function.
With that in mind, I don’t think this is better than a Sans-IO approach to design.
There are definitely a lot of situations where Sans-IO is the better choice, particularly when implementing protocols where most logic is internal, and “surface area” is minimal. If, on the other hand, your library is primarily a public API built on top of a protocol (i.e. REST client over HTTP), you would still end up duplicating a large portion of your code (all of the user-facing endpoints). That’s where, in my (humble but very biased) opinion, ZyncIO’s approach is superior, especially if you want something that type checkers can understand[1].
i.e. not dynamically generated clients, which are otherwise another valid solution ↩︎