diff --git a/paket.dependencies b/paket.dependencies index 3106fad..b28ecb4 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -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 diff --git a/paket.lock b/paket.lock index 3e43c3b..33ff5e6 100644 --- a/paket.lock +++ b/paket.lock @@ -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 diff --git a/src/Fable.Python.fsproj b/src/Fable.Python.fsproj index 180e747..e657b9e 100644 --- a/src/Fable.Python.fsproj +++ b/src/Fable.Python.fsproj @@ -14,6 +14,7 @@ + diff --git a/src/stdlib/asyncio/ContextManager.fs b/src/stdlib/asyncio/ContextManager.fs new file mode 100644 index 0000000..2bbd76b --- /dev/null +++ b/src/stdlib/asyncio/ContextManager.fs @@ -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`. + [] + abstract member AEnter: unit -> Task<'T> + + /// `__aexit__(None, None, None)` — release after the body succeeded. + [] + abstract member AExit: unit -> Task + + /// `__aexit__(type(e), e, e.__traceback__)` — release after the body raised. + /// A truthy result means the exception was handled and should be suppressed. + [] + abstract member AExit: error: exn -> Task + +[] +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> = + 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 + } diff --git a/test/TestAsyncIO.fs b/test/TestAsyncIO.fs index 1be8cb5..2c58203 100644 --- a/test/TestAsyncIO.fs +++ b/test/TestAsyncIO.fs @@ -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__. +[] +type private Lock() = + [] + 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. +[] +type private TrackingCm(suppress: bool, raiseOnSuccessExit: bool) = + member val exits = 0 with get, set + + member this.``__aenter__``() : System.Threading.Tasks.Task = task { return box this } + + member this.``__aexit__``(excType: obj, exc: obj, tb: obj) : System.Threading.Tasks.Task = + task { + this.exits <- this.exits + 1 + + if raiseOnSuccessExit && isNull (box exc) then + failwith "exit boom" + + return suppress + } + [] let ``test builder run zero works`` () = let tsk = task { () } @@ -107,3 +135,106 @@ let ``test task with option result works`` () = let result = asyncio.run tsk result |> equal (Some 42) + +[] +let ``test async context manager enters and exits`` () = + let lock = Lock() + let cm = unbox> 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 + +[] +let ``test async context manager exits on error`` () = + let lock = Lock() + let cm = unbox> 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 + +[] +let ``test tryUsing suppresses error when exit returns true`` () = + let manager = TrackingCm(suppress = true, raiseOnSuccessExit = false) + let cm = unbox> 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) + +[] +let ``test using re-raises even when exit returns true`` () = + let manager = TrackingCm(suppress = true, raiseOnSuccessExit = false) + let cm = unbox> 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 + +[] +let ``test async context manager does not exit twice when success exit raises`` () = + let manager = TrackingCm(suppress = false, raiseOnSuccessExit = true) + let cm = unbox> 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