How to generate python typing information for a library that supports sync and async interfaces?

Viewed 100

I have a problem with both typing and type-hints in Python. I prepared an (executable) example that showcases the problem that I am facing for a library that should expose both, a synchronous and asynchronous interface. It is obviously a simplified example, cutting out the noise and focussing on the issue at hand:

import asyncio
import time

class CommonFunctions:
    # I want to use this as the interface defintion
    # for my async and sync implementations below
    
    def do_something(self, i: int, text: str = "hello world") -> str:
        return text * i
    
    def do_something_else(self, i: int, j: int) -> str:
        return i * j

    # Imagine more functions here... This is 
    # the library and where most development happens.


class SyncLib(CommonFunctions):
    def __complex_operation(self):
        # This is a standin for sync-api calls
        time.sleep(1)
    
    def do_something(self, *args, **kwargs):
        self.__complex_operation()
        return super().do_something(*args, **kwargs)
    
    def do_something_else(self, *args, **kwargs):
        self.__complex_operation()
        return super().do_something_else(*args, **kwargs)


class AsyncLib(CommonFunctions):
    async def __complex_async_operation(self):
        # This is a standin for async-api calls
        await asyncio.sleep(1)
    
    async def do_something(self, *args, **kwargs):
        await self.__complex_async_operation()
        return super().do_something(*args, **kwargs)
    
    async def do_something_else(self, *args, **kwargs):
        await self.__complex_async_operation()
        return super().do_something_else(*args, **kwargs)


print("User using the sync-lib...")
sync_lib = SyncLib()
print(sync_lib.do_something(5))
print(sync_lib.do_something_else(5, 6))

print("User using the async-lib...")
async_lib = AsyncLib()
async def async_main():
    print(await async_lib.do_something(5))
    print(await async_lib.do_something_else(5, 6))
asyncio.get_event_loop().run_until_complete(async_main())

The classes SyncLib and AsyncLib are the interfaces that will be used by users of the library. For different usecases, the library should have a synchronous and asynchronous interface provided by the classes SyncLib and AsyncLib (for the interested: the actual library is based around the httpx sync/async clients, here symbolized by time.sleep()/asyncio.sleep() calls).

Most of the library actually is synchronous, but for a small portion it depends on the calls to the underlying library, here symbolized by the __complex_operation calls.

The problem

For users of the library, the problem with this design is that the typing information and type-hints for the AsyncLib (and the SyncLib) is all out of whack. All public member functions look like they accept *args, **kwargs arguments. This, I can easily fix by adding a Protocol for the CommonFunctions-class that exactly defines the available arguments and argument types for each function.

However, such a protocol would then only define the synchronous calls. The asynchronous calls would need a second declaration of their own Protocol that just would be a copy of the original sychronous Protocol with all function declarations being marked as async.

All of a sudden, should a parameter need to be added to a function, a developer needs to maintain three interfaces: The CommonFunctions, the synchronous protocol, and the asynchronous protocol (plus the docstrings).

There must be a better way?! My idea and question: Is there a way to generate an async-Protocol from the sync-Protocol in such a way that the typing library and/or type-hinting tools in IDEs can undestand them?

What I tried...

Well, I read a bunch of PEPs and such, but actually did not find a lot for converting a set of signatures from one Protocol to another. Even changing the signature of a single function from sync to async while preserving the argument lists from the sync function in a generic way seems complicated.

For the single-function case I was thinking of something like a decorator that could do something like this:

import typing

Result = typing.TypeVar("Result")
Args = typing.TypeVar("Args")

def typing_sync_to_async(func: typing.Callable[Args, Result]) -> typting.Callable[Result, typing.Coroutine[typing.Any, typing.Any, Result]]:
    return func

@typing_sync_to_async
def sync_example(x: int) -> str:
    pass

But well, this does not work because

  1. The argument list cannot be captures like this
  2. This does not cover **kwargs as I found out, because Callable does not implement a way to define types for keyword arguments, it seems.

To me, it seems that the only way to do this is to define those Protocols for hundreds of functions manually (async and sync) and that I cannot rely on an existing feature that would provide this. Is that correct? Maybe, if I learn more about the typing library and contribute something myself?

0 Answers
Related