feat(asyncio): support Python async context managers#329
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #134 — adds support for consuming Python async context managers (the
async withprotocol:__aenter__/__aexit__) from F#.F#
use/use!only supports synchronousIDisposable, andasync withcan't be expressed directly in Fable because F#task/asynccannotawaitinside afinally. This adds a small helper that drives the protocol by hand.What's added
New file
src/stdlib/asyncio/ContextManager.fs(namespaceFable.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— runsbodyinside 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 asusing, but honors__aexit__'s suppression signal for fullasync withfidelity. Returns'T option:Some resulton success,Nonewhen the body raised and__aexit__returned truthy (handled), otherwise re-raises.This compiles to a clean
async defwith realawait manager.__aenter__()/manager.__aexit__(...).Why two combinators?
usingmirrorsasync withfor the common case (locks, connections, files, transactions — all return falsy from__aexit__) and keeps a clean'Treturn type. Honoring suppression inusingwould force it to invent a value when the body is aborted;tryUsingmodels that honestly asNonefor the rare async CM that actually suppresses (e.g. an asynccontextlib.suppress).__aexit__is awaited exactly onceThe body's outcome is captured in an inner
taskso__aexit__runs outside the body'stry. Awaiting the success-path__aexit__inside thetrywould route an exception it raises into the error branch and call__aexit__a second time — Python'sasync withnever does this.FSharp.Core floor: 5.0 → 6.0
AsyncContextManager.usingrelies on thetaskcomputation expression, introduced in FSharp.Core 6.0. The library previously floored at 5.0 (lowest_matching: true); bumping to>= 6.0.0lets the helper live in the library instead of forcing every consumer to hand-write thetry/withdriver. 6.0 was released Nov 2021, so it's a safe floor.Tests
Added tests in
test/TestAsyncIO.fs:Using the stdlib
asyncio.Lockasync context manager:__aenter__) and released afterwards (__aexit__)Using a hand-written
TrackingCm(counts exits, can suppress or raise on exit) to cover the branchesasyncio.Lockcan't reach:tryUsingreturnsNoneand exits exactly once when__aexit__suppressesusingre-raises even when__aexit__returns truthy__aexit__raises (exits == 1)All 561 tests pass (
just test).Notes
Scope is the
taskCE, which maps to native Pythonawait— matching the issue ("can't get it to work with tasks"). The CPSasync {}builder can't cleanly await raw Python coroutines, sotaskis the correct surface.🤖 Generated with Claude Code