Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion paket.dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ source https://api.nuget.org/v3/index.json
storage: none
framework: netstandard2.0, netstandard2.1, net6.0, net8.0, net9.0, net10.0

nuget FSharp.Core >= 5.0.0 lowest_matching: true
nuget FSharp.Core >= 6.0.0 lowest_matching: true
nuget Fable.Core >= 5.0.0 lowest_matching: true

group Test
Expand Down
2 changes: 1 addition & 1 deletion paket.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ NUGET
remote: https://api.nuget.org/v3/index.json
Fable.Core (5.0)
FSharp.Core (>= 4.7.2)
FSharp.Core (5.0)
FSharp.Core (6.0)

GROUP Examples
STORAGE: NONE
Expand Down
1 change: 1 addition & 0 deletions src/Fable.Python.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<Compile Include="stdlib/asyncio/Futures.fs" />
<Compile Include="stdlib/asyncio/Events.fs" />
<Compile Include="stdlib/asyncio/Tasks.fs" />
<Compile Include="stdlib/asyncio/ContextManager.fs" />
<Compile Include="stdlib/Ast.fs" />
<Compile Include="stdlib/Base64.fs" />
<Compile Include="stdlib/Builtins.fs" />
Expand Down
109 changes: 109 additions & 0 deletions src/stdlib/asyncio/ContextManager.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Bindings for Python asynchronous context managers (the `async with` protocol:
// __aenter__ / __aexit__).
//
// F# `use`/`use!` only supports IDisposable (synchronous Dispose), so there is
// no built-in way to consume a Python async context manager, and `async with`
// cannot be expressed directly because F# `task`/`async` cannot `await` inside a
// `finally`. Cast a value returning such an object to IAsyncContextManager and
// drive the protocol from a `task { }` by awaiting __aexit__ on the success and
// error paths (never in a finalizer) — see AsyncContextManager.using.
namespace Fable.Python.AsyncIO

open System.Threading.Tasks
open Fable.Core

/// A Python asynchronous context manager: an object implementing `__aenter__`
/// and `__aexit__`. Bind (or unbox) library values returning such objects to
/// this interface to drive the `async with` protocol from F#.
type IAsyncContextManager<'T> =
/// `__aenter__()` — acquire the resource. Await the result in a `task`.
[<Emit("$0.__aenter__()")>]
abstract member AEnter: unit -> Task<'T>

/// `__aexit__(None, None, None)` — release after the body succeeded.
[<Emit("$0.__aexit__(None, None, None)")>]
abstract member AExit: unit -> Task<bool>

/// `__aexit__(type(e), e, e.__traceback__)` — release after the body raised.
/// A truthy result means the exception was handled and should be suppressed.
[<Emit("$0.__aexit__(type($1), $1, getattr($1, '__traceback__', None))")>]
abstract member AExit: error: exn -> Task<bool>

[<RequireQualifiedAccess>]
module AsyncContextManager =

/// Acquire the resource, run the body, and await `__aexit__` exactly once.
/// Returns `Ok result` when the body succeeds, or `Error (error, suppress)`
/// when it raises — where `suppress` is the truthy/falsy value `__aexit__`
/// returned for that error.
///
/// The body's outcome is captured in an inner `task` so that `__aexit__` is
/// awaited *outside* the `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.
let private run (manager: IAsyncContextManager<'T>) (body: 'T -> Task<'U>) : Task<Result<'U, exn * bool>> =
task {
let! resource = manager.AEnter()

let! outcome =
task {
try
let! result = body resource
return Ok result
with error ->
return Error error
}

match outcome with
| Ok result ->
let! _ = manager.AExit()
return Ok result
| Error error ->
let! suppress = manager.AExit(error)
return Error(error, suppress)
}

/// Run the body within a Python asynchronous context manager, mirroring
/// Python's `async with manager as resource: ...`.
///
/// `__aenter__()` is awaited to acquire the resource, `body` is run with it,
/// and `__aexit__(...)` is awaited afterwards on both the success and error
/// paths. If the body raises, the exception is **always re-raised** after
/// `__aexit__` runs — a truthy `__aexit__` return is ignored so the result
/// type stays a plain `'U`. Use `tryUsing` if you need to honor suppression.
///
/// ```fsharp
/// task {
/// let! rows =
/// AsyncContextManager.using (pool.acquire ()) (fun conn ->
/// task { return! conn.fetch "SELECT 1" })
/// return rows
/// }
/// ```
let using (manager: IAsyncContextManager<'T>) (body: 'T -> Task<'U>) : Task<'U> =
task {
let! outcome = run manager body

match outcome with
| Ok result -> return result
| Error(error, _) -> return raise error
}

/// Like `using`, but honors `__aexit__`'s suppression signal — matching the
/// full `async with` contract.
///
/// Returns `Some result` when the body succeeds. If the body raises and
/// `__aexit__` returns a truthy value (the exception is handled), returns
/// `None`; otherwise the exception is re-raised.
let tryUsing (manager: IAsyncContextManager<'T>) (body: 'T -> Task<'U>) : Task<'U option> =
task {
let! outcome = run manager body

match outcome with
| Ok result -> return Some result
| Error(error, suppress) ->
if suppress then
return None
else
return raise error
}
131 changes: 131 additions & 0 deletions test/TestAsyncIO.fs
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
module Fable.Python.Tests.AsyncIO

open Fable.Core
open Fable.Python.Testing
open Fable.Python.AsyncIO

/// asyncio.Lock is a stdlib async context manager: `async with lock` acquires it
/// via __aenter__ and releases it via __aexit__.
[<Import("Lock", "asyncio")>]
type private Lock() =
[<Emit("$0.locked()")>]
member _.locked() : bool = nativeOnly

/// A minimal hand-written async context manager used to exercise the branches
/// that stdlib's asyncio.Lock never hits: __aexit__ returning a truthy value
/// (suppress the exception) and __aexit__ raising on the success path.
/// `exits` counts how many times __aexit__ was awaited.
[<AttachMembers>]
type private TrackingCm(suppress: bool, raiseOnSuccessExit: bool) =
member val exits = 0 with get, set

member this.``__aenter__``() : System.Threading.Tasks.Task<obj> = task { return box this }

member this.``__aexit__``(excType: obj, exc: obj, tb: obj) : System.Threading.Tasks.Task<bool> =
task {
this.exits <- this.exits + 1

if raiseOnSuccessExit && isNull (box exc) then
failwith "exit boom"

return suppress
}

[<Fact>]
let ``test builder run zero works`` () =
let tsk = task { () }
Expand Down Expand Up @@ -107,3 +135,106 @@ let ``test task with option result works`` () =

let result = asyncio.run tsk
result |> equal (Some 42)

[<Fact>]
let ``test async context manager enters and exits`` () =
let lock = Lock()
let cm = unbox<IAsyncContextManager<obj>> lock

let tsk =
task {
// Inside the body the lock is held (acquired by __aenter__)...
let! insideLocked = AsyncContextManager.using cm (fun _ -> task { return lock.locked () })
// ...and released again afterwards (by __aexit__).
return insideLocked, lock.locked ()
}

let inside, after = asyncio.run tsk
inside |> equal true
after |> equal false

[<Fact>]
let ``test async context manager exits on error`` () =
let lock = Lock()
let cm = unbox<IAsyncContextManager<obj>> lock

let tsk =
task {
try
let! _ =
AsyncContextManager.using cm (fun _ ->
task {
do failwith "boom"
return 0
})

return false
with _ ->
// __aexit__ must have released the lock even though the body threw.
return not (lock.locked ())
}

asyncio.run tsk |> equal true

[<Fact>]
let ``test tryUsing suppresses error when exit returns true`` () =
let manager = TrackingCm(suppress = true, raiseOnSuccessExit = false)
let cm = unbox<IAsyncContextManager<obj>> manager

let tsk =
task {
// Body raises, but __aexit__ returns true: tryUsing honors the
// suppression and yields None instead of re-raising.
let! result =
AsyncContextManager.tryUsing cm (fun _ ->
task {
do failwith "boom"
return 0
})

return result, manager.exits
}

// No exception escapes, result is None, and __aexit__ ran exactly once.
asyncio.run tsk |> equal (None, 1)

[<Fact>]
let ``test using re-raises even when exit returns true`` () =
let manager = TrackingCm(suppress = true, raiseOnSuccessExit = false)
let cm = unbox<IAsyncContextManager<obj>> manager

let tsk =
task {
try
// `using` always re-raises, ignoring the truthy __aexit__ return.
let! _ =
AsyncContextManager.using cm (fun _ ->
task {
do failwith "boom"
return 0
})

return -1
with _ ->
return manager.exits
}

asyncio.run tsk |> equal 1

[<Fact>]
let ``test async context manager does not exit twice when success exit raises`` () =
let manager = TrackingCm(suppress = false, raiseOnSuccessExit = true)
let cm = unbox<IAsyncContextManager<obj>> manager

let tsk =
task {
try
// Body succeeds; the success-path __aexit__ raises. That error must
// propagate without __aexit__ being invoked a second time.
let! _ = AsyncContextManager.using cm (fun _ -> task { return 0 })
return -1
with _ ->
return manager.exits
}

asyncio.run tsk |> equal 1