Proposal for a new way to overload methods by arguments, and whether it is synchronous or asynchronous

Inspired by the `singledispatch` method in the `functools` module, I thought it would be very useful to have a method that can also overload methods or functions based on whether they are synchronous or asynchronous.

This can greatly simplify the use of libraries containing both synchronous and asynchronous code that perform the same task but use different dependencies depending on the context. It can also facilitate the migration of synchronous code to asynchronous code.

example of use

import asyncio


class Example:
    @coroutinedispatch
    def process(self, x: int) -> str:
        return f"Processing integer: {x}"

    @process.register
    async def _(self, x: int) -> str:
        await asyncio.sleep(0.1)
        return f"Processing integer asynchronously: {x}"

    @process.register
    def _(self, x: str) -> str:
        return f"Processing string: {x}"

    @process.register
    async def _(self, x: str) -> str:
        await asyncio.sleep(0.1)
        return f"Processing string asynchronously: {x}"


example = Example()
print(example.process(42))  # Processing integer: 42
print(example.process("hello"))  # Processing string: hello


async def main():
    print(await example.process(42))  # Processing integer asynchronously: 42
    print(await example.process("hello"))  # Processing string asynchronously: hello

I had thought that an example code might look something like this

import inspect
import asyncio
from typing import Any, Callable, get_type_hints, get_origin
from functools import lru_cache


class coroutinedispatch:
    def __init__(self, func: Callable):
        self._sync_methods = {}
        self._async_methods = {}
        self._name = func.__name__

        arg_types = self.get_arg_types(func)

        if inspect.iscoroutinefunction(func):
            self._async_methods[arg_types] = func
        else:
            self._sync_methods[arg_types] = func

    def get_arg_types(self, func: Callable) -> tuple:
        try:
            hints = get_type_hints(func)
            sig = inspect.signature(func)
            params = list(sig.parameters.values())

            if params and params[0].name in ("self", "cls"):
                params = params[1:]

            return tuple(hints.get(p.name, Any) for p in params)
        except Exception:
            return ()

    @lru_cache(maxsize=128)
    def match_types(self, provided_args: tuple, expected_types: tuple) -> bool:
        if len(provided_args) != len(expected_types):
            return False

        for arg, expected in zip(provided_args, expected_types):
            if expected is Any:
                continue

            origin = get_origin(expected)
            if origin is not None:
                expected = origin

            if not isinstance(arg, expected):
                return False

        return True

    @lru_cache(maxsize=128)
    def _find_matching_method(self, is_async: bool, *args: Any) -> Callable:
        """Find the method that matches the argument types."""
        methods = self._async_methods if is_async else self._sync_methods

        # Search for exact match
        for arg_types, method in methods.items():
            if self.match_types(args, arg_types):
                return method

        # If no match, raise descriptive error
        arg_type_names = tuple(type(arg).__name__ for arg in args)
        context = "async" if is_async else "sync"
        available = list(methods.keys())

        raise TypeError(
            f"No matching {context} method '{self._name}' found for "
            f"arguments: {arg_type_names}. Available: {available}"
        )

    def register(self, func: Callable) -> Callable:
        """Register a new method overload."""
        arg_types = self.get_arg_types(func)

        if inspect.iscoroutinefunction(func):
            self._async_methods[arg_types] = func
        else:
            self._sync_methods[arg_types] = func

        return self

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        try:
            asyncio.get_event_loop()
            is_async_context = True
        except RuntimeError:
            is_async_context = False
            pass

        # Exclude 'self' from arguments if present
        check_args = args[1:] if args and hasattr(args[0], self._name) else args

        try:
            method = self._find_matching_method(is_async_context, *check_args)
            result = method(*args, **kwargs)

            return result
        except TypeError:
            # If no async/sync method, try the other
            try:
                is_async_context = not is_async_context
                method = self._find_matching_method(is_async_context, *check_args)
                result = method(*args, **kwargs)

                return result
            except TypeError:
                raise

    def __get__(self, obj, objtype=None):
        """Support for bound methods"""
        if obj is None:
            return self

        import functools

        return functools.partial(self.__call__, obj)

I would like to know your opinions and be able to discuss this idea.

A problem with this approach is the way the “async context” detection works.

This is something that a few libraries do (Django being a famous example), and it causes all sorts of headaches. The issue is that simply checking for “async artifacts” (in this case a running event loop) to infer if something is being called within an “async context”, assumes that the caller explicitly set up this event loop or is even aware about it, which isn’t always the case. One example of this would be libraries that implicitly set up event loops behind the scenes, a notable example of this being fsspec.

In practice this means that you can run into a situation where someone is using library a and library b in their code. a exposes a coroutinedispatch function, b implicitly sets up an event loop at some point somewhere, and then the user of library a now suddenly has a coroutine at hand and doesn’t know why.

def do_something():
 value = coro_dispatch_fn() # returns an 'int' here
 files = fsspec.filesystem("http").ls()
 value = coro_dispatch_fn() # now returns a 'Coroutine[int]'

I think the return type of a function changing because of a side effect somewhere else is quite confusing and a potential major source of all sorts of bugs.

Another aspect to consider would be that this is impossible to type-check. A static type checker can infer if something is guaranteed to occur within an async context (e.g. it’s being called within an async function), but it cannot infer if something is guaranteed to not be called within an async context, at least when “async context” is being used as in your proposal.

3 Likes

Good morning, thank you so much for taking the time to respond to my proposal.

There are different ways to determine whether we are in an asynchronous context or not. For example, we can also do this to determine if we are in a synchronous or asynchronous context:

CO_COROUTINE = getattr(inspect, 'CO_COROUTINE', 0x0080)

def _called_from_async(depth: int = 2) -> bool:
    try:
        frame = sys._getframe(depth)
        return bool(frame.f_code.co_flags & CO_COROUTINE)

    except (ValueError, AttributeError):
        return False

Although I’ve tested the code you suggested and it works correctly, I’m seeing that the problem might occur when we’re in an asynchronous function calling another synchronous one. In that case, it can produce a false positive result indicating that it’s being executed within an asynchronous function.

Through testing, sys._getframe works correctly and is slightly faster (from 2µs with asyncio.get_event_loop to 1.27µs with sys._getframe).

Example of coroutinedispatch with sys._getframe:

import inspect
import asyncio
import sys
from typing import Any, Callable, get_type_hints, get_origin
from functools import lru_cache


class coroutinedispatch:
    CO_COROUTINE = getattr(inspect, "CO_COROUTINE", 0x0080)

    def __init__(self, func: Callable):
        self._sync_methods = {}
        self._async_methods = {}
        self._name = func.__name__

        arg_types = self.get_arg_types(func)

        if inspect.iscoroutinefunction(func):
            self._async_methods[arg_types] = func
        else:
            self._sync_methods[arg_types] = func

    def _called_from_async(self, depth: int = 2) -> bool:
        """Detect if caller is inside an async def."""
        try:
            frame = sys._getframe(depth)
            return bool(frame.f_code.co_flags & self.CO_COROUTINE)
        except (ValueError, AttributeError):
            return False

    def get_arg_types(self, func: Callable) -> tuple:
        try:
            hints = get_type_hints(func)
            sig = inspect.signature(func)
            params = list(sig.parameters.values())

            if params and params[0].name in ("self", "cls"):
                params = params[1:]

            return tuple(hints.get(p.name, Any) for p in params)
        except Exception:
            return ()

    @lru_cache(maxsize=128)
    def match_types(self, provided_args: tuple, expected_types: tuple) -> bool:
        if len(provided_args) != len(expected_types):
            return False

        for arg, expected in zip(provided_args, expected_types):
            if expected is Any:
                continue

            origin = get_origin(expected)
            if origin is not None:
                expected = origin

            if not isinstance(arg, expected):
                return False

        return True

    @lru_cache(maxsize=128)
    def _find_matching_method(self, is_async: bool, *args: Any) -> Callable:
        """Find the method that matches the argument types."""
        methods = self._async_methods if is_async else self._sync_methods

        # Search for exact match
        for arg_types, method in methods.items():
            if self.match_types(args, arg_types):
                return method

        # If no match, raise descriptive error
        arg_type_names = tuple(type(arg).__name__ for arg in args)
        context = "async" if is_async else "sync"
        available = list(methods.keys())

        raise TypeError(
            f"No matching {context} method '{self._name}' found for "
            f"arguments: {arg_type_names}. Available: {available}"
        )

    def register(self, func: Callable) -> Callable:
        """Register a new method overload."""
        arg_types = self.get_arg_types(func)

        if inspect.iscoroutinefunction(func):
            self._async_methods[arg_types] = func
        else:
            self._sync_methods[arg_types] = func

        return self

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        is_async_context = self._called_from_async()

        # Exclude 'self' from arguments if present
        check_args = args[1:] if args and hasattr(args[0], self._name) else args

        try:
            method = self._find_matching_method(is_async_context, *check_args)
            result = method(*args, **kwargs)

            return result
        except TypeError:
            # If no async/sync method, try the other
            try:
                is_async_context = not is_async_context
                method = self._find_matching_method(is_async_context, *check_args)
                result = method(*args, **kwargs)

                return result
            except TypeError:
                raise

    def __get__(self, obj, objtype=None):
        """Support for bound methods"""
        if obj is None:
            return self

        import functools

        return functools.partial(self.__call__, obj)

I look forward to any further suggestions or problems you might encounter.

But why detect the state we are in? User already needs to state explicitly, what they want (just calling or await-ing).

I get noise reduction, but user can explicitly chose what they want. So, how is runtime detection better, then explicit naming?

My proposal aims to simplify the use of any library/framework for the user. For example, currently in Django, if you want to execute a sync/async query, you run the following code.

User.objects.get(username="example") # Sync
await User.objects.aget(username="example") # Async

And with these changes, the method that needs to be executed would be managed internally.

def sync_function():
    User.objects.get(username="example") # Sync

async def async_function():
    await User.objects.get(username="example") # Async

This proposal could halve the API documentation for libraries that support both synchronous and asynchronous operations, and also prevent errors such as synchronous operations running in an asynchronous context (blocking) and asynchronous operations running in a synchronous context (leaving the coroutine unexecuted).

x = example.process(42)
await x

What is the type of x? What code is executed for example.process?

If you say an asynchronous, because it is executed when there is a running event loop, then what about the following code?

asynd def f(x):
    print(await x)
asyncio.run(f(example.process(42)))

If you say a synchronous, because there is no await immediately preceeding the call (we can consider await + call a single syntax construct), then your hybrid method will be incompatible with a lot of existing code.

For the first example, the IDE will tell you it’s Coroutine[str] | str.

Although I would still need to improve the class to ensure it works.

For the second example:

async def f(x):
    print(await x)


asyncio.run(f(example.process(42)))

What you would be passing to the function f is a str because you are invoking the function in a synchronous context. If you needed to get the coroutine, you would have to invoke it within an asynchronous context.

async def bar(f):
    print(await f)


async def foo(x):
    bar(example.process(x))


asyncio.run(foo(42))

It’s clear that if you need to obtain the coroutine in a synchronous context, this proposal won’t work for you, but you could always choose not to use it; ultimately, this is a proposal to add to functools so that people who find it useful can use it.

I got something published that does that magic, if you want to evaluate: https://github.com/alanjds/acallable :

from acallable import acallable

@acallable
def get(*args, **kwargs):
    return User.objects.get(*args, **kwargs)  # Sync

@get.acall
async def get(*args, **kwargs):
    return User.objects.aget(*args, **kwargs) # ASync


def sync_function():
    return get(username="example")  # dispatches get.sync(...)

async def async_function():
    return await get(username="example") # dispatches get.__acall__(...)

This and another discussion were the inspiration for it (sorry for the crosspost, btw).

…and about that x:

x = example.process(42)
await x

x is always Awaitable if process is using this lib. Because its decision is based on:

Is the call site inside a code where await is legal?

And await x cannot exist outside of an async def, so process(42) will be translated to process.__acall__(42) and return a coro. Does not matter really if you will await it. If await is possible on this code, x will be a coro.

I’m skeptical that this offers much value because while the function call will remain the same, an await will need to be added so the code will need to be updated regardless. If the transition was seamless and sync code magically switched from sync to sync depending on the context this would be a huge benefit, but in light of having to add async and await to function definitions I think the common name for sync and async calls makes the situation worse not better…how do you find all the places where you need to change a def to an async def and add an await if the names for sync and async calls are the same?

2 Likes

The goal of this proposal is to address these three points:

  1. Unifying APIs in hybrid libraries (SDK/database drivers/ORM)

Currently, modern Python libraries that support both synchronous and asynchronous execution are forced to duplicate entry points. Developers must create separate client classes (e.g., httpx.Client vs. httpx.AsyncClient) or invent redundant method names (e.g., fetch_sync vs. fetch_async, or session.query() vs. await session.execute()).

A dispatch system based on libraries of signature types and routines allows for exposing a unified, polymorphic interface without the complexity of namespaces, while keeping synchronous and asynchronous implementations strictly isolated internally.

  1. Gradual Refactoring of Legacy Codebases

Migrating large codebases from synchronous to asynchronous paradigms is rarely an all-or-nothing effort. @coroutinedispatch facilitates gradual migration by developers, allowing them to register asynchronous overloads alongside existing synchronous methods. This preserves backward compatibility for synchronous calls while enabling new asynchronous paths within the same domain model.

  1. Eliminating the “Synchronous over Asynchronous” Antipattern

A common antipattern in hybrid libraries is attempting to automatically detect event loops or wrap asynchronous code with asyncio.run() when called synchronously. This often leads to failures in nested loops and deadlocks. A declarative dispatch mechanism based on signature inspection (iscoroutinefunction) enforces a strict structural separation between synchronous and asynchronous execution paths, avoiding risky runtime solutions.

1 Like

The idea is exactly to have a function to change from sync to async seemless depending on context.

I may be missing something here. Please enlighten-me if so:

Imagine an existing lib that have fetch(url) as sync. A first step to support async would be to just let await fetch(url) to occur on async contexts. It can be achieved by simply applying a decorator on definition:

@acallable
def fetch(url):
    ...

Every sync place will remain the same, with no breaking change. And calls on async context will now need await, preparing for a lib upgrade that really makes it do their stuff asynchronously.

The minimal and lazy implementation is to just let the sync version be the async version. await fetch(...) is just the sync wrapped in a coro.

Then a version that really do the stuff async can be added later via:

@fetch.acall
def fetch(url):
    ...

and now every async def place that worked before will keep working. And no sync def place stopped working in any phase of this transition.

Am I missing something here?

That description lines up with what my understanding of the idea is. The concern I have is to actually switch from sync to async you have to add awaits to calls to fetch. While doing that it’s trivial to also rename fetch() to afetch() (or whatever). There is virtually nothing gained by having the sync and async share the same name. At the same time, having two functions with vastly different call semantics named the same thing adds complexity for virtually no benefit, and is likely to make understanding the code more difficult.

But for needing to add ‘await’ to every call that you want to change from sync to async this would be helpful, but since every single call to the sync version needs to be modified to make it async I don’t see any benefit and do see downsides.

That’s a valid perspective on the migration problem, but the mechanism behind @acallable (wrapping deferred coroutines) introduces fundamental structural and execution problems that this proposal intentionally avoids.

Here’s why a context-aware dispatch mechanism (like inspecting CO_COROUTINE frames in coroutinedispatch) is architecturally superior to simply wrapping synchronous code in awaitable functions:

  1. The “false asynchronicity” trap and event loop blocking

If step 1 of a migration simply wraps a blocking synchronous function (fetch(url)) in a coroutine so that callers can write await fetch(url), it creates a dangerous illusion of non-blocking I/O.

Within an event loop, writing await fetch(url), where fetch is a wrapped synchronous function (for example, using requests or a synchronous database driver), completely blocks the main thread. The event loop cannot yield to other tasks during that await.

If the decorator compensates by automatically executing the synchronous code in a thread pool (for example, using asyncio.to_thread), it adds considerable thread allocation and context switching overhead, negating the lightweight concurrency advantages of asyncio.

  1. Real Dual Implementations vs. Synthetic Wrappers

Your suggestion from @acallable assumes that the synchronous and asynchronous versions are always internally interchangeable. In real libraries:

The synchronous version uses synchronous sockets/drivers (requests, psycopg2).

The asynchronous version uses non-blocking event loops (httpx, asyncpg).

@coroutinedispatch allows developers to provide two completely different native implementations registered under the same name. It checks if the calling frame has the CO_COROUTINE flag set (using sys._getframe) and instantly redirects execution to the appropriate native method (asynchronous vs. synchronous methods) without overhead or mock asynchronous wrappers.

  1. Static Analysis and Type Safety (PEP 484)

A decorator that dynamically modifies a function’s execution model based on whether a second definition is provided causes failures in static type checkers (mypy, pyright) and IDE autocomplete.

By declaring explicit synchronous and asynchronous signatures using @process.register, both type paths remain fully inspectable, native, and safe from day one.

Summary: Wrapping synchronous functions in coroutines (@acallable) provides a temporary syntactic solution at the cost of blocking event loops and hiding the real I/O performance bottlenecks. CoroutineDispatch provides true runtime separation: it executes native synchronous code on synchronous calls and native non-blocking asynchronous code on asynchronous calls under a single, unified interface.

You’re absolutely right: if the goal were simply to save time by refactoring a single line of fetch() to await fetch(), renaming it to afetch() would be trivial. However, the value of @coroutinedispatch lies not in saving keystrokes, but in polymorphic architecture and domain cleanup:

  1. Polymorphism and Generic Abstractions

When designing generic components (such as the Repository pattern, service layers, or middleware), forcing distinct method names breaks polymorphism.

If an interface defines a contract like storage.save(entity), users can operate with abstractions without problems. Forcing the use of save() instead of asave() filters implementation details (I/O paradigm) into the public domain contract, forcing higher-level wrappers to duplicate their entire interface hierarchy.

  1. API Footprint and Cognitive Load in High-Level DSLs

In major SDKs, ORMs, or HTTP clients, having to duplicate every method with the “a” prefix (get/aget, post/apost, filter/afilter, execute/aexecute) doubles the library’s API footprint.

A unified entry point (client.get()) keeps the API clean and consistent. The caller explicitly defines the execution semantics at the call point using await, while the method name remains tied to its actual business domain.

  1. Decoupling I/O Mechanics from Domain Logic

In domain-oriented design, a method name represents a business function (e.g., calculate_risk(), fetch_user()). Whether that operation is executed using blocking synchronous I/O or non-blocking coroutines is an infrastructure implementation detail. @coroutinedispatch allows the infrastructure implementation to evolve without overloading the business domain vocabulary.

  1. Code Generation and Metaprogramming

In automated code generation (such as generating SDKs from OpenAPI/AsyncAPI specifications or working with dynamic proxies), consistent method naming significantly reduces complexity in code generators and decorator pipelines.

Summary: Renaming fetch() and afetch() works well for simple local scripts. However, for authors of libraries, frameworks, and codebases with complex architectures, @coroutinedispatch avoids API footprint overinflation and preserves clean polymorphic interfaces.

I may not have been clear…my concern has nothing to do with “saving keystrokes”, I hope what follows clears this up.

polymorphism is broken more by the fact that you have to await coroutines than different names because even with the same name, you have to modify the code to change from one to the other. sync and async calls can not be polymorphic due to the different way they are called. As I’ve said, if it weren’t for this using the same name would make sense because it could be polymorphic and would just work as needed in either context.

If I understand correctly this is the primary problem you are trying to solve. The proposed solution does this by presenting an API that users can use in a synchronous or asynchronous manner…if you want async stick an await in front of the API call and it will just work. This seems to have an implicit assumption that every API function is implemented as both sync and async. If this is not the case the abstraction is leaky and the library has to document (either explicitly or through typing) which function are sync and which are async. Since some functionality is inherently synchronous (it doesn’t ever block) I suspect most libraries will end up in this situation of having to differentiate sync from async, and the benefit of hiding what is sync and async is minimized. This creates the same problem you were trying to solve, but in a way that is opposite from the core language…rather than marking what is async it marks what is not async

It is also a core aspect of the language that has significant semantic differences While it can be hidden to some extent by using the @ coroutinedispatch to give both two functions that implement functionality using these different semantics the same name, I disagree that it is helpful to end users. Reducing the documentation footprint of the library certainly helps the library authors, I think libraries should prioritize the concerns of the end-user, and the end-user benefits of this proposal (same name) are not justified by the tradeoffs (different semantics depending on inferred context).

end-user complexity should be the priority. I believe this proposal is likely to lead to end-users having to understand (debug, troubleshoot, whatnot) the rules used to infer which context should be applied to the call. This is likely to be very frustrating when users encounter coroutines being generated when a sync call should be or vice versa. I acknowledge this is an assumption based on no experience with the library. As you developed this did you encounter cases where the context was improperly inferred? If so, what was involved in resolving those issues?

It might help me see the value of this if you shared a PR that shows what it looks like to change from sync to async. It would go a long ways towards demonstrating how seamless the process is and the value of a unified name for the different call semantics.

Similarly, are you able to share a PR showing switching a library to use @ coroutinedispatch so I could see the improvement it gives to library authors?

You’ve raised fundamental architectural points that compel us to critically examine the semantics of calls versus the interface design.

To address your key points and your question at the end:

1. Context Inference and “Magic” (Answering your question)

To answer your question directly: yes, relying on implicit context inference (e.g., inspecting frames to guess whether the caller wants synchronous or asynchronous) introduces edge cases and ambiguity.

If a synchronous function is called within an async def block without the intention of waiting for it, inferring async based solely on the surrounding scope breaks the caller’s expectations. This is certainly a valid concern regarding “inferred context.”

The true value of a standard dispatch mechanism shouldn’t depend on “guessing” the runtime context, but rather on providing a declarative protocol for overloads based on signatures and argument types (similar to singledispatch), where method search rules are deterministic instead of random.

2. Polymorphism at the Protocol/Middleware Level

You’re right to point out that requiring await inherently alters the call point syntax. However, the polymorphism that @coroutinedispatch seeks to resolve operates primarily at the interface/contract level rather than the caller’s syntactic identity:

  • Generic Wrappers and Middleware: When writing decorators, event handlers, or RPC/GraphQL resolvers, inspectable overloads allow the framework to check iscoroutinefunction(dispatcher.dispatch(arg)) before invoking it. This allows the framework code to handle both paradigms polymorphically under a single interface contract.

  • Protocol Consistency: In domain-oriented design (DOD), a Repository interface should not overload its public contract with paired methods (save() and asave()) for every domain operation just to support hybrid backends.

3. Asymmetric APIs (Synchronous Functions Only)

As you correctly pointed out, not all functions have an asynchronous counterpart. Like functools.singledispatch, @coroutinedispatch uses a default fallback implementation:

  • If a method only has a basic synchronous implementation, calling it returns the direct result.

  • A coroutine is only dispatched if an explicit asynchronous overload of @process.register matches the call signature. This prevents libraries from having to mark non-I/O utility functions.


Conclusion

I agree with your main premise: prioritizing predictability for the end user is fundamental, and implicit context guessing is a poor abstraction.

However, providing a standardized dispatch pattern for hybrid libraries allows library authors to explicitly structure synchronous/asynchronous overloads, reducing API duplication and maintaining clear type signatures.

Heads up, you’ll likely get better responses from people if you speak in your own words rather than using an LLM to generate output that looks like a reply.

2 Likes

It’s likely, I’m not very good at expressing myself in a language that isn’t my own and I’m asking for recommendations and how to address my points.

Name seems like a perfectly adequate way to differentiate these…in fact, it makes the signatures different even if the argument types are identical.