From a48bf9aafc91fd63db1f16c699acebb2b1a80dca Mon Sep 17 00:00:00 2001 From: Chris Rink Date: Wed, 9 Apr 2025 22:01:05 -0400 Subject: [PATCH 1/3] Support `async for` and `async with` constructs using macros --- CHANGELOG.md | 3 +++ src/basilisp/core.lpy | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 089fb0d9..d62b3155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index 09171512..84a33099 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -4085,6 +4085,55 @@ (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." + [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 [is-running?# true] + (when is-running?# + (try + (let [~bound-name (await (. it# ~'__anext__))] + ~@body) + (catch python/StopAsyncIteration _ + (recur false)))))))) + +(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)." + [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 ;; ;;;;;;;;;;;;;;;;;;;;;; From 4cda3f9694f50b933d2b068765e50260d0becff1 Mon Sep 17 00:00:00 2001 From: Chris Rink Date: Wed, 9 Apr 2025 22:09:33 -0400 Subject: [PATCH 2/3] Better? --- src/basilisp/core.lpy | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index 84a33099..f82d5d0e 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -4101,13 +4101,13 @@ (let [bound-name (first binding) iter (second binding)] `(let [it# (. ~iter ~'__aiter__)] - (loop [is-running?# true] - (when is-running?# - (try - (let [~bound-name (await (. it# ~'__anext__))] - ~@body) - (catch python/StopAsyncIteration _ - (recur false)))))))) + (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 From fd8e8680551117581ba68b0ed37622f80b8f0037 Mon Sep 17 00:00:00 2001 From: Chris Rink Date: Thu, 10 Apr 2025 21:19:55 -0400 Subject: [PATCH 3/3] Macro tests --- src/basilisp/core.lpy | 12 ++++++-- tests/basilisp/test_core_async_macros.lpy | 37 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/basilisp/test_core_async_macros.lpy diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index f82d5d0e..7b948f81 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -4091,7 +4091,11 @@ (defmacro afor "Repeatedly execute ``body`` while the binding name is repeatedly rebound to - successive values from the asynchronous iterable." + 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 @@ -4112,7 +4116,11 @@ (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)." + ``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)] diff --git a/tests/basilisp/test_core_async_macros.lpy b/tests/basilisp/test_core_async_macros.lpy new file mode 100644 index 00000000..85ee2cff --- /dev/null +++ b/tests/basilisp/test_core_async_macros.lpy @@ -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))))))