Skip to main content

acallable

Write one named sync function with an alternative async version. Call it from sync or async code. No name duplication.

from acallable import acallable

@acallable
def fetch(url: str) -> str:
    print('synchronous version')
    import httpx
    r = httpx.get(url)
    return r.text

@fetch.acall
async def fetch(url: str) -> str:
    print('asynchronous version')
    import httpx
    async with httpx.AsyncClient() as client:
        r = await client.get(url)
    return r.text

Same name, both works:

>>> def my_sync_main():
...    body = fetch("https://example.com")          # from sync context
...    ...

>>> my_sync_main()
synchonous version

>>> async def my_async_main():
...    body = await fetch("https://example.com")    # from async context
...    ...

>>> asyncio.run(my_async_main())
asynchronous version

When not provided, the async version is just the sync one wrapped in a coroutine. For free :)

Also works with classes (and methods btw):

@acallable
class Fetcher:
    def __call__(self, url: str) -> str:          # sync body
        import httpx
        return httpx.get(url).text

    async def __acall__(self, url: str) -> str:   # async body
        import httpx
        async with httpx.AsyncClient() as client:
            return (await client.get(url)).text

Based on the Jonny Saunders' idea of having __acall__ as the natural async version of __call__

The problem it solves

Python's async coloring forces library authors to choose:

  • Ship get() and aget()?
  • Or ship get() and get_async()?
  • Or create two classes SyncVersion.get() and AsyncVersion.get()?

Users then have to pick the right name depending on their own calling context, and switch if that context changes. The two names diverge over time, doubling maintenance.

acallable lets you register one sync body and one async body under the same name. The right one is selected automatically based on the call site being inside an async context or not.

Install

pip install acallable

Where this idea comes from?

The naming problem

The standard workaround for dual sync/async is two names: get & aget, send & send_async, fetch_sync & fetch. Or two classes maybe extending from a single base. Guido van Rossum described one approach that adds a .sync() attribute alongside the async function. That keeps one canonical implementation but still requires callers to choose which name to use.

The __acall__ idea

Jonny Saunders proposed an __acall__ dunder method: an async counterpart to __call__ so that await foo() would dispatch to __acall__ if present, while foo() keeps dispatching to __call__ as normal. The pattern also maps cleanly like a property-setter decorator:

class Runner:
    def process(self): ...

    @process.async
    async def process(self): ...

This example above does not work and uses the async reserved word, but the idea is not bad. acallable implements this idea.

@acallable decorates the sync body; @fn.acall registers the async body. No new syntax and no language changes.

When __acall__ vs. __call__? When "called from an awaitable context"!

The original __acall__ proposed to dispatch based on the question "is this call expression being awaited right now?":

  • foo() -> foo.__call__() (as today)
  • await foo() -> foo.__acall__() (new)

However it lets open the question: what is x in this example?

async def bar():
    x = foo()
    await x

As await x should be x.__await__(), detecting x = foo.__acall__() is not possible, breaking expectations.

To solve it, acallable shifts the __acall__ choosing question to:

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

With this definition, x surely is a coroutine returned by foo.__acall__(), because foo() is called inside async def.

Implementation details

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

That new definition also solves the following example:

async def compute_many(numbers):
    results = await asyncio.gather(*(compute(i) for i in numbers))
    ...

Should compute(i) be compute.__call__(i) or compute.__acall__(i)?

Here compute(i) is called from inside a generator expression. The immediate frame is a CO_GENERATOR, where await is not legal. The immediate genexpr context is sync, then asyncio.gather would receive plain values instead of coroutines. However it whould not be correct, as "the call site IS inside a code where await is legal".

acallable walks up the call stack transparently past any CO_GENERATOR frames (sync genexprs and plain generators) until it finds some sync or async enclosing context. CO_ASYNC_GENERATOR frames (async def ... yield) are considered async contexts. That let genexpr-inside-gather patterns working correctly.

The frame-inspection overhead using sys._getframe and co_flags was reported in the Python discussion forums as costing less than 1.5µs;

Should all CO_GENERATOR be transparent?

This is debatable because standard def gen(): ... yield <something> is also CO_GENERATOR.

The question goes down to one of this confusing solutions:

  1. Two rules for generators: <genexpr> is transparent; def..yield is sync
  2. Two rules for sync detection: def is sync; EXCEPT def..yield that is transparent

FOR NOW this lib considers ALL generators transparent (Option 2). A future change on this could occur, but surely versioned as a breaking change and documented.

Usage

Decorating functions

from acallable import acallable

@acallable
def greet(name: str) -> str:
    return f"Hello, {name}"          # sync body: runs from plain def

@greet.acall
async def greet(name: str) -> str:
    await asyncio.sleep(0)           # async body: runs when awaited
    return f"Hello, {name}"

# From sync caller, like `def _():`
print(greet("world"))                # Hello, world

# From async caller, like `async def _():`
print(await greet("world"))          # Hello, world

Decorating a Class method

from acallable import acallable

class DataStore:
    def __init__(db, ...):
        ...

    @acallable
    def save(self, record: dict) -> None:
        db.execute_sync(record)

    @save.acall
    async def save(self, record: dict) -> None:
        await db.execute_async(record)

store = DataStore()

def foo(): store.save({"k": "v"})               # sync
async def foo(): async store.save({"k": "v"})   # async

Decorating a whole Class

Define __call__ (sync) and __acall__ (async) directly on the class body. @acallable wires the dispatcher automatically:

from acallable import acallable

@acallable
class Fetcher:
    def __call__(self, url: str) -> str:          # sync body
        import httpx
        return httpx.get(url).text

    async def __acall__(self, url: str) -> str:   # async body
        import httpx
        async with httpx.AsyncClient() as client:
            return (await client.get(url)).text

fetcher = Fetcher()

def sync_main():
    body = fetcher("https://example.com")         # calls __call__

async def async_main():
    body = await fetcher("https://example.com")   # calls __acall__

The decorated inheritance is not touched: isinstance(fetcher, Fetcher) works, subclassing works, type checkers are kept mostly happy.

Default async body

If you omit @fn.acall or Class.__acall__, the async path automatically wraps the sync body in a coroutine. Simple functions that have no real async I/O yet get both paths for free:

@acallable
def compute(x: int) -> int:
    return x * 2

def foo(): return compute(3)              # returns 6
async def foo(): return await compute(3)  # also returns 6

This way you can create APIs today that will have real async I/O in the future. No need to break the contracts later.

Static typing caveats

Static typers are currently (2026) not following the decoration code "magic", leading to detecting false errors like __await__ may be missing. I tried my best but could not make typecheckers "behave" by luring them into accepting that a class __call__ can return an int | Awaitable[int] for example.

My current advise is to lower the typechecker issue level to "warning" or to "ignore" for now for the await-related kind of checks below. If this lib gets traction, they someday may provide a better option. Until that:

# pyproject.toml
...

[tool.ty.rules]
invalid-await = "warn"  # or "ignore"

[tool.pyright]
reportGeneralTypeIssues = "warning"  # or "none"

[tool.mypy.overrides]
disable_error_code = ["operator"]  # Sorry, no way to force "warning" here.

[tool.ruff]
lint.extend-ignore = [
    'N807',  # Allow functions named __acall__ to start with `__`
]

Implementation inspiration & acknowledgements

zyncio (by Benjy Wiener) solves the same problem from the opposite direction: write everything as a coroutine and branch inside the function if zyncio.is_sync().

acallable keeps the two bodies separate (one sync fn, one async fn) and let the decorator handle detection and dispatch. The call site needs no mixin inheritance or subclass split.

I am thankful for Benjy Wiener for the inspiration and implementation ideas. zyncio is the best working approximation from __acall__ that I could find. I just found the DX worth some improvement that would not be possible with a direct contribution without a huge API breaking change.

Thanks also Ricardo Robles for exploring the sys._getframe + co_flags detection approach.

And Serhiy Storchaka for the important corner question about x = foo(); await x

Hope to had not missed many important contributions, besides GvH of course :)

Links

License

This package is licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 and can undestand more at http://choosealicense.com/licenses/apache/ on the sidebar notes.

Apache Licence v2.0 is a MIT-like licence. This means, in plain English:

  • It's truly open source
  • You can use it as you wish, for money or not
  • You can sublicence it (change the licence!!)
  • This way, you can even use it on your closed-source project As long as:
  • You cannot use the authors name, logos, etc, to endorse a project
  • You keep the authors copyright notices where this code got used, even on your closed-source project (come on, even Microsoft kept BSD notices on Windows about its TCP/IP stack :P)

Release files for acallable 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for acallable 0.1.0
File Size Uploaded
acallable-0.1.0.tar.gz 19.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for acallable 0.1.0
File Interpreter ABI Platform
acallable-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size:32.5 kB

Release files / acallable-0.1.0.tar.gz

Download URL acallable-0.1.0.tar.gz
Size 19.8 kB
Tags Source
SHA-256 checksum
How to use checksums
eba352ddd6e4f911424a2fc2862ce72be17d1e1bde060ab8d7a48b34c7aa262d
BLAKE2b-256 checksum
How to use checksums
6e4b6c130fadd0ea4034cde25fc37b68b7f626424d844b7e5a43f3723aef9b4c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.

Transparency log

Release files / acallable-0.1.0-py3-none-any.whl

Download URL acallable-0.1.0-py3-none-any.whl
Size 12.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
24e80f1b8f07fbba09bb5769eb7f50f3c74c98cd7fcb1cdc2124a68f10a14928
BLAKE2b-256 checksum
How to use checksums
9dd0240f45a416a34c4db035782f9338d9d5c0d12eb3d9b3f28fcb28f6ffd7e9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page