Skip to content

Conversation

@o-santi
Copy link
Contributor

@o-santi o-santi commented Dec 17, 2025

What kind of change does this PR introduce?

Preview for the sync vs async rewrite proposal, using functions as the first example. The idea for this rewrite is that we can refactor the methods in such a way to only write the core logic once, and derive both sync and async implementations out of the same client.

To understand the rewrite, we should study the following hypothetical method, and its async counterpart:

class SyncSupabaseClient:
    def list_something(self, **options) -> Something:
        http_request = request_from_options(options)
        response = self.client.send(http_request) # <<<<<<<
        return Something.from_response(response)

class AsyncSupabaseClient:
    async def list_something(self, **options) -> Something:
        http_request = request_from_options(options)
        response = await self.client.send(http_request) # <<<<<<<
        return Something.from_response(response)

We see that the only differences between the sync and async implementation is the async and await keywords. In this PR, I propose to rewrite these two into a single implementation, by making removing the IO out of the methods:

class SupabaseClient:
    def list_something(self, **options) -> ServerEndpoint[Something]:
        return ServerEndpoint(
            request=request_from_options(options)
            on_success=Something.from_response(response)
        )

class SyncExecutor:
    def communicate(self, endpoint: ServerEndpoint[Something]) -> Something:
      response = self.client.send(endpoint.request)
      return endpoint.on_success(response)

class AsyncExecutor:
    async def communicate(self, endpoint: ServerEndpoint[Something]) -> Something:
        response = await self.client.send(endpoint.request)
        return endpoint.on_success(response)

With this, we can write the list_something method only once, as a description for how to send a request and how to parse a response, and execute it both ways, either sync or async.

However, in order to execute it in this way, we'd need to externally call the client in the executor:

AsyncExecutor().communicate(client.list_sommething())
SyncExecutor().communicate(client.list_something())

This would work, but would be cumbersome and tiring to use. Instead, we can a store an executor inside the client, and use a decorator to blindly call communicate on the endpoint. Regardless of which kind of IO, this will return the correct object, and will work correctly.

class SupabaseClient:
    @http_endpoint
    def list_something(self, **options) -> ServerEndpoint[Something]:
        return ServerEndpoint(
            request=request_from_options(options)
            on_success=Something.from_response(response)
        )

def http_endpoint(method):
    def inner(client, **options):
        endpoint = client.method(options)
        return client.executor.communicate(endpoint)
    return inner

In order to derive the sync and async clients, all we'd need to do is pass the executor we want:

sync_client = SupabaseClient(SyncExecutor())
async_client = SupabaseClient(AsyncExecutor())
sync_client.list_something() # this is sync
# Something
await async_client.list_something() # this is async, and when awaited returns the same thing
# Awaitable[Something]

However, how do we convince mypy that this is the case?

Typing

In order to show mypy that the type of list_something() depends on the executor kind, we need to make add the executor as a generic type parameter of SupabaseClient, and use @overloads on the http_endpoint to decide whether it should be sync or async:

Params = ParamSpec("Params")
Executor = TypeVar("Executor", SyncExecutor, AsyncExecutor)

class HasExecutor(Protocol[Executor]):
    executor: Executor
    base_url: URL

@dataclass
class http_endpoint(Generic[Params, Success]):
    method: Callable[Concatenate[Any, Params], ServerEndpoint[Success]]

    @overload
    def __get__(
        self, obj: HasExecutor[SyncExecutor], objtype: type | None = None
    ) -> Callable[Params, Success]: ...

    @overload
    def __get__(
        self, obj: HasExecutor[AsyncExecutor], objtype: type | None = None
    ) -> Callable[Params, Awaitable[Success]]: ...

    def __get__(
        self, obj: HasExecutor[Executor], objtype: type | None = None
    ) -> Callable[Params, Success | Awaitable[Success]]:
        def bound_method(
            *args: Params.args, **kwargs: Params.kwargs
        ) -> Success | Awaitable[Success]:
            endpoint = self.method(obj, *args, **kwargs)
            return obj.executor.communicate(obj.base_url, endpoint)

        return bound_method

Even though this seems complex, its not. Because the typing is not easy to write, I rewrote the http_endpoint decorator to be a dataclass class, so that I could annotate it more easily. Thus, you can interpret it in the following way:

  1. If we don't know which executor is being used, all we can say is that it returns Success | Awaitable[Sucess]
  2. If we know that the obj that is calling this method is a client with SyncExecutor, the return is the sync version, Success
  3. If we know that the obj that is calling this method is a client with AsyncExecutor, the return is the sync version, Awaitable[Sucess]

The ParamSpec part is used to be generic over all the possible args and kwargs of any method passed in. Similarly, the HasExecutor protocol is used to be generic over all the possible clients that can be passed in, so that we can share this decorator over multiple clients -- eg. SupabaseVectorClient and GoTrueClient.

With this, mypy is satisfied, and http_endpoint type checks for both the sync and async clients. Using the functions example in this PR:

sync_client = SyncFunctionsClient("", {})
reveal_type(sync_client.invoke("my-function"))
# Union[JSON, bytes]
async_client = AsyncFunctionsClient("", {})
reveal_type(async_client.invoke("my-function"))
# Awaitable[Union[JSON, bytes]]

[In fact, this is a form of Higher Kinded Types in Python! HasExecutor is a family of functor types. If you're interested, you can read more here: https://sobolevn.me/2020/10/higher-kinded-types-in-python].

this way, we only need to write the whole client once, and we can
derive both IO implementations from a single source of truth, reducing
the amount of code needed for each package in half.
@o-santi o-santi force-pushed the sync-async-refactor branch from ee7748e to 49f0014 Compare December 17, 2025 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants