Skip to content

Support async for and async with constructs using macros #1226

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 11, 2025
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
* Added `afor` and `awith` macros to support async Python interop (#1179, #1181)

### Changed
* Single arity functions can be tagged with `^:allow-unsafe-names` to preserve their parameter names (#1212)

Expand Down
57 changes: 57 additions & 0 deletions src/basilisp/core.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -4085,6 +4085,63 @@
(finally
(println (* 1000 (- (perf-counter) start#)) "msecs")))))

;;;;;;;;;;;;;;;;;;
;; Async Macros ;;
;;;;;;;;;;;;;;;;;;

(defmacro afor
"Repeatedly execute ``body`` while the binding name is repeatedly rebound to
successive values from the asynchronous iterable.

.. warning::

The ``afor`` macro may only be used in an asynchronous function context."
[binding & body]
(if (operator/ne 2 (count binding))
(throw
(ex-info "bindings take the form [name iter]"
{:bindings binding}))
nil)
(let [bound-name (first binding)
iter (second binding)]
`(let [it# (. ~iter ~'__aiter__)]
(loop [val# nil]
(try
(let [~bound-name (await (. it# ~'__anext__))
result# (do ~@body)]
(recur result#))
(catch python/StopAsyncIteration _
val#))))))

(defmacro awith
"Evaluate ``body`` within a ``try`` / ``except`` expression, binding the named
expressions as per Python's async context manager protocol spec (Python's
``async with`` blocks).

.. warning::

The ``awith`` macro may only be used in an asynchronous function context."
[bindings & body]
(let [binding (first bindings)
expr (second bindings)]
`(let [obj# ~expr
~binding (await (. obj# ~'__aenter__))
hit-except# (volatile! false)]
(try
(let [res# ~@(if (nthnext bindings 2)
[(concat
(list 'awith (vec (nthrest bindings 2)))
body)]
(list (concat '(do) body)))]
res#)
(catch python/Exception e#
(vreset! hit-except# true)
(when-not (await (. obj# (~'__aexit__ (python/type e#) e# (.- e# ~'__traceback__))))
(throw e#)))
(finally
(when-not @hit-except#
(await (. obj# (~'__aexit__ nil nil nil)))))))))

;;;;;;;;;;;;;;;;;;;;;;
;; Threading Macros ;;
;;;;;;;;;;;;;;;;;;;;;;
Expand Down
37 changes: 37 additions & 0 deletions tests/basilisp/test_core_async_macros.lpy
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
(ns tests.basilisp.test-core-async-macros
(:import asyncio contextlib)
(:require
[basilisp.test :refer [deftest is are testing]]))

(defn async-to-sync
[f & args]
(let [loop (asyncio/new-event-loop)]
(asyncio/set-event-loop loop)
(.run-until-complete loop (apply f args))))

(deftest awith-test
(testing "base case"
(let [get-val (contextlib/asynccontextmanager
(fn ^:async get-val
[]
(yield :async-val)))
val-ctxmgr (fn ^:async yield-val
[]
(awith [v (get-val)]
v))]
(is (= :async-val (async-to-sync val-ctxmgr))))))

(deftest afor-test
(testing "base case"
(let [get-vals (fn ^:async get-vals
[]
(dotimes [n 5]
(yield n)))
val-loop (fn ^:async val-loop
[]
(let [a (atom [])
res (afor [v (get-vals)]
(swap! a conj v)
v)]
[@a res]))]
(is (= [[0 1 2 3 4] 4] (async-to-sync val-loop))))))