Skip to content

Commit f696364

Browse files
dbrattliclaude
andcommitted
feat(asyncio): support Python async context managers
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>
1 parent 0cc1661 commit f696364

5 files changed

Lines changed: 117 additions & 2 deletions

File tree

‎paket.dependencies‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ source https://api.nuget.org/v3/index.json
44
storage: none
55
framework: netstandard2.0, netstandard2.1, net6.0, net8.0, net9.0, net10.0
66

7-
nuget FSharp.Core >= 5.0.0 lowest_matching: true
7+
nuget FSharp.Core >= 6.0.0 lowest_matching: true
88
nuget Fable.Core >= 5.0.0 lowest_matching: true
99

1010
group Test

‎paket.lock‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ NUGET
44
remote: https://api.nuget.org/v3/index.json
55
Fable.Core (5.0)
66
FSharp.Core (>= 4.7.2)
7-
FSharp.Core (5.0)
7+
FSharp.Core (6.0)
88

99
GROUP Examples
1010
STORAGE: NONE

‎src/Fable.Python.fsproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<Compile Include="stdlib/asyncio/Futures.fs" />
1515
<Compile Include="stdlib/asyncio/Events.fs" />
1616
<Compile Include="stdlib/asyncio/Tasks.fs" />
17+
<Compile Include="stdlib/asyncio/ContextManager.fs" />
1718
<Compile Include="stdlib/Ast.fs" />
1819
<Compile Include="stdlib/Base64.fs" />
1920
<Compile Include="stdlib/Builtins.fs" />
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Bindings for Python asynchronous context managers (the `async with` protocol:
2+
// __aenter__ / __aexit__).
3+
//
4+
// F# `use`/`use!` only supports IDisposable (synchronous Dispose), so there is
5+
// no built-in way to consume a Python async context manager, and `async with`
6+
// cannot be expressed directly because F# `task`/`async` cannot `await` inside a
7+
// `finally`. Cast a value returning such an object to IAsyncContextManager and
8+
// drive the protocol from a `task { }` by awaiting __aexit__ on the success and
9+
// error paths (never in a finalizer) — see AsyncContextManager.using.
10+
namespace Fable.Python.AsyncIO
11+
12+
open System.Threading.Tasks
13+
open Fable.Core
14+
15+
/// A Python asynchronous context manager: an object implementing `__aenter__`
16+
/// and `__aexit__`. Bind (or unbox) library values returning such objects to
17+
/// this interface to drive the `async with` protocol from F#.
18+
type IAsyncContextManager<'T> =
19+
/// `__aenter__()` — acquire the resource. Await the result in a `task`.
20+
[<Emit("$0.__aenter__()")>]
21+
abstract member AEnter: unit -> Task<'T>
22+
23+
/// `__aexit__(None, None, None)` — release after the body succeeded.
24+
[<Emit("$0.__aexit__(None, None, None)")>]
25+
abstract member AExit: unit -> Task<bool>
26+
27+
/// `__aexit__(type(e), e, e.__traceback__)` — release after the body raised.
28+
/// A truthy result means the exception was handled and should be suppressed.
29+
[<Emit("$0.__aexit__(type($1), $1, getattr($1, '__traceback__', None))")>]
30+
abstract member AExit: error: exn -> Task<bool>
31+
32+
[<RequireQualifiedAccess>]
33+
module AsyncContextManager =
34+
35+
/// Run the body within a Python asynchronous context manager, mirroring
36+
/// Python's `async with manager as resource: ...`.
37+
///
38+
/// `__aenter__()` is awaited to acquire the resource, `body` is run with it,
39+
/// and `__aexit__(...)` is awaited afterwards on both the success and error
40+
/// paths. If the body raises and `__aexit__` returns a truthy value the
41+
/// exception is suppressed (as `async with` does); otherwise it is re-raised.
42+
///
43+
/// ```fsharp
44+
/// task {
45+
/// let! rows =
46+
/// AsyncContextManager.using (pool.acquire ()) (fun conn ->
47+
/// task { return! conn.fetch "SELECT 1" })
48+
/// return rows
49+
/// }
50+
/// ```
51+
let using (manager: IAsyncContextManager<'T>) (body: 'T -> Task<'U>) : Task<'U> =
52+
task {
53+
let! resource = manager.AEnter()
54+
55+
try
56+
let! result = body resource
57+
let! _ = manager.AExit()
58+
return result
59+
with error ->
60+
let! suppress = manager.AExit(error)
61+
62+
if suppress then
63+
return Unchecked.defaultof<'U>
64+
else
65+
return raise error
66+
}

‎test/TestAsyncIO.fs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
module Fable.Python.Tests.AsyncIO
22

3+
open Fable.Core
34
open Fable.Python.Testing
45
open Fable.Python.AsyncIO
56

7+
/// asyncio.Lock is a stdlib async context manager: `async with lock` acquires it
8+
/// via __aenter__ and releases it via __aexit__.
9+
[<Import("Lock", "asyncio")>]
10+
type private Lock() =
11+
[<Emit("$0.locked()")>]
12+
member _.locked() : bool = nativeOnly
13+
614
[<Fact>]
715
let ``test builder run zero works`` () =
816
let tsk = task { () }
@@ -107,3 +115,43 @@ let ``test task with option result works`` () =
107115

108116
let result = asyncio.run tsk
109117
result |> equal (Some 42)
118+
119+
[<Fact>]
120+
let ``test async context manager enters and exits`` () =
121+
let lock = Lock()
122+
let cm = unbox<IAsyncContextManager<obj>> lock
123+
124+
let tsk =
125+
task {
126+
// Inside the body the lock is held (acquired by __aenter__)...
127+
let! insideLocked = AsyncContextManager.using cm (fun _ -> task { return lock.locked () })
128+
// ...and released again afterwards (by __aexit__).
129+
return insideLocked, lock.locked ()
130+
}
131+
132+
let inside, after = asyncio.run tsk
133+
inside |> equal true
134+
after |> equal false
135+
136+
[<Fact>]
137+
let ``test async context manager exits on error`` () =
138+
let lock = Lock()
139+
let cm = unbox<IAsyncContextManager<obj>> lock
140+
141+
let tsk =
142+
task {
143+
try
144+
let! _ =
145+
AsyncContextManager.using cm (fun _ ->
146+
task {
147+
do failwith "boom"
148+
return 0
149+
})
150+
151+
return false
152+
with _ ->
153+
// __aexit__ must have released the lock even though the body threw.
154+
return not (lock.locked ())
155+
}
156+
157+
asyncio.run tsk |> equal true

0 commit comments

Comments
 (0)