Skip to content

Commit a105061

Browse files
Add custom data readers #924 (#979)
Fixes #924 Hello, I managed to get the custom data readers feature implemented. Let me know if you have any question or would like to see any changes. Core Changes: - Added custom data readers, from files on the path as well as entry points. - Entry point groups can be customized or disabled with cli arguments - Added `*default-data-reader-fn*` - Changed the reader to allow any tag values like in Clojure, but restrict tags from loaded data readers to only qualified symbols. - Updated documentation with new features Other Changes: - Changed load functions to return the result of the last form evaluated as in Clojure. I was touching this function anyway and noticed the discrepancy. - Added `basilisp.core/read-seq`; it allows other namespaces to stop depending on `basilisp.lang.reader` directly. - Added `basilisp.contrib.pytest.fixtures` namespace with a `pytest.MonkeyPatch` test fixture to support testing this feature. - Added cli arguments to some subcommands that I noticed were missing. --------- Co-authored-by: Chris Rink <[email protected]>
1 parent ba0ec3a commit a105061

File tree

8 files changed

+414
-34
lines changed

8 files changed

+414
-34
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added
99
* Added the `CollReduce` and `KVReduce` protocols in `basilisp.core.protocols` and implemented `reduce` in terms of those protocols (#927)
10+
* Added support for custom data readers (#924)
11+
* Added `*default-data-reader-fn*` (#924)
1012
* Added `basilisp.pprint/print-table` function (#983)
1113
* Added `basilisp.core/read-all` function (#986)
1214

docs/reader.rst

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,32 @@ Python literals use the matching syntax to the corresponding Python data type, w
465465
* ``#py {}`` produces a Python `dict <https://docs.python.org/3/library/stdtypes.html#dict>`_ type.
466466
* ``#py #{}`` produces a Python `set <https://docs.python.org/3/library/stdtypes.html#set>`_ type.
467467

468+
.. _custom_data_readers:
469+
470+
Custom Data Readers
471+
^^^^^^^^^^^^^^^^^^^
472+
473+
`Like Clojure <https://clojure.org/reference/reader#tagged_literals>`_ , data readers can be changed by binding :lpy:var:`*data-readers*`.
474+
475+
When Basilisp starts it can load data readers from multiple sources.
476+
477+
It will search in :external:py:attr:`sys.path` for files named ``data_readers.lpy`` or else ``data_readers.cljc``; each which must contain a mapping of qualified symbol tags to qualified symbols of function vars.
478+
479+
.. code-block:: clojure
480+
{my/tag my.namespace/tag-handler}
481+
482+
It will also search for any :external:py:class:`importlib.metadata.EntryPoint` in the group ``basilisp_data_readers`` group.
483+
Entry points must refer to a map of data readers.
484+
This can be disabled by setting the ``BASILISP_USE_DATA_READERS_ENTRY_POINT`` environment variable to ``false``.
485+
486+
.. _default_data_reader_fn:
487+
488+
Default Data Reader Function
489+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
490+
By default, an exception will be raised if the reader encounters a tag that it doesn't have a data reader for.
491+
This can be customised by binding :lpy:var:`*default-data-readers-fn*`.
492+
It should be a function which is a function that takes two arguments, the tag symbol, and the value form.
493+
468494
.. _reader_special_chars:
469495

470496
Special Characters
@@ -592,4 +618,4 @@ Platform Reader Features
592618
Basilisp includes a specialized reader feature based on the current platform (Linux, MacOS, Windows, etc.).
593619
There exist cases where it may be required to use different APIs based on which platform is currently in use, so having a reader conditional to detect the current platform can simplify the development process across multiple platforms.
594620
The reader conditional name is always a keyword containing the lowercase version of the platform name as reported by ``platform.system()``.
595-
For example, if ``platform.system()`` returns the Python string ``"Windows"``, the platform specific reader conditional would be ``:windows``.
621+
For example, if ``platform.system()`` returns the Python string ``"Windows"``, the platform specific reader conditional would be ``:windows``.

src/basilisp/cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,29 @@ def _add_debug_arg_group(parser: argparse.ArgumentParser) -> None:
250250
)
251251

252252

253+
def _add_runtime_arg_group(parser: argparse.ArgumentParser) -> None:
254+
group = parser.add_argument_group(
255+
"runtime arguments",
256+
description=(
257+
"The runtime arguments below affect reader and execution time features."
258+
),
259+
)
260+
group.add_argument(
261+
"--data-readers-entry-points",
262+
action=_set_envvar_action(
263+
"BASILISP_USE_DATA_READERS_ENTRY_POINT", parent=argparse._StoreAction
264+
),
265+
nargs="?",
266+
const=_to_bool(os.getenv("BASILISP_USE_DATA_READERS_ENTRY_POINT", "true")),
267+
type=_to_bool,
268+
help=(
269+
"If true, Load data readers from importlib entry points in the "
270+
'"basilisp_data_readers" group. (env: '
271+
"BASILISP_USE_DATA_READERS_ENTRY_POINT; default: true)"
272+
),
273+
)
274+
275+
253276
Handler = Callable[[argparse.ArgumentParser, argparse.Namespace], None]
254277

255278

@@ -384,6 +407,8 @@ def _add_nrepl_server_subcommand(parser: argparse.ArgumentParser) -> None:
384407
default=".nrepl-port",
385408
help='the file path where the server port number is output to, defaults to ".nrepl-port".',
386409
)
410+
_add_runtime_arg_group(parser)
411+
_add_debug_arg_group(parser)
387412

388413

389414
def repl(
@@ -478,6 +503,7 @@ def _add_repl_subcommand(parser: argparse.ArgumentParser) -> None:
478503
help="default namespace to use for the REPL",
479504
)
480505
_add_compiler_arg_group(parser)
506+
_add_runtime_arg_group(parser)
481507
_add_debug_arg_group(parser)
482508

483509

@@ -588,6 +614,7 @@ def _add_run_subcommand(parser: argparse.ArgumentParser) -> None:
588614
help="command line args made accessible to the script as basilisp.core/*command-line-args*",
589615
)
590616
_add_compiler_arg_group(parser)
617+
_add_runtime_arg_group(parser)
591618
_add_debug_arg_group(parser)
592619

593620

@@ -612,6 +639,8 @@ def test(
612639
)
613640
def _add_test_subcommand(parser: argparse.ArgumentParser) -> None:
614641
parser.add_argument("args", nargs=-1)
642+
_add_runtime_arg_group(parser)
643+
_add_debug_arg_group(parser)
615644

616645

617646
def version(_, __) -> None:

src/basilisp/core.lpy

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4400,9 +4400,9 @@
44004400
[fmt & args]
44014401
(print (apply format fmt args)))
44024402

4403-
;;;;;;;;;;;;;;;;;;;;
4404-
;; REPL Utilities ;;
4405-
;;;;;;;;;;;;;;;;;;;;
4403+
;;;;;;;;;;;;;;;;;;;;;;;;;
4404+
;; Reading and Loading ;;
4405+
;;;;;;;;;;;;;;;;;;;;;;;;;
44064406

44074407
(def
44084408
^{:doc "The default data readers used in reader macros. Overriding or
@@ -4428,6 +4428,14 @@
44284428
*resolver*
44294429
basilisp.lang.runtime/resolve-alias)
44304430

4431+
(def ^{:doc "When no data reader is found for a tag and ``*default-data-reader-fn*``
4432+
is non-``nil``, it will be called with two arguments, the tag and the value.
4433+
If ``*default-data-reader-fn*`` is ``nil`` (the default), raise a
4434+
``basilisp.lang.compiler.SyntaxError``."
4435+
:dynamic true}
4436+
*default-data-reader-fn*
4437+
nil)
4438+
44314439
(defn- read-iterator
44324440
[opts x]
44334441
(let [read (:read opts basilisp.lang.reader/read)]
@@ -4457,8 +4465,9 @@
44574465
"Read the next form from the ``stream``\\. If no stream is specified, uses the value
44584466
currently bound to :lpy:var:`*in*`.
44594467

4460-
Callers may bind a map of readers to :lpy:var:`*data-readers*` to customize the data
4461-
readers used reading this string.
4468+
Callers may bind a map of readers to :lpy:var:`*data-readers*` or a default data
4469+
reader function to :lpy:var::`*default-data-reader-fn*` to customize the data
4470+
readers used reading this string
44624471

44634472
The stream must satisfy the interface of :external:py:class:`io.TextIOBase`\\, but
44644473
does not require any pushback capabilities. The default
@@ -4477,8 +4486,9 @@
44774486
"Eagerly read all forms from the ``stream``\\. If no stream is specified, uses the
44784487
value currently bound to :lpy:var:`*in*`.
44794488

4480-
Callers may bind a map of readers to :lpy:var:`*data-readers*` to customize
4481-
the data readers used reading this string
4489+
Callers may bind a map of readers to :lpy:var:`*data-readers*` or a default data
4490+
reader function to :lpy:var::`*default-data-reader-fn*` to customize the data
4491+
readers used reading this string
44824492

44834493
The stream must satisfy the interface of :external:py:class:`io.TextIOBase`\\, but
44844494
does not require any pushback capabilities. The default
@@ -7024,6 +7034,94 @@
70247034
(.write writer content)
70257035
nil))
70267036

7037+
;;;;;;;;;;;;;;;;;;;;;;;;;
7038+
;; Custom Data Readers ;;
7039+
;;;;;;;;;;;;;;;;;;;;;;;;;
7040+
7041+
(defmulti ^:private make-custom-data-readers
7042+
(fn [obj metadata] (type obj)))
7043+
7044+
(defmethod make-custom-data-readers :default
7045+
[obj mta]
7046+
(throw (ex-info "Not a valid data-reader map" (assoc mta :object obj))))
7047+
7048+
(defmethod make-custom-data-readers basilisp.lang.interfaces/IPersistentMap
7049+
[mappings mta]
7050+
(reduce (fn [m [k v]]
7051+
(let [v' (if (qualified-symbol? v)
7052+
(intern (create-ns (symbol (namespace v)))
7053+
(symbol (name v)))
7054+
v)]
7055+
(cond
7056+
(not (qualified-symbol? k))
7057+
(throw
7058+
(ex-info "Invalid tag in data-readers. Expected qualified symbol."
7059+
(merge mta {:form k})))
7060+
7061+
(not (ifn? v'))
7062+
(throw (ex-info "Invalid reader function in data-readers"
7063+
(merge mta {:form v})))
7064+
7065+
:else
7066+
(assoc m (with-meta k mta) v'))))
7067+
mappings
7068+
mappings))
7069+
7070+
(defmethod make-custom-data-readers importlib.metadata/EntryPoint
7071+
[entry-point mta]
7072+
(make-custom-data-readers (.load entry-point)
7073+
(assoc mta
7074+
:basilisp.entry-point/name (.-name entry-point)
7075+
:basilisp.entry-point/group (.-group entry-point))))
7076+
7077+
(defmethod make-custom-data-readers pathlib/Path
7078+
[file mta]
7079+
(make-custom-data-readers
7080+
(with-open [rdr (basilisp.io/reader file)]
7081+
(read (if (.endswith (name file) "cljc")
7082+
{:eof nil :read-cond :allow}
7083+
{:eof nil})
7084+
rdr))
7085+
(assoc mta :file (str file))))
7086+
7087+
(defn- data-readers-entry-points []
7088+
(when (#{"true" "t" "1" "yes" "y"} (.lower
7089+
(os/getenv
7090+
"BASILISP_USE_DATA_READERS_ENTRY_POINT"
7091+
"true")))
7092+
(#?@(:lpy39- [get (.entry_points importlib/metadata)]
7093+
:lpy310+ [.entry_points importlib/metadata ** :group])
7094+
"basilisp_data_readers")))
7095+
7096+
(defn- data-readers-files []
7097+
(->> sys/path
7098+
(mapcat file-seq)
7099+
(filter (comp #{"data_readers.lpy" "data_readers.cljc"} name))
7100+
(group-by #(.-parent %))
7101+
vals
7102+
;; Only load one data readers file per directory and prefer
7103+
;; `data_readers.lpy` to `data_readers.cljc`
7104+
(map #(first (sort-by name > %)))))
7105+
7106+
(defn- load-data-readers []
7107+
(alter-var-root
7108+
#'*data-readers*
7109+
(fn [mappings additional-mappings]
7110+
(reduce (fn [m [k v]]
7111+
(if (not= (get m k v) v)
7112+
(throw (ex-info "Conflicting data-reader mapping"
7113+
(merge (meta k) {:conflict k, :mappings m})))
7114+
(assoc m k v)))
7115+
mappings
7116+
additional-mappings))
7117+
;; Can't use `read` when altering `*data-readers*` so do reads ahead of time
7118+
(->> (concat (data-readers-files)
7119+
(data-readers-entry-points))
7120+
(mapcat #(make-custom-data-readers % nil))
7121+
doall)))
7122+
7123+
(load-data-readers)
7124+
70277125
;;;;;;;;;;;;;;;;;
70287126
;; Transducers ;;
70297127
;;;;;;;;;;;;;;;;;

0 commit comments

Comments
 (0)