Skip to content
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

Adds support for :decorators in anon fns, defasync #1189

Merged
merged 4 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ 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 support for the `:decorators` meta key in anonymous `fn`s (#1178)

### Changed
* `import` now returns nil instead of the last module's string representation (#1174)
* The `:decorators` key now works in `defn` when passed as a metadata name key, expanding its support to `defn`-derived macros like `defasync` (#1178).

### Fixed
* Fix a bug in `defn` where the `attr-map?` and function metdata were merged into a seq instead of a map, causing `macroexpand` to fail in some cases (#1186)
Expand Down
63 changes: 62 additions & 1 deletion docs/pyinterop.rst
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,67 @@ Type hints may be applied to :lpy:form:`def` names, function arguments and retur
Return annotations are combined as by :external:py:obj:`typing.Union`, so ``typing.Union[str, str] == str``.
The annotations for individual arity arguments are preserved in their compiled form, but they are challenging to access programmatically.

.. _python_decorators:

Python Decorators
-----------------

Python decorators are functions that modify the behavior of other functions or methods. They are applied to a function by prefixing it with the `@decorator_name` syntax. A decorator takes a function as input, performs some action, and returns a new function that typically extends or alters the original function's behavior.

Basilisp offers a convenience `:decorators` metadata key to support Python-style decorators, which allows you to pass a vector of functions that wrap the final function emitted by the :lpy:fn:`fn` anonymous function, as well as by :lpy:fn:`defn` and its derivatives, such as :lpy:fn:`defasync`. These decorators are applied from right to left, similar to how Python decorators work, modifying the function's behavior before it is used.
ikappaki marked this conversation as resolved.
Show resolved Hide resolved

.. code-block:: clojure

(import asyncio atexit)

;;; defn support
;;
;; The following will print ":goodbye!" on program exit
(defn say-goodbye {:decorators [atexit/register]}
[]
(println :goodbye!))

;;; fn support
;;
;; example decorator
(defn add-5-decorator
[f]
(fn [] (+ (f) 5)))

;; Decorators passed to fn via form metadata
(^{:decorators [add-5-decorator]} (fn [] 6))
;; => 11

;; Decorators passed to fn via function name metadata
((fn ^{:decorators [add-5-decorator]} seven [] 7))
;; => 12

;;; Decorators with arguments, and order of application
;;
;; example decorator
(defn mult-x-decorator
[x]
(fn [f]
(fn [] (* (f) x))))

((fn ^{:decorators [add-5-decorator (mult-x-decorator -1)]} seven [] 7))
;; => -2

;;; defasync support
;;
;; example async decorator
(defn add-7-async-decorator
[f]
^:async (fn [] (+ (await (f)) 7)))

(defasync ^{:decorators [add-7-async-decorator]} six
[]
(await (asyncio/sleep 0.1))
6)

(asyncio/run (six))
;; => 13

ikappaki marked this conversation as resolved.
Show resolved Hide resolved
.. _arithmetic_division:

Arithmetic Division
Expand All @@ -279,4 +340,4 @@ Users still have the option to use the native :external:py:func:`operator.floord

.. seealso::

:lpy:fn:`quot`, :lpy:fn:`rem`, :lpy:fn:`mod`
:lpy:fn:`quot`, :lpy:fn:`rem`, :lpy:fn:`mod`
47 changes: 30 additions & 17 deletions src/basilisp/core.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,11 @@

After the name, an optional mapping of meta attributes may be provided.
Any metadata values given will be attached to the metadata of the
interned Var. A few special meta keys change how ``defn`` emits the
final Var and function:
interned Var, overriding any existing metadata associated with the
``name``.

A few special meta keys change how ``defn`` emits the final Var and
function. The processing is handled by :lpy:fn:`fn`:

- ``:decorators`` is an optional vector of functions which will wrap
the final function emitted by ``defn``. Like standard Python
Expand Down Expand Up @@ -411,15 +414,7 @@
{:found (first body)})))
(rest body)))

decorators (:decorators fmeta)
fn-body (if decorators
(loop [wrappers (seq (python/reversed decorators))
final `(fn ~fname ~@body)]
(if (seq wrappers)
(recur (rest wrappers)
`(~(first wrappers) ~final))
final))
`(fn ~fname ~@body))]
fn-body `(fn ~fname ~@body)]
`(def ~fname ~fn-body))))

(defn nth
Expand Down Expand Up @@ -6002,6 +5997,13 @@
(defmacro ^:no-warn-on-redef fn
"Return an anonymous (but possibly named) function.

A few special metadata keys change how ``fn`` emits the final function:

- ``:decorators`` is an optional vector of functions which will wrap
the final function emitted by ``fn``. Like standard Python
decorators and the ``comp`` function, decorators are applied to the
generated function from right to left.

Function argument vectors support sequential and associative :ref:`destructuring` .

See :lpy:form:`fn` for more details."
Expand All @@ -6017,12 +6019,23 @@
(apply list (map fn-arity-with-destructuring body))

:else
body)]
(as-> arities $
(cond->> $ name (cons name))
(cons 'fn* $)
(cond-> $
(meta &form) (with-meta (meta &form))))))
body)
fn-decl (as-> arities $
(cond->> $ name (cons name))
(cons 'fn* $)
(cond-> $
(meta &form) (with-meta (meta &form))))

fmeta (merge (meta fn-decl) (meta name))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the correct order for how it is done in Clojure? I am indifferent towards the order, but I'd rather not have another ticket later just because it's wrong.

Copy link
Contributor Author

@ikappaki ikappaki Dec 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very interesting point I did not consider.

To clarify, I believe you are asking about the merging order: should the fn name metadata override the form (fn-decl) metadata? Specifically, should the fn name metadata key :decorators take precedence over the :decorators key in the form metadata?

From a quick survey:

  1. defn behavior: Both Clojure and Basilisp pass metadata to the fn function name.
  2. Clojure fn behavior: The fn definition uses metadata keys from the params vector only for :pre and :post conditions.

Thus based on the above, there’s no defined precedence in Clojure between fn form and name metadata. This leaves the choice up to us.

For the fn form, there are three potential places for the :decorators metadata key:

  1. The fn form itself, i.e. ^{:decorators [...]} (fn ...)).
  2. The fn name (if provided), i.e. (fn ^{:decorators [...]} name ...).
  3. The fn params, `(fn ^{:decorators [...]} [param1 ...] ...)

Since defn passes its metadata to the fn name (option 2), this seems like a strong candidate for support. Conceptually, :decorators are tied to the function, so supporting the form metadata key (option 1) also makes sense.

Conceptually to me, decorators are linked to the function, so I think support for the key in the form metadata should also be supported.

If both are supported, my view is:

  • Specific overrides general: The function name metadata key should take precedence over the form metadata key.
  • This aligns with the idea of specificity: metadata inside the fn form is more targeted than metadata outside it (but I think you can also equally argue in the opposite direction 😅)

Supporting params metadata (option 3) would also be possible, but it complicates precedence rules unnecessarily.

I prefer supporting both form and name metadata keys, with precedence given to the fn name key. Alternatively, for simplicity, we could support only the form metadata key.

What is your view?

Thanks

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer supporting both form and name metadata keys, with precedence given to the fn name key. Alternatively, for simplicity, we could support only the form metadata key.

I agree that we should support form and name with preference for name metadata.

decorators (:decorators fmeta)]
(if decorators
(loop [wrappers (seq (python/reversed decorators))
final fn-decl]
(if (seq wrappers)
(recur (rest wrappers)
`(~(first wrappers) ~final))
final))
fn-decl)))

(defn destructure
"Take a ``[binding expr]`` pair (as from a ``let`` block) and produce all of the
Expand Down
52 changes: 49 additions & 3 deletions tests/basilisp/test_core_macros.lpy
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
(ns tests.basilisp.test-core-macros
(:import contextlib inspect os socket tempfile)
(:import asyncio contextlib inspect os socket tempfile)
(:require
[basilisp.string :as str]
[basilisp.test :refer [deftest is are testing]]))

(deftest fn-decorators-test
(testing "decorators"
(testing "in form meta"
(let [add-5% #(fn [] (+ (%) 5))
f1 ^{:decorators [add-5%]} (fn [] 7)]
(is (= 12 (f1)))))

(testing "in fn name meta"
(let [add-5% #(fn [] (+ (%) 5))
f2 (fn ^{:decorators [add-5%]} a-fn [] 13)]
(is (= 18 (f2)))))

(testing "with single arg"
(let [add-x% (fn [x] #(fn [] (+ (%) x)))
f3 (fn ^{:decorators [(add-x% 10)]} _f3 [] 7)]
(is (= 17 (f3)))))
ikappaki marked this conversation as resolved.
Show resolved Hide resolved

(testing "order"
(let [add-5% #(fn [] (+ (%) 5))
mult-x% (fn [x] #(fn [] (* (%) x)))
fvar (defn f4 {:decorators [add-5% (mult-x% -1)]} [] 9)]
(is (= -4 (f4)))))))

(deftest defn-test
(testing "single arity defn"
(testing "simple"
Expand Down Expand Up @@ -96,7 +119,13 @@
(is (= {:abc 10 :lmn 15 :xyz 11} (select-keys vmeta [:abc :lmn :xyz])))))

(testing "macroexpand with attr-map?"
(is (macroexpand '(defn fx {:abc 10} [] :kw)))))
(is (macroexpand '(defn fx {:abc 10} [] :kw))))

(testing "decorators"
(let [add-5% #(fn [] (+ (%) 5))
mult-x% (fn [x] #(fn [] (* (%) x)))
fvar (defn f12 {:decorators [add-5% (mult-x% -1)]} [] 9)]
(is (= -4 (f12))))))

(deftest defasync-test
(testing "single arity defasync"
Expand Down Expand Up @@ -189,7 +218,24 @@
(is (:async vmeta))
(is (inspect/iscoroutinefunction af8))
(is (= "0.1" (:added vmeta)))
(is (= "another multi-arity docstring" (:doc vmeta)))))))
(is (= "another multi-arity docstring" (:doc vmeta)))))

(testing "async decorators"
(testing "single"
(let [add-5% #(fn ^:async _ [] (+ (await (%)) 5))
fvar (defasync ^{:decorators [add-5%]} af9 [] 7)]
(is (= 12 (asyncio/run (af9))))))

(testing "single with arg"
(let [add-x% (fn [x] #(fn ^:async _ [] (+ (await (%)) x)))
fvar (defasync ^{:decorators [(add-x% 10)]} af10 [] 7)]
(is (= 17 (asyncio/run (af10))))))

(testing "order"
(let [add-5% #(fn ^:async _ [] (+ (await (%)) 5))
mult-x% (fn [x] #(fn ^:async _ [] (* (await (%)) x)))
fvar (defasync af11 {:decorators [add-5% (mult-x% -1)]} [] 9)]
(is (= -4 (asyncio/run (af11)))))))))

(deftest defn-private-test
(testing "single arity defn-"
Expand Down
Loading