Allow mixed sync/async context managers in `async with` statements

TL;DR: Let’s allow async with to accept both sync and async context managers in a single statement, as long as there’s at least one async context manager present. This would eliminate the nested indentation that currently plagues mixed context manager code. GitHub issue here.

The Problem

If you write async Python with context managers, you’ve hit this pain point: every time you need to mix sync and async context managers, you get punished with another level of indentation:

# status quo: the staircase of doom
async with acquire_lock() as lock:
    with open('config.json') as f:
        async with connect_to_db() as db:
            with temp_directory() as tmpdir:
                # Your actual code is now 16 spaces deep;
                # you and your colleagues are sad.

You can get around this with contextlib.AsyncExitStack(), but only at the cost of some serious footguns for the unwary (e.g. mis-nesting contexts, the sharp edges in .pop_all(), etc).

The Proposal

Allow async with to accept both sync and async context managers:

async with (
    acquire_lock() as lock,
    open('config.json') as f,
    connect_to_db() as db,
    temp_directory() as tmpdir,
):
    # Your code starts at a reasonable indentation
    # Your colleagues might actually review your PR

The rule is simple: if there’s at least one async context manager in the group, you can use async with for the whole thing. This is semantically identical to a safe use of AsyncExitStack, but much prettier code and harder to get wrong.

Unsatisfying workarounds

Add checkpoints

Wrapping sync context managers in async helpers adds await points where your async framework can switch tasks. Context manager boundaries are terrible places for task switches - they often handle timeouts, locks, or error recovery - and so this can introduce bugs, as well as the performance overhead.

def as_acm(ctx):
    @asynccontextmanager
    def inner():
        with ctx as val:
            await sleep(0)
            yield val
    return inner()

async with (
    acquire_lock() as lock,
    as_acm(open('config.json')) as f,
    connect_to_db() as db,
    as_acm(temp_directory()) as tmpdir,
):
    # hope you didn't need an atomic section there...

An AsyncExitStack helper function

The semantics of a async with multicontext(...) helper are better than the as_acm() trick, but you lose the locality of as clauses, and with that the code becomes uglier and harder to understand:

@asynccontextmanager
async def multicontext(*cms):
    assert any(hasattr(cm, "__aenter__") for cm in cms)
    async with AsyncExitStack() as stack:
        enter, aenter = stack.enter_context, stack.enter_async_context
        yield tuple(
            (enter(cm) if hasattr(cm, "__enter__") else await aenter(cm))
            for cm in cms
        )

async with (
    acquire_lock(),
    open('config.json'),
    connect_to_db(),
    temp_directory(),
) as (lock, db, f, tmpdir):
    # Wait, which order were those in again?

Implementation Considerations

The change should be straightforward:

  • Runtime check that at least one async context manager is present
  • Proper handling of __enter__/__exit__ vs __aenter__/__aexit__

The semantics are clear: sync context managers use their normal protocol, async ones use the async protocol, execution order remains left-to-right. AsyncExitStack already does this; I’m just proposing to support it directly at the statement level where code can be as clean and simple as possible.

Prior Art

I’ve been using both of the “unsatisfying workarounds” above in production Trio code; they do indeed work and are indeed unsatisfying. We would be delighted to switch over to using the async with statement as soon as possible.

Backwards Compatibility

This is purely additive. All existing code continues to work. The only “breaking” change is that code that was previously a RuntimeError would now work, with the only reasonable non-error semantics.

What do you think?

Am I missing some crucial consideration here? Is there a technical blocker I’m not seeing? For those using structured concurrency with asyncio.TaskGroup or Trio or AnyIO, this would be particularly nice - you could finally group your timeout/cancellation contexts naturally with your resource contexts without the indentation penalty.

4 Likes

Super quick question,

We need the await in there since @asynccontextmanager requires an async generator? Could we write a different helper that just calls __enter__ in __aenter__ (etc) thus avoiding the suspension point?

1 Like

Async generators don’t require that await, you could make an as_acm that doesn’t yield to the event loop, this seems like a perfectly fine work around:

    @asynccontextmanager
    async def as_acm(ctx):
        with ctx as val:
            yield val

How would you make this work without probing the context manager to see if it’s async or sync? This seems slow

This also doesn’t seem backwards compatible as in eg dask there’s classes that support both async and sync context management but only work in the appropriate context. (The sync context manager dispatches to an event loop in a background thread, the async version uses the running loop)

1 Like

I like the idea. Two points on details:

Runtime check that at least one async context manager is present

Do we need this? It would be more straightforward to implement if you could use async with even if all the context managers are sync; this seems harmless. A concrete advantage is that you don’t need to worry about switching between with and async with if e.g. you temporarily comment out one context manager in a group.

This also doesn’t seem backwards compatible

This depends on the exact semantics. I think it should be to check if __aenter__/__aexit__ exist; if so, use them; if not, check for __enter__/__exit__; if so, use them; if not, raise. For compatibility it’s important that we check the async version first, contrary to the helper function shown in @Zac-HD’s example.

(There might be some more subtlety around what happens if only one of the enter and exit methods exist; we can probably align with however that is handled today.)

How would you make this work without probing the context manager to see if it’s async or sync? This seems slow

I don’t think the procedure I outlined above should be very slow.

6 Likes

As for compatibility, it seems obvious for me that the feature should call __aenter__ if it exists, and only otherwise fallback to the sync context manager protocol. This is a non-issue.

I recently wrote a PR which suggests using async with when someone uses it for a sync manager. Detecting whether it’s supported or not is quite straightforward so we can reuse that code: gh-128398: improve error message when incorrectly `with` and `async with` by picnixz · Pull Request #132218 · python/cpython · GitHub.

The difficulty is that the proposed rule would have to look across multiple context managers. If you have the code async with a, b, c: pass, then under Zac’s proposal, the interpreter should raise an error only if all three of a, b, and c implement enter but not aenter. That means the interpreter would have to track that state across instructions. Not impossible of course, but not completely trivial either.

2 Likes

I had the same thought. I don’t understand why it should raise an error in this case ? If it accepts both kinds of context managers, let it always accept all of them whatever they are. Is there a drawback I don’t see ?

Could we perhaps move the async keyword inside the parens?

with (
    async acquire_lock() as lock,
    open('config.json') as f,
    async connect_to_db() as db,
    temp_directory() as tmpdir,
):

Now you can mix sync and async context managers in a single with block, and, unlike with the OP’s solution as-is, it’s abundantly clear to anyone reading the code which context managers are async and which aren’t, and the interpreter doesn’t have to waste any time checking, either - it can raise RuntimeError if any of the context managers are of the wrong kind.

For completeness, I suppose we should probably also allow with async foo as bar as an equivalent to async with foo as bar (for the case where you’re only entering one context manager and not a bunch of them).

14 Likes

The problem here is that if we don’t yield to the event loop, or if we allow all-sync context managers, this breaks the syntactic visibility of checkpoints.

Trio goes to considerable lengths to ensure that every time you see the await, async for, or async with keyword, it’s executing a checkpoint where the event loop has a chance to cancel the task or schedule other work[1]. This is vital to avoid deadlocks in a cooperative-concurrency system; I wrote a whole linter and Anthropic’s codebase puts considerable effort into maintaining this property.


  1. or raises an exception; the __aexit__ of a nursery also has slightly more complicated rules ↩︎

2 Likes

This seems like an elegant and explicit solution to me, although as new syntax it’s also a larger change.

1 Like

Could Python itself execute a checkpoint whenever async with is about to fallback to __enter__?

Nope, it’s unfortunately quite complicated.

There’s the practical reason that the interpreter doesn’t have a reliable way to know how to execute a checkpoint - that’s a callback into your currently-active async framework of choice, and there are several that could even be active simultaneously in a single thread.

There’s also the principled reason, which is that checkpoints mark the places where the scheduler can cancel or switch tasks, and so inserting additional checkpoints can dramatically change program semantics, by e.g. allowing a task to be cancelled before performing some cleanup logic. Some context managers (e.g. async with lock:) block on entry; others like async with TaskGroup() as tg: block on exit, and there are some fairly gnarly interactions with propagating exceptions in __aexit__ blocks too.

I see similarities to Enhance builtin iterables like list, range with async methods like __aiter__, __anext__,