Your Python SDK ships Client and AsyncClient as two hand-written files with the same
methods. A fix lands in the sync get_meter, but the async copy keeps the bug for a release
because nobody edits both files. The two already disagree on the default page size, which no
test can see because each suite reads one file.
What you get
You will end up with one async source, a sync package that unasync writes from it, and a test that fails when the two classes drift apart. This is for you if your Python SDK runs over httpx and needs both calling conventions.
Short answer
Write AsyncClient once, as coroutines over httpx.AsyncClient, in a _async package.
Generate Client from it with unasync, which drops every async and await, renames the
double-underscore methods, and maps AsyncClient and aclose to the names httpx spells
differently. Commit the
output, then test that both classes expose the same methods with the same parameters and that a
fresh unasync run reproduces the committed file.
You will need
Python 3.11 or later, httpx for both
transports, and unasync at build time only,
because the generated package imports httpx and never unasync. Verified 2026-10-21 against
Python 3.13.16, httpx 0.28.1, unasync 0.6.0, stripe 16.0.0, and openapi-python-client 0.29.1.
The sample serves both clients from one httpx.MockTransport, and the pitfall script runs a
keep-alive server on the loopback interface to count connections, so nothing here reaches a
network. The openapi-python-client output under generated/ was produced once from the sample’s
OpenAPI document and committed.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| httpx Client and AsyncClient | A handful of operations, written by hand over the pair httpx already ships | Every method exists twice, so every fix, default, and docstring lands twice, and the agreement test is yours to write | The operation count passes a dozen, or the two files have disagreed once already |
openapi-python-client sync and asyncio pairs | The API has an OpenAPI document and a module per endpoint is an acceptable shape | Four functions per endpoint, sync, sync_detailed, asyncio, and asyncio_detailed, called with a client argument rather than on one | You want client.get_meter() rather than get_meter.sync(client=client), or there is no OpenAPI document |
Stripe Python _async methods | One class for both conventions, where callers accept a suffix on every async call | Twice the methods on one class, over two HTTP libraries by default, requests for sync and httpx for async | The SDK is small enough that a second class costs less than a suffix on every call |
| unasync | One async source and a sync twin with identical names, regenerated on every build | A build step, a committed output reviewers must trust, and a replacement for every Async name httpx spells differently | The two halves must behave differently, or the package has no build step to hang it on |
openapi-python-client and Stripe both keep the two conventions side by side, as four functions
per endpoint module in one case and as an _async suffix on one class in the other.1 Both
outputs are generated, which is how their halves stay in step. A hand-written pair over httpx
has no generator behind it, so agreement rests on whoever makes the next edit. unasync gives
hand-written code the generated property, and charges a build step plus a replacement for every
name httpx spells differently.
Write the client once, as coroutines
The async source is the only file anyone edits. It lives in meterco/_async/, and everything
that is not a coroutine lives outside that directory, so the generated package imports the
shared code instead of carrying a copy.
class AsyncClient:
def __init__(
self,
api_key: str,
base_url: str = "https://api.meterco.example/v1",
transport: httpx.AsyncBaseTransport | None = None,
):
self._http = httpx.AsyncClient(
base_url=base_url,
headers={"authorization": f"Bearer {api_key}", "user-agent": "meterco-python/1.0"},
transport=transport,
)
async def __aenter__(self) -> "AsyncClient":
return self
async def __aexit__(self, *exc_info) -> None:
await self.aclose()
async def aclose(self) -> None:
await self._http.aclose()
async def get_meter(self, id: str) -> dict:
return await self._request("GET", f"/meters/{segment(id)}")
Three choices in that file serve the generator. MeterCoError is imported from
meterco/errors.py rather than defined here, so both clients raise one class and a caller
catches it without knowing which client raised. The transport is a constructor argument, which
is what lets the tests serve both clients from one MockTransport. The client is a context
manager with an explicit aclose, because httpx’s
async documentation closes a client with async with or
await client.aclose(), and a client given neither keeps the connections it pooled. The pitfall
section counts them.
Every id goes through segment, which lives in meterco/_paths.py for the same reason as the
error class. It escapes /, ?, and # and refuses an empty id, ., and .., so
update_meter("../admin/keys", ...) cannot send a PATCH to /v1/admin/keys with the key
attached.
Generate the sync half with unasync
unasync works on tokens rather than on a parse tree.2 It deletes async and await together
with the whitespace after them, and renames the nine names in its default table, __aenter__ to
__enter__ among them.3 Any other name that starts with Async and a capital letter becomes
Sync. That last rule would write httpx.SyncClient, which does not exist, so the rule here
maps the names httpx spells differently.
RULE = unasync.Rule(
"/_async/",
"/_sync/",
additional_replacements={
"AsyncClient": "Client",
"AsyncBaseTransport": "BaseTransport",
"aclose": "close",
},
)
def build(root: Path = HERE) -> list[Path]:
sources = sorted((root / "meterco" / "_async").glob("*.py"))
unasync.unasync_files([str(p) for p in sources], rules=[RULE])
return [root / "meterco" / "_sync" / p.name for p in sources]
unasync_files is the entry point the
documentation gives for running outside
setuptools; the same Rule goes to cmdclass_build_py when a setup.py builds the package.
Run it after every edit to the source.
PYTHONPATH=vendor python3 build_sync.py
wrote meterco/_sync/__init__.py: 1 lines
wrote meterco/_sync/client.py: 49 lines
The output is the same file with the coroutine machinery removed.
def __enter__(self) -> "Client":
return self
def __exit__(self, *exc_info) -> None:
self.close()
def close(self) -> None:
self._http.close()
def get_meter(self, id: str) -> dict:
return self._request("GET", f"/meters/{segment(id)}")
Commit it. A reader of the sync package sees ordinary Python, a type checker sees ordinary signatures, and a reviewer sees the diff of the generated file beside the diff of the source. Docstrings survive untouched, because unasync applies its renaming to a string’s whole text rather than to the words inside it, so write them to read correctly in both packages.
Compare the method tables
The other two designs are measured rather than described. The script walks a module that
openapi-python-client generated from the sample’s OpenAPI document, a StripeClient service
from the installed stripe package, and the two classes from this page.
PYTHONPATH=vendor python3 compare.py
openapi-python-client, module meter_co_api_client/api/meters/get_meter.py
async asyncio
async asyncio_detailed
sync sync
sync sync_detailed
one Client class carries both httpx clients: get_httpx_client(), get_async_httpx_client()
stripe 16.0.0, StripeClient('sk_test_x').v1.customers
sync create async create_async
sync delete async delete_async
sync delete_discount async delete_discount_async
sync list async list_async
sync retrieve async retrieve_async
sync search async search_async
sync update async update_async
7 pairs on one class, 0 methods without a twin
meterco, this page: meterco._async.client.AsyncClient and the unasync output meterco._sync.client.Client
AsyncClient ['__aenter__', '__aexit__', 'aclose', 'get_meter', 'list_meters', 'update_meter']
Client ['__enter__', '__exit__', 'close', 'get_meter', 'list_meters', 'update_meter']
openapi-python-client answers the question per endpoint: one module, four functions, and a
single Client whose get_httpx_client and get_async_httpx_client hold one httpx client of
each kind. Stripe answers it per method: seven operations on the customers service, each with an
_async twin on the same class, which its README
describes as suffixing the method name with _async. The unasync output answers it per class,
with identical names, and a caller switches by changing one import.
Check it worked
Run both clients against one transport first. httpx.MockTransport is a BaseTransport and an
AsyncBaseTransport at once, so one handler serves both.
PYTHONPATH=vendor python3 demo.py
AsyncClient get_meter('mtr_1') {'id': 'mtr_1', 'serial': 'SN-40199', 'state': 'installed'}
AsyncClient list_meters(state='installed') ['mtr_1']
AsyncClient get_meter('mtr_404') raises meterco: 404
Client get_meter('mtr_1') {'id': 'mtr_1', 'serial': 'SN-40199', 'state': 'installed'}
Client list_meters(state='installed') ['mtr_1']
Client get_meter('mtr_404') raises meterco: 404
the two classes, method by method
async def __aenter__() def __enter__()
async def __aexit__(*exc_info) def __exit__(*exc_info)
async def _request(method: 'str', path: 'str', **kwargs) def _request(method: 'str', path: 'str', **kwargs)
async def aclose() def close()
async def get_meter(id: 'str') def get_meter(id: 'str')
async def list_meters(state: 'str | None' = None, limit: 'int' = 20) def list_meters(state: 'str | None' = None, limit: 'int' = 20)
async def update_meter(id: 'str', state: 'str') def update_meter(id: 'str', state: 'str')
Then run the tests. The two that matter walk the classes with
inspect.signature and
regenerate the sync package into a temporary directory.
def test_every_pair_has_the_same_parameters():
for a_name, fn in methods(AsyncClient).items():
s_name = RENAMED.get(a_name, a_name)
assert s_name in methods(Client), f"{s_name} missing from Client"
assert without_return(fn) == without_return(methods(Client)[s_name]), a_name
def test_committed_sync_files_are_what_unasync_writes():
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
shutil.copytree(HERE / "meterco", root / "meterco", ignore=shutil.ignore_patterns("__pycache__", "_sync"))
for written in build_sync.build(root):
committed = HERE / "meterco" / "_sync" / written.name
assert written.read_bytes() == committed.read_bytes(), f"{committed.name} is stale: run build_sync.py"
PYTHONPATH=vendor python3 test_clients.py
ok test_public_method_sets_match
ok test_every_pair_has_the_same_parameters
ok test_every_method_is_a_coroutine_on_one_side_only
ok test_both_clients_give_the_same_answer_through_one_transport
ok test_both_clients_raise_the_same_error
ok test_an_id_is_one_path_segment
ok test_committed_sync_files_are_what_unasync_writes
7 cases, 0 failures
RENAMED holds the three names allowed to differ, and the first test refuses any other
difference. The last test catches a hand edit to the generated file. The committed bytes have to
equal what a fresh run writes, so a fix made in _sync fails the build until it is made in
_async and regenerated.
When it goes wrong
pitfall.py shows three failures, the first two against a keep-alive server on the loopback
interface that counts the connections it has open.
PYTHONPATH=vendor python3 pitfall.py
asyncio.run around one AsyncClient, two calls
call 1: mtr_1
call 2: RuntimeError: Event loop is closed
holding clients open
5 clients dropped, no aclose: 5 connections open on the server
the same 5 after gc.collect(): 0 connections open on the server
5 calls, 5 clients, async with: 0 connections open on the server
unasync with no additional_replacements
class SyncClient:
transport: httpx.SyncBaseTransport | None = None,
self._http = httpx.SyncClient(
def __enter__(self) -> "SyncClient":
self.aclose()
def aclose(self) -> None:
self._http.aclose()
constructing it: AttributeError: module 'httpx' has no attribute 'SyncClient'
The first is the shortcut that avoids a second class altogether: keep one AsyncClient and
wrap each call in asyncio.run. The first call succeeds.
asyncio.run creates a
new event loop and closes it at the end. The connection pooled during the first call belongs to
a loop that is gone, so the second call fails with Event loop is closed. A sync client
built that way answers one call and fails on the next.
The second is the client nobody closes. Five clients made five calls and were dropped without
aclose. The server held all five connections open until gc.collect() reached the clients,
because nothing else closes a dropped client’s pool. The script pauses automatic collection for
that count, so it does not depend on when the collector would run. The same five calls under
async with left none open. A client created per request and never closed holds one connection
per request until the collector runs, instead of reusing one.
The third is unasync’s default rules on the same source. With no additional_replacements, the
prefix rule writes httpx.SyncClient and httpx.SyncBaseTransport, and aclose keeps its
name. The file imports cleanly and fails on the first construction with
AttributeError: module 'httpx' has no attribute 'SyncClient', which is why the test suite
constructs the sync client rather than only inspecting it.
When not to do this
Do not generate the sync half when the two halves need to behave differently. unasync produces a line-for-line twin, so a sync client that should block on a thread pool, batch its calls, or skip a feature the async client has cannot come out of it. Write two classes then, and keep the method-set test so they agree on names at least.
Do not wrap coroutines in asyncio.run to get a sync API without a second class. The pitfall
section shows it failing on its second call. The runner also refuses to start inside a caller that
already has an event loop running in the same thread, which rules out every async framework
your users might be on.
Do not adopt the build step for a client with three methods. The committed output and the drift test cost more than writing the second class by hand, and the method-set test works as well on two hand-written files as on a generated pair.
Calling the generated file read-only means little without the test that makes it so. Without
the byte comparison, the first urgent fix lands in _sync, the next regeneration erases it, and the
bug returns with no diff to explain why.
Related how-tos
Last verified
Verified 2026-10-21 against Python 3.13.16, httpx 0.28.1, unasync 0.6.0, stripe 16.0.0, and
openapi-python-client 0.29.1. Every output block is what the command preceding it printed. The
openapi-python-client module under generated/ was generated once and committed, and the stripe
figures come from walking the installed package, with no request made to Stripe.
Footnotes
-
The two generators agree on the mechanism and differ on the spelling.
openapi-python-clientnames its coroutineasyncio, after the standard library module that runs it, and its blocking functionsync, a word Python itself does not use for the purpose. Stripe appends_async, so its sync method carries no marker and its async method carries a suffix the language does not read. A caller who uses both SDKs learns both spellings. ↩︎ Back to text -
unasync is kept by the python-trio organization, and its README traces the idea to a urllib3 issue. The same README lists three users: the official Elasticsearch Python client, Hip, and httpcore, which it says uses a different implementation of the same idea. httpcore describes itself as the layer beneath a higher level client library such as httpx. So the
httpx.Clientthis page generates a twin of is itself served by a sync package written from an async one, and the technique sits two layers deep in its own comparison. ↩︎ Back to text -
PEP 492, created in April 2015 for Python 3.5, added the
async defandawaitsyntax and the magic methods__aenter__,__aexit__,__aiter__, and__anext__. Those four are nearly half of unasync’s default table of nine entries; the rest coverasynccontextmanager, three names fromtyping, andStopAsyncIteration. Everything else the tool knows is a prefix rule: a name beginningAsyncfollowed by a capital letter becomesSync, whether or not anything by that name exists. ↩︎ Back to text