Skip to content

feat(asyncio): support Python async context managers#329

Merged
dbrattli merged 2 commits into
mainfrom
feat/async-context-manager
Jun 25, 2026
Merged

feat(asyncio): support Python async context managers#329
dbrattli merged 2 commits into
mainfrom
feat/async-context-manager

Conversation

@dbrattli

@dbrattli dbrattli commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #134 — adds support for consuming Python async context managers (the async with protocol: __aenter__ / __aexit__) from F#.

F# use/use! only supports synchronous IDisposable, and async with can't be expressed directly in Fable because F# task/async cannot await inside a finally. This adds a small helper that drives the protocol by hand.

What's added

New file src/stdlib/asyncio/ContextManager.fs (namespace Fable.Python.AsyncIO):

  • IAsyncContextManager<'T> — interface mapping to __aenter__() and __aexit__(...) (both the success (None, None, None) and error (type(e), e, e.__traceback__) forms).
  • AsyncContextManager.using manager body — runs body inside the context manager, awaiting __aexit__ exactly once on both the success and error paths (never in a finalizer). Returns a plain 'T; if the body raises, the exception is always re-raised after __aexit__ runs.
  • AsyncContextManager.tryUsing manager body — same as using, but honors __aexit__'s suppression signal for full async with fidelity. Returns 'T option: Some result on success, None when the body raised and __aexit__ returned truthy (handled), otherwise re-raises.
task {
    let! rows =
        AsyncContextManager.using (pool.acquire ()) (fun conn ->
            task { return! conn.fetch "SELECT 1" })
    return rows
}

This compiles to a clean async def with real await manager.__aenter__() / manager.__aexit__(...).

Why two combinators?

using mirrors async with for the common case (locks, connections, files, transactions — all return falsy from __aexit__) and keeps a clean 'T return type. Honoring suppression in using would force it to invent a value when the body is aborted; tryUsing models that honestly as None for the rare async CM that actually suppresses (e.g. an async contextlib.suppress).

__aexit__ is awaited exactly once

The body's outcome is captured in an inner task so __aexit__ runs outside the body's try. Awaiting the success-path __aexit__ inside the try would route an exception it raises into the error branch and call __aexit__ a second time — Python's async with never does this.

FSharp.Core floor: 5.0 → 6.0

AsyncContextManager.using relies on the task computation expression, introduced in FSharp.Core 6.0. The library previously floored at 5.0 (lowest_matching: true); bumping to >= 6.0.0 lets the helper live in the library instead of forcing every consumer to hand-write the try/with driver. 6.0 was released Nov 2021, so it's a safe floor.

Tests

Added tests in test/TestAsyncIO.fs:

Using the stdlib asyncio.Lock async context manager:

  • lock is held inside the body (__aenter__) and released afterwards (__aexit__)
  • lock is still released when the body raises (error path)

Using a hand-written TrackingCm (counts exits, can suppress or raise on exit) to cover the branches asyncio.Lock can't reach:

  • tryUsing returns None and exits exactly once when __aexit__ suppresses
  • using re-raises even when __aexit__ returns truthy
  • no double-exit when the success-path __aexit__ raises (exits == 1)

All 561 tests pass (just test).

Notes

Scope is the task CE, which maps to native Python await — matching the issue ("can't get it to work with tasks"). The CPS async {} builder can't cleanly await raw Python coroutines, so task is the correct surface.

🤖 Generated with Claude Code

dbrattli and others added 2 commits June 25, 2026 20:10
Add `IAsyncContextManager<'T>` and `AsyncContextManager.using` to drive the
Python `async with` protocol (`__aenter__`/`__aexit__`) from F#.

F# `use`/`use!` only supports synchronous `IDisposable`, and `async with`
cannot be expressed directly because `task`/`async` cannot `await` inside a
`finally`. `using` drives the protocol by hand, awaiting `__aexit__` on both
the success and error paths and honoring its truthy return to suppress the
exception, matching `async with` semantics.

The `task` CE this needs was introduced in FSharp.Core 6.0, so the library
floor is raised from 5.0 to 6.0 (released Nov 2021).

Closes #134

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure AsyncContextManager so __aexit__ is awaited exactly once on
every path. The success-path exit now runs outside the body's try, so a
raising __aexit__ propagates instead of triggering a second call.

Replace the silent Unchecked.defaultof on suppression with two combinators:
- using: always re-raises after __aexit__, returning a plain 'U
- tryUsing: honors __aexit__'s suppression signal, returning 'U option

Add a hand-written TrackingCm to cover the branches asyncio.Lock can't
reach: suppression via tryUsing, using re-raising despite a truthy exit,
and no double-exit when the success-path __aexit__ raises.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dbrattli dbrattli merged commit 8c7cc90 into main Jun 25, 2026
10 checks passed
@dbrattli dbrattli deleted the feat/async-context-manager branch June 25, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support for asynchronous context managers

1 participant