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()andaget()? - Or ship
get()andget_async()? - Or create two classes
SyncVersion.get()andAsyncVersion.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
awaitis 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:
- Two rules for generators:
<genexpr>is transparent;def..yieldis sync - Two rules for sync detection:
defis sync; EXCEPTdef..yieldthat 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
- Python discussion: How can async support dispatch between sync and async variants?
__acall__proposal: posts #20 and #22- Frame-inspection detection: post #3 in the overload proposal thread
- Related project: zyncio, the coroutine-first approach
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)
| File | Size | Uploaded | |
|---|---|---|---|
| acallable-0.1.0.tar.gz | 19.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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