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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added support for multi-arity methods on `definterface` (#538)
* Added support for Protocols (#460)
* Added support for Volatiles (#460)
* Add JSON encoder and decoder in `basilisp.json` namespace (#484)

### Fixed
* Fixed a bug where the Basilisp AST nodes for return values of `deftype` members could be marked as _statements_ rather than _expressions_, resulting in an incorrect `nil` return (#523)
Expand Down
15 changes: 14 additions & 1 deletion src/basilisp/core.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -3019,6 +3019,17 @@
~@(rest method-calls)))
x))

(defmacro memfn
"Expands into a function that calls the method `name` on the first argument
of the resulting function. If `args` are provided, the resulting function will
have arguments of these names.

This is a convenient way of producing a first-class function for a Python
method."
[name & args]
`(fn [t# ~@args]
(. t# ~name ~@args)))

(defmacro new
"Create a new instance of class with args.

Expand Down Expand Up @@ -3942,6 +3953,8 @@
(:import opts)))]
`(do
(in-ns (quote ~name))
~(when doc
`(alter-meta! (the-ns (quote ~name)) assoc :doc ~doc))
(refer-basilisp ~@refer-filters)
~requires
~uses
Expand Down Expand Up @@ -4831,7 +4844,7 @@
(->> (group-by first methods)
(reduce (fn [m [method-name arities]]
(->> (map rest arities)
(apply list `fn method-name)
Copy link
Member Author

Choose a reason for hiding this comment

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

If the extended method function uses the dispatch method name, then the compiler will add the local function name to the symbol table for that name and users will be unable to call the generic Protocol dispatch method.

(apply list `fn)
(assoc m (keyword (name method-name)))))
{})))

Expand Down
249 changes: 249 additions & 0 deletions src/basilisp/json.lpy
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
(ns basilisp.json
"JSON Encoder and Decoders

This namespace includes functions for performing basic JSON encoding from
and decoding to Basilisp builtin data structures. It is built on top of Python's
builtin `json` module. The builtin `json` module is not intended to be extended
in the way that is done here. As such, it is not the fastest JSON decoder or
encoder available, but it is builtin so it is readily available for quick
encoding and decoding needs."
(:refer-basilisp :exclude [read])
(:import
datetime
decimal
fractions
json
uuid))

;;;;;;;;;;;;;;
;; Encoders ;;
;;;;;;;;;;;;;;

(defprotocol JSONEncodeable
(to-json-encodeable* [this opts]
"Return an object which can be JSON encoded by Python's default JSONEncoder.

`opts` is a map with the following options:

`:key-fn` - is a function which will be called for each key in a map;
default is `name`"))

(extend-protocol JSONEncodeable
python/object
(to-json-encodeable* [this _]
(throw
(python/TypeError
(str "Cannot JSON encode objects of type " (python/type this))))))

(defn ^:private encodeable-scalar
[o _]
o)

(defn ^:private stringify-scalar
[o _]
(python/str o))

(defn ^:private encodeable-date-type
[o _]
(.isoformat o))

(defn ^:private kw-or-sym-to-encodeable
[o _]
(if-let [ns-str (namespace o)]
(str ns-str "/" (name o))
(name o)))

(defn ^:private map-to-encodeable
[o {:keys [key-fn] :as opts}]
(->> o
(map (fn [[k v]] [(key-fn k) v]))
(python/dict)))

(defn ^:private seq-to-encodeable
[o opts]
(->> o
(map #(to-json-encodeable* % opts))
(python/list)))

(extend python/str JSONEncodeable {:to-json-encodeable* encodeable-scalar})
(extend python/int JSONEncodeable {:to-json-encodeable* encodeable-scalar})
(extend python/float JSONEncodeable {:to-json-encodeable* encodeable-scalar})
(extend python/bool JSONEncodeable {:to-json-encodeable* encodeable-scalar})
(extend nil JSONEncodeable {:to-json-encodeable* encodeable-scalar})

(extend basilisp.lang.keyword/Keyword JSONEncodeable {:to-json-encodeable* kw-or-sym-to-encodeable})
(extend basilisp.lang.symbol/Symbol JSONEncodeable {:to-json-encodeable* kw-or-sym-to-encodeable})

(extend basilisp.lang.interfaces/IPersistentMap JSONEncodeable {:to-json-encodeable* map-to-encodeable})

(extend basilisp.lang.interfaces/IPersistentList JSONEncodeable {:to-json-encodeable* seq-to-encodeable})
(extend basilisp.lang.interfaces/IPersistentSet JSONEncodeable {:to-json-encodeable* seq-to-encodeable})
(extend basilisp.lang.interfaces/IPersistentVector JSONEncodeable {:to-json-encodeable* seq-to-encodeable})

;; Support extended reader types.
(extend datetime/datetime JSONEncodeable {:to-json-encodeable* encodeable-date-type})
(extend datetime/date JSONEncodeable {:to-json-encodeable* encodeable-date-type})
(extend datetime/time JSONEncodeable {:to-json-encodeable* encodeable-date-type})
(extend decimal/Decimal JSONEncodeable {:to-json-encodeable* stringify-scalar})
(extend fractions/Fraction JSONEncodeable {:to-json-encodeable* stringify-scalar})
(extend uuid/UUID JSONEncodeable {:to-json-encodeable* stringify-scalar})

;; Support Python types in case they are embedded in other Basilisp collections.
(extend python/dict JSONEncodeable {:to-json-encodeable* (fn [d opts] (map-to-encodeable (.items d) opts))})
(extend python/list JSONEncodeable {:to-json-encodeable* seq-to-encodeable})
(extend python/tuple JSONEncodeable {:to-json-encodeable* seq-to-encodeable})
(extend python/set JSONEncodeable {:to-json-encodeable* seq-to-encodeable})
(extend python/frozenset JSONEncodeable {:to-json-encodeable* seq-to-encodeable})

(defn ^:private write-opts
[{:keys [escape-non-ascii indent item-sep key-fn key-sep]}]
{:escape-non-ascii (if (boolean? escape-non-ascii) escape-non-ascii true)
:indent indent
:key-fn (or key-fn name)
:separator #py ((or item-sep ", ") (or key-sep ": "))})

(defn write
"Serialize the object `o` as JSON to the writer object `writer` (which must be
any file-like object supporting `.write()` method).

All data structures supported by the Basilisp reader are serialized to JSON
by default. Maps are serialized as JSON Objects. Lists, sets, and vectors are
serialized as JSON arrays. Keywords and symbols are serialized as strings with
their namespace (if they have one). Python scalar types are serialized as their
corresponding JSON types (string, integer, float, boolean, and `nil`). Instants
(Python `datetime`s) and the related Python `date` and `time` types are
serialized as ISO 8601 date strings. Decimals are serialized as stringified
floats. Fractions are serialized as stringified ratios (numerator and
denominator). UUIDs are serialized as their canonical hex string format.

Support for other data structures can be added by extending the `JSONEncodeable`
Protocol. That protocol includes one method which must return a Python data type
which can be understood by Python's builtin `json` module.

The encoder supports a few options which may be specified as key/value pairs:

`:key-fn` - is a function which will be called for each key in a map;
default is `name`
`:escape-non-ascii` - if `true`, escape non-ASCII characters in the output;
default is `true`
`:indent` - if `nil`, use a compact representation; if a positive
integer, each indent level will be that many spaces; if
zero, a negative integer, or the empty string, newlines
will be inserted without indenting; if a string, that
string value will be used as the indent
`:item-sep` - a string separator between object and array items;
default is ', '
`:key-sep` - a string separator between object key/value pairs;
default is ': '"
[o writer & {:as opts}]
(let [{:keys [escape-non-ascii indent separator] :as opts} (write-opts opts)]
(json/dump o writer **
:default #(to-json-encodeable* % opts)
:ensure-ascii escape-non-ascii
:indent indent
:separators separator)))

(defn write-str
"Serialize the object `o` as JSON and return the serialized object as a string.

All data structures supported by the Basilisp reader are serialized to JSON
by default. Maps are serialized as JSON Objects. Lists, sets, and vectors are
serialized as JSON arrays. Keywords and symbols are serialized as strings with
their namespace (if they have one). Python scalar types are serialized as their
corresponding JSON types (string, integer, float, boolean, and `nil`). Instants
(Python `datetime`s) and the related Python `date` and `time` types are
serialized as ISO 8601 date strings. Decimals are serialized as stringified
floats. Fractions are serialized as stringified ratios (numerator and
denominator). UUIDs are serialized as their canonical hex string format.

Support for other data structures can be added by extending the `JSONEncodeable`
Protocol. That protocol includes one method which must return a Python data type
which can be understood by Python's builtin `json` module.

The options for `write-str` are the same as for those of `write`."
[o & {:as opts}]
(let [{:keys [escape-non-ascii indent separator] :as opts} (write-opts opts)]
(json/dumps o **
:default #(to-json-encodeable* % opts)
:ensure-ascii escape-non-ascii
:indent indent
:separators separator)))

;;;;;;;;;;;;;;
;; Decoders ;;
;;;;;;;;;;;;;;

(defprotocol JSONDecodeable
(from-decoded-json* [this opts]
"Return a Basilisp object in place of a Python object returned by Python's
default JSONDecoder.

`opts` is a map with the following options:

`:key-fn` - is a function which will be called for each key in a map;
default is `identity`"))

(extend-protocol JSONDecodeable
python/dict
(from-decoded-json* [this {:keys [key-fn] :as opts}]
(->> (.items this)
(mapcat (fn [[k v]] [(key-fn k) (from-decoded-json* v opts)]))
(apply hash-map)))

python/list
(from-decoded-json* [this opts]
(->> this (map #(from-decoded-json* % opts)) (vec))))

(defn ^:private decode-scalar
[o _]
o)

(extend python/int JSONDecodeable {:from-decoded-json* decode-scalar})
(extend python/float JSONDecodeable {:from-decoded-json* decode-scalar})
(extend python/str JSONDecodeable {:from-decoded-json* decode-scalar})
(extend python/bool JSONDecodeable {:from-decoded-json* decode-scalar})
(extend nil JSONDecodeable {:from-decoded-json* decode-scalar})

(defn ^:private read-opts
[{:keys [key-fn strict?]}]
{:key-fn (or key-fn identity)
:strict (if (boolean? strict?) strict? true)})

;; Python's builtin `json.load` currently only includes an Object hook; it has
;; no hook for Array types. Due to this limitation, we have to iteratively
;; transform the entire parsed object into Basilisp data structures rather than
;; building the final object iteratively. There is an open bug report with
;; Python, but it has gotten no traction: https://bugs.python.org/issue36738

(defn read
"Decode the JSON-encoded stream from `reader` (which can be any Python file-like
object) into Basilisp data structures.

JSON Objects will be decoded as Basilisp maps. JSON Arrays will be decoded as
as Basilisp vectors. All other JSON data types will be decoded as the
corresponding Python types (strings, booleans, integers, floats, and `nil`).

The decoder supports a few options which may be specified as key/value pairs:

`:key-fn` - is a function which will be called for each key in a map;
default is `identity`
`:strict?` - boolean value; if `true`, control characters (characters in
ASCII 0-31 range) will be prohibited inside JSON strings;
default is `true`"
[reader & {:as opts}]
(let [{:keys [strict?] :as opts} (read-opts opts)]
(-> (json/load reader ** :strict strict?)
(from-decoded-json* opts))))

(defn read-str
"Decode the JSON-encoded string `s` into Basilisp data structures.

JSON Objects will be decoded as Basilisp maps. JSON Arrays will be decoded as
as Basilisp vectors. All other JSON data types will be decoded as the
corresponding Python types (strings, booleans, integers, floats, and `nil`).

The options for `read-str` are the same as for those of `read`."
[s & {:as opts}]
(let [{:keys [strict?] :as opts} (read-opts opts)]
(-> (json/loads s ** :strict strict?)
(from-decoded-json* opts))))
13 changes: 9 additions & 4 deletions src/basilisp/repl.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,20 @@
(defn pydoc
"Print the Python docstring for a function."
[o]
(print (inspect/getdoc o)))
(println (inspect/getdoc o)))

(defn print-doc
"Print the docstring from an interned var."
[v]
(let [var-meta (meta v)]
(if var-meta
(print (:doc var-meta))
nil)))
(println "------------------------")
(println (cond->> (name v)
(namespace v) (str (namespace v) "/")))
(when var-meta
(when-let [arglists (:arglists var-meta)]
(println arglists))
(when-let [docstring (:doc var-meta)]
(println " " docstring)))))

(defmacro doc
"Print the docstring from an interned Var if found."
Expand Down
1 change: 1 addition & 0 deletions tests/basilisp/prompt_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def patch_completions(self, completions: Iterable[str]):
"mapv",
"max",
"max-key",
"memfn",
"merge",
"meta",
"methods",
Expand Down
Loading