Skip to content

Commit 8c7cc90

Browse files
dbrattliclaude
andauthored
feat(asyncio): support Python async context managers (#329)
* 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> * fix(asyncio): await __aexit__ once; split using/tryUsing 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0cc1661 commit 8c7cc90

5 files changed

Lines changed: 243 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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
/// Acquire the resource, run the body, and await `__aexit__` exactly once.
36+
/// Returns `Ok result` when the body succeeds, or `Error (error, suppress)`
37+
/// when it raises — where `suppress` is the truthy/falsy value `__aexit__`
38+
/// returned for that error.
39+
///
40+
/// The body's outcome is captured in an inner `task` so that `__aexit__` is
41+
/// awaited *outside* the `try`. Awaiting the success-path `__aexit__` inside
42+
/// the `try` would route an exception it raises into the error branch and
43+
/// call `__aexit__` a second time — Python's `async with` never does this.
44+
let private run (manager: IAsyncContextManager<'T>) (body: 'T -> Task<'U>) : Task<Result<'U, exn * bool>> =
45+
task {
46+
let! resource = manager.AEnter()
47+
48+
let! outcome =
49+
task {
50+
try
51+
let! result = body resource
52+
return Ok result
53+
with error ->
54+
return Error error
55+
}
56+
57+
match outcome with
58+
| Ok result ->
59+
let! _ = manager.AExit()
60+
return Ok result
61+
| Error error ->
62+
let! suppress = manager.AExit(error)
63+
return Error(error, suppress)
64+
}
65+
66+
/// Run the body within a Python asynchronous context manager, mirroring
67+
/// Python's `async with manager as resource: ...`.
68+
///
69+
/// `__aenter__()` is awaited to acquire the resource, `body` is run with it,
70+
/// and `__aexit__(...)` is awaited afterwards on both the success and error
71+
/// paths. If the body raises, the exception is **always re-raised** after
72+
/// `__aexit__` runs — a truthy `__aexit__` return is ignored so the result
73+
/// type stays a plain `'U`. Use `tryUsing` if you need to honor suppression.
74+
///
75+
/// ```fsharp
76+
/// task {
77+
/// let! rows =
78+
/// AsyncContextManager.using (pool.acquire ()) (fun conn ->
79+
/// task { return! conn.fetch "SELECT 1" })
80+
/// return rows
81+
/// }
82+
/// ```
83+
let using (manager: IAsyncContextManager<'T>) (body: 'T -> Task<'U>) : Task<'U> =
84+
task {
85+
let! outcome = run manager body
86+
87+
match outcome with
88+
| Ok result -> return result
89+
| Error(error, _) -> return raise error
90+
}
91+
92+
/// Like `using`, but honors `__aexit__`'s suppression signal — matching the
93+
/// full `async with` contract.
94+
///
95+
/// Returns `Some result` when the body succeeds. If the body raises and
96+
/// `__aexit__` returns a truthy value (the exception is handled), returns
97+
/// `None`; otherwise the exception is re-raised.
98+
let tryUsing (manager: IAsyncContextManager<'T>) (body: 'T -> Task<'U>) : Task<'U option> =
99+
task {
100+
let! outcome = run manager body
101+
102+
match outcome with
103+
| Ok result -> return Some result
104+
| Error(error, suppress) ->
105+
if suppress then
106+
return None
107+
else
108+
return raise error
109+
}

test/TestAsyncIO.fs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
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+
14+
/// A minimal hand-written async context manager used to exercise the branches
15+
/// that stdlib's asyncio.Lock never hits: __aexit__ returning a truthy value
16+
/// (suppress the exception) and __aexit__ raising on the success path.
17+
/// `exits` counts how many times __aexit__ was awaited.
18+
[<AttachMembers>]
19+
type private TrackingCm(suppress: bool, raiseOnSuccessExit: bool) =
20+
member val exits = 0 with get, set
21+
22+
member this.``__aenter__``() : System.Threading.Tasks.Task<obj> = task { return box this }
23+
24+
member this.``__aexit__``(excType: obj, exc: obj, tb: obj) : System.Threading.Tasks.Task<bool> =
25+
task {
26+
this.exits <- this.exits + 1
27+
28+
if raiseOnSuccessExit && isNull (box exc) then
29+
failwith "exit boom"
30+
31+
return suppress
32+
}
33+
634
[<Fact>]
735
let ``test builder run zero works`` () =
836
let tsk = task { () }
@@ -107,3 +135,106 @@ let ``test task with option result works`` () =
107135

108136
let result = asyncio.run tsk
109137
result |> equal (Some 42)
138+
139+
[<Fact>]
140+
let ``test async context manager enters and exits`` () =
141+
let lock = Lock()
142+
let cm = unbox<IAsyncContextManager<obj>> lock
143+
144+
let tsk =
145+
task {
146+
// Inside the body the lock is held (acquired by __aenter__)...
147+
let! insideLocked = AsyncContextManager.using cm (fun _ -> task { return lock.locked () })
148+
// ...and released again afterwards (by __aexit__).
149+
return insideLocked, lock.locked ()
150+
}
151+
152+
let inside, after = asyncio.run tsk
153+
inside |> equal true
154+
after |> equal false
155+
156+
[<Fact>]
157+
let ``test async context manager exits on error`` () =
158+
let lock = Lock()
159+
let cm = unbox<IAsyncContextManager<obj>> lock
160+
161+
let tsk =
162+
task {
163+
try
164+
let! _ =
165+
AsyncContextManager.using cm (fun _ ->
166+
task {
167+
do failwith "boom"
168+
return 0
169+
})
170+
171+
return false
172+
with _ ->
173+
// __aexit__ must have released the lock even though the body threw.
174+
return not (lock.locked ())
175+
}
176+
177+
asyncio.run tsk |> equal true
178+
179+
[<Fact>]
180+
let ``test tryUsing suppresses error when exit returns true`` () =
181+
let manager = TrackingCm(suppress = true, raiseOnSuccessExit = false)
182+
let cm = unbox<IAsyncContextManager<obj>> manager
183+
184+
let tsk =
185+
task {
186+
// Body raises, but __aexit__ returns true: tryUsing honors the
187+
// suppression and yields None instead of re-raising.
188+
let! result =
189+
AsyncContextManager.tryUsing cm (fun _ ->
190+
task {
191+
do failwith "boom"
192+
return 0
193+
})
194+
195+
return result, manager.exits
196+
}
197+
198+
// No exception escapes, result is None, and __aexit__ ran exactly once.
199+
asyncio.run tsk |> equal (None, 1)
200+
201+
[<Fact>]
202+
let ``test using re-raises even when exit returns true`` () =
203+
let manager = TrackingCm(suppress = true, raiseOnSuccessExit = false)
204+
let cm = unbox<IAsyncContextManager<obj>> manager
205+
206+
let tsk =
207+
task {
208+
try
209+
// `using` always re-raises, ignoring the truthy __aexit__ return.
210+
let! _ =
211+
AsyncContextManager.using cm (fun _ ->
212+
task {
213+
do failwith "boom"
214+
return 0
215+
})
216+
217+
return -1
218+
with _ ->
219+
return manager.exits
220+
}
221+
222+
asyncio.run tsk |> equal 1
223+
224+
[<Fact>]
225+
let ``test async context manager does not exit twice when success exit raises`` () =
226+
let manager = TrackingCm(suppress = false, raiseOnSuccessExit = true)
227+
let cm = unbox<IAsyncContextManager<obj>> manager
228+
229+
let tsk =
230+
task {
231+
try
232+
// Body succeeds; the success-path __aexit__ raises. That error must
233+
// propagate without __aexit__ being invoked a second time.
234+
let! _ = AsyncContextManager.using cm (fun _ -> task { return 0 })
235+
return -1
236+
with _ ->
237+
return manager.exits
238+
}
239+
240+
asyncio.run tsk |> equal 1

0 commit comments

Comments
 (0)